1#![deny(missing_docs)]
19
20use chrono::{DateTime, Utc};
21use onetaskgraph_plugin_api::{
22 Capabilities, Cursor, DependencyEdge, DependencyKind, DependencySupport, Direction, Health,
23 Label, NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver,
24 SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
25 TaskSource,
26};
27use schemars::{Schema, schema_for};
28use secrecy::{ExposeSecret, SecretString};
29use serde::Deserialize;
30use serde_json::{Value, json};
31
32pub const KIND: &str = "linear";
34const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
35
36pub mod graphql {
41 pub const VIEWER: &str = "query { viewer { id } }";
43 pub const ISSUE: &str = "query($id:String!){ issue(id:$id){ id title description url createdAt updatedAt state{name type} labels{nodes{id name color}} project{id} } }";
45 pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name description url createdAt updatedAt status{name type} labels{nodes{id name color}} } }";
47 pub const ISSUES: &str = "query($first:Int!,$after:String,$filter:IssueFilter){ issues(first:$first,after:$after,filter:$filter){ nodes{id title description url createdAt updatedAt state{name type} labels{nodes{id name color}} project{id}} pageInfo{hasNextPage endCursor} } }";
49 pub const PROJECTS: &str = "query($first:Int!,$after:String,$filter:ProjectFilter){ projects(first:$first,after:$after,filter:$filter){ nodes{id name description url createdAt updatedAt status{name type} labels{nodes{id name color}}} pageInfo{hasNextPage endCursor} } }";
51 pub const LABELS: &str = "query($first:Int!,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
53 pub const ISSUE_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ issue(id:$id){ relations(first:$first,after:$after){nodes{type relatedIssue{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{type issue{id}} pageInfo{hasNextPage endCursor}} } }";
55 pub const PROJECT_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ relations(first:$first,after:$after){nodes{type relatedProject{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{type project{id}} pageInfo{hasNextPage endCursor}} } }";
57}
58
59use graphql::{
60 ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS, PROJECTS, VIEWER,
61};
62
63#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
65#[serde(default, deny_unknown_fields)]
66pub struct LinearConfig {
67 #[schemars(with = "String")]
69 api_key_env: EnvName,
70 #[schemars(with = "Option<String>")]
72 team: Option<Team>,
73 #[schemars(with = "String")]
75 endpoint: Endpoint,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79#[serde(try_from = "String")]
80struct EnvName(String);
81impl TryFrom<String> for EnvName {
82 type Error = String;
83 fn try_from(value: String) -> Result<Self, Self::Error> {
84 let mut bytes = value.bytes();
85 if bytes
86 .next()
87 .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
88 && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
89 {
90 Ok(Self(value))
91 } else {
92 Err("must be an uppercase environment-variable name".into())
93 }
94 }
95}
96#[derive(Debug, Clone, Deserialize)]
97#[serde(try_from = "String")]
98struct Team(String);
99impl TryFrom<String> for Team {
100 type Error = String;
101 fn try_from(value: String) -> Result<Self, Self::Error> {
102 if value.trim().is_empty() {
103 Err("must not be empty".into())
104 } else {
105 Ok(Self(value))
106 }
107 }
108}
109#[derive(Debug, Clone, Deserialize)]
110#[serde(try_from = "String")]
111struct Endpoint(String);
112impl TryFrom<String> for Endpoint {
113 type Error = String;
114 fn try_from(value: String) -> Result<Self, Self::Error> {
115 let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
116 if matches!(url.scheme(), "http" | "https") {
117 Ok(Self(value))
118 } else {
119 Err("must use http or https".into())
120 }
121 }
122}
123
124impl Default for LinearConfig {
125 fn default() -> Self {
126 Self {
127 api_key_env: EnvName("LINEAR_API_KEY".into()),
128 team: None,
129 endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, Default)]
136pub struct Plugin;
137
138impl SourcePlugin for Plugin {
139 fn kind(&self) -> &'static str {
140 KIND
141 }
142 fn config_schema(&self) -> Schema {
143 schema_for!(LinearConfig)
144 }
145 fn build(
146 &self,
147 name: &SourceName,
148 config: &Value,
149 secrets: &dyn SecretResolver,
150 ) -> Result<Box<dyn TaskSource>, SourceError> {
151 let config: LinearConfig =
152 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
153 message: format!("source {name}: {e}"),
154 })?;
155 let key = secrets
156 .get(&config.api_key_env.0)
157 .filter(|v| !v.expose_secret().trim().is_empty())
158 .ok_or_else(|| SourceError::Auth {
159 message: format!("set environment variable {}", config.api_key_env.0),
160 })?;
161 Ok(Box::new(LinearSource {
162 client: reqwest::Client::new(),
163 endpoint: config.endpoint,
164 key,
165 team: config.team,
166 }))
167 }
168}
169
170struct LinearSource {
171 client: reqwest::Client,
172 endpoint: Endpoint,
173 key: SecretString,
174 team: Option<Team>,
175}
176
177#[derive(Deserialize)]
178struct Envelope {
179 data: Option<Value>,
181 #[serde(default)]
182 errors: Vec<GqlError>,
183}
184#[derive(Deserialize)]
185struct GqlError {
186 message: String,
187 extensions: Option<GqlExtensions>,
188}
189#[derive(Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct GqlExtensions {
192 code: GqlErrorCode,
193 retry_after: Option<u64>,
194}
195#[derive(Deserialize)]
196enum GqlErrorCode {
197 #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
198 RateLimited,
199 #[serde(other)]
200 Other,
201}
202
203impl LinearSource {
204 async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
206 let response = self
207 .client
208 .post(&self.endpoint.0)
209 .header("Authorization", self.key.expose_secret())
210 .json(&json!({"query": query, "variables": variables}))
211 .send()
212 .await
213 .map_err(|e| SourceError::Unavailable {
214 message: e.to_string(),
215 })?;
216 let status = response.status();
217 let retry = response
218 .headers()
219 .get("retry-after")
220 .and_then(|v| v.to_str().ok())
221 .and_then(|v| v.parse().ok());
222 if status.as_u16() == 429 {
223 return Err(SourceError::RateLimited {
224 retry_after_seconds: retry,
225 });
226 }
227 if status.as_u16() == 401 || status.as_u16() == 403 {
228 return Err(SourceError::Auth {
229 message: "Linear rejected the configured credential".into(),
230 });
231 }
232 if !status.is_success() {
233 return Err(SourceError::Unavailable {
234 message: format!("Linear returned HTTP {status}"),
235 });
236 }
237 let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
238 message: e.to_string(),
239 })?;
240 if let Some(error) = body.errors.first() {
241 if error
242 .extensions
243 .as_ref()
244 .is_some_and(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
245 {
246 let hint = error
247 .extensions
248 .as_ref()
249 .and_then(|value| value.retry_after);
250 return Err(SourceError::RateLimited {
251 retry_after_seconds: hint.or(retry),
252 });
253 }
254 return Err(SourceError::Refused {
255 message: error.message.clone(),
256 });
257 }
258 body.data.ok_or_else(|| SourceError::Malformed {
259 message: "GraphQL response has no data".into(),
260 })
261 }
262
263 fn filter(
265 &self,
266 labels: &onetaskgraph_plugin_api::LabelFilter,
267 statuses: &[StatusCategory],
268 project: Option<&ProjectFilter>,
269 ) -> Value {
270 let mut parts = Vec::new();
271 if let Some(team) = &self.team {
272 parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
273 }
274 if !labels.any_of.is_empty() {
275 parts.push(json!({"labels": {"some": {"name": {"inIgnoreCase": labels.any_of}}}}));
276 }
277 for name in &labels.all_of {
278 parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
279 }
280 for name in &labels.none_of {
281 parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
282 }
283 if !statuses.is_empty() {
284 parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(linear_statuses).collect::<Vec<_>>()}}}));
285 }
286 match project {
287 Some(ProjectFilter::Orphans) => parts.push(json!({"project": {"null": true}})),
288 Some(ProjectFilter::Is(id)) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
289 _ => {}
290 }
291 if parts.len() == 1 {
292 parts.pop().unwrap()
293 } else {
294 json!({"and": parts})
295 }
296 }
297 }
299
300#[async_trait::async_trait]
301impl TaskSource for LinearSource {
302 fn kind(&self) -> &'static str {
303 KIND
304 }
305 fn capabilities(&self) -> Capabilities {
306 Capabilities {
307 projects: Support::Native,
308 orphan_tasks: Support::Native,
309 filter_by_label: Support::Native,
310 filter_by_status: Support::Native,
311 search_title: Support::Unsupported,
312 search_content: Support::Unsupported,
313 task_dependencies: DependencySupport::BothDirections,
314 project_dependencies: DependencySupport::BothDirections,
315 max_page_size: 250,
316 }
317 }
318 async fn health(&self) -> Result<Health, SourceError> {
319 let data = self.send(VIEWER, json!({})).await?;
320 str_at(
321 data.get("viewer").ok_or_else(|| SourceError::Malformed {
322 message: "missing viewer".into(),
323 })?,
324 "id",
325 )?;
326 Ok(Health {
327 reachable: true,
328 detail: None,
329 })
330 }
331 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
332 let d = self.send(ISSUE, json!({"id":id.0})).await?;
333 optional(&d, "issue", map_task)
334 }
335 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
336 let d = self.send(PROJECT, json!({"id":id.0})).await?;
337 optional(&d, "project", map_project)
338 }
339 async fn query_tasks(
340 &self,
341 query: &TaskQuery,
342 page: &PageRequest,
343 ) -> Result<Page<Task>, SourceError> {
344 let d=self.send(ISSUES,json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.filter(&query.labels,&query.statuses,Some(&query.project))})).await?;
345 connection(&d, "issues", map_task)
346 }
347 async fn query_projects(
348 &self,
349 query: &ProjectQuery,
350 page: &PageRequest,
351 ) -> Result<Page<Project>, SourceError> {
352 let d=self.send(PROJECTS,json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.filter(&query.labels,&query.statuses,None)})).await?;
354 connection(&d, "projects", map_project)
355 }
356 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
357 let d = self
358 .send(
359 LABELS,
360 json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)}),
361 )
362 .await?;
363 connection(&d, "issueLabels", map_label)
364 }
365 async fn task_dependencies(
366 &self,
367 id: &NativeId,
368 direction: Direction,
369 page: &PageRequest,
370 ) -> Result<Page<DependencyEdge>, SourceError> {
371 let d=self.send(ISSUE_RELATIONS,json!({"id":id.0,"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)})).await?;
372 relation_page(&d, DependencyRoot::Issue, id, direction)
373 }
374 async fn project_dependencies(
375 &self,
376 id: &NativeId,
377 direction: Direction,
378 page: &PageRequest,
379 ) -> Result<Page<DependencyEdge>, SourceError> {
380 let d=self.send(PROJECT_RELATIONS,json!({"id":id.0,"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)})).await?;
381 relation_page(&d, DependencyRoot::Project, id, direction)
382 }
383}
384
385fn linear_statuses(s: &StatusCategory) -> Vec<&'static str> {
387 match s {
388 StatusCategory::Backlog => vec!["backlog"],
389 StatusCategory::Todo => vec!["unstarted"],
390 StatusCategory::InProgress => vec!["started"],
391 StatusCategory::Done => vec!["completed"],
392 StatusCategory::Cancelled => vec!["canceled"],
393 StatusCategory::Unknown => vec![],
394 }
395}
396fn status(v: &Value) -> Result<Status, SourceError> {
397 let name = str_at(v, "name")?.into();
398 let category = match str_at(v, "type")? {
399 "backlog" => StatusCategory::Backlog,
400 "unstarted" => StatusCategory::Todo,
401 "started" => StatusCategory::InProgress,
402 "completed" => StatusCategory::Done,
403 "canceled" => StatusCategory::Cancelled,
404 _ => StatusCategory::Unknown,
405 };
406 Ok(Status { category, name })
407}
408fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
410 v.get(k)
411 .and_then(Value::as_str)
412 .ok_or_else(|| SourceError::Malformed {
413 message: format!("missing string field {k}"),
414 })
415}
416fn map_label(v: &Value) -> Result<Label, SourceError> {
417 Ok(Label {
418 id: NativeId(str_at(v, "id")?.into()),
419 name: str_at(v, "name")?.into(),
420 color: optional_string(v, "color")?,
421 })
422}
423fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
424 v.get("nodes")
425 .and_then(Value::as_array)
426 .ok_or_else(|| SourceError::Malformed {
427 message: "missing label nodes".into(),
428 })?
429 .iter()
430 .map(map_label)
431 .collect()
432}
433fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
434 optional_str(v, k)?
435 .map(|s| {
436 s.parse().map_err(|e| SourceError::Malformed {
437 message: format!("invalid {k}: {e}"),
438 })
439 })
440 .transpose()
441}
442fn map_task(v: &Value) -> Result<Task, SourceError> {
443 Ok(Task {
444 id: NativeId(str_at(v, "id")?.into()),
445 title: str_at(v, "title")?.into(),
446 content: optional_string(v, "description")?,
447 status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
448 message: "missing state".into(),
449 })?)?,
450 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
451 message: "missing labels".into(),
452 })?)?,
453 project: match v.get("project") {
454 None => {
455 return Err(SourceError::Malformed {
456 message: "missing project field".into(),
457 });
458 }
459 Some(Value::Null) => None,
460 Some(p) => Some(NativeId(str_at(p, "id")?.into())),
461 },
462 url: optional_string(v, "url")?,
463 created_at: time(v, "createdAt")?,
464 updated_at: time(v, "updatedAt")?,
465 })
466}
467fn map_project(v: &Value) -> Result<Project, SourceError> {
468 Ok(Project {
469 id: NativeId(str_at(v, "id")?.into()),
470 title: str_at(v, "name")?.into(),
471 content: optional_string(v, "description")?,
472 status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
473 message: "missing status".into(),
474 })?)?,
475 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
476 message: "missing project labels".into(),
477 })?)?,
478 url: optional_string(v, "url")?,
479 created_at: time(v, "createdAt")?,
480 updated_at: time(v, "updatedAt")?,
481 })
482}
483fn optional<T>(
484 d: &Value,
485 k: &str,
486 f: fn(&Value) -> Result<T, SourceError>,
487) -> Result<Option<T>, SourceError> {
488 match d.get(k) {
489 None => Err(SourceError::Malformed {
490 message: format!("missing {k}"),
491 }),
492 Some(Value::Null) => Ok(None),
493 Some(value) => f(value).map(Some),
494 }
495}
496fn connection<T>(
497 d: &Value,
498 k: &str,
499 f: fn(&Value) -> Result<T, SourceError>,
500) -> Result<Page<T>, SourceError> {
501 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
502 message: format!("missing {k} connection"),
503 })?;
504 let items = c
505 .get("nodes")
506 .and_then(Value::as_array)
507 .ok_or_else(|| SourceError::Malformed {
508 message: "missing nodes".into(),
509 })?
510 .iter()
511 .map(f)
512 .collect::<Result<_, _>>()?;
513 let next = page_next(c)?;
514 Ok(Page { items, next })
515}
516#[derive(Clone, Copy)]
517enum DependencyRoot {
518 Issue,
519 Project,
520}
521impl DependencyRoot {
522 const fn as_str(self) -> &'static str {
523 match self {
524 Self::Issue => "issue",
525 Self::Project => "project",
526 }
527 }
528}
529fn relation_page(
530 d: &Value,
531 root: DependencyRoot,
532 id: &NativeId,
533 direction: Direction,
534) -> Result<Page<DependencyEdge>, SourceError> {
535 let key = if direction == Direction::DependsOn {
536 "relations"
537 } else {
538 "inverseRelations"
539 };
540 let c = d
541 .get(root.as_str())
542 .and_then(|v| v.get(key))
543 .ok_or_else(|| SourceError::Malformed {
544 message: format!("missing {key}"),
545 })?;
546 let nodes = c
547 .get("nodes")
548 .and_then(Value::as_array)
549 .ok_or_else(|| SourceError::Malformed {
550 message: "missing relation nodes".into(),
551 })?;
552 let mut items = Vec::new();
553 for n in nodes {
554 let other = n
555 .get(if direction == Direction::DependsOn {
556 "relatedIssue"
557 } else {
558 "issue"
559 })
560 .or_else(|| {
561 n.get(if direction == Direction::DependsOn {
562 "relatedProject"
563 } else {
564 "project"
565 })
566 })
567 .and_then(|v| v.get("id"))
568 .and_then(Value::as_str)
569 .ok_or_else(|| SourceError::Malformed {
570 message: "missing related id".into(),
571 })?;
572 let (from, to) = if direction == Direction::DependsOn {
573 (id.clone(), NativeId(other.into()))
574 } else {
575 (NativeId(other.into()), id.clone())
576 };
577 #[derive(Deserialize)]
579 #[serde(rename_all = "camelCase")]
580 enum RelationKind {
581 Blocks,
582 Related,
583 }
584 let kind = match serde_json::from_value::<RelationKind>(n.get("type").cloned().ok_or_else(
585 || SourceError::Malformed {
586 message: "missing relation type".into(),
587 },
588 )?)
589 .map_err(|e| SourceError::Malformed {
590 message: format!("invalid relation type: {e}"),
591 })? {
592 RelationKind::Blocks => DependencyKind::Blocks,
593 RelationKind::Related => DependencyKind::Related,
594 };
595 items.push(DependencyEdge { from, to, kind });
597 }
598 let next = page_next(c)?;
599 Ok(Page { items, next })
600}
601
602fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
603 match v.get(k) {
604 None => Err(SourceError::Malformed {
605 message: format!("missing field {k}"),
606 }),
607 Some(Value::Null) => Ok(None),
608 Some(value) => value
609 .as_str()
610 .map(Some)
611 .ok_or_else(|| SourceError::Malformed {
612 message: format!("field {k} is not a string"),
613 }),
614 }
615}
616fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
617 Ok(optional_str(v, k)?.map(Into::into))
618}
619fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
620 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
621 message: "missing pageInfo".into(),
622 })?;
623 let more = info
624 .get("hasNextPage")
625 .and_then(Value::as_bool)
626 .ok_or_else(|| SourceError::Malformed {
627 message: "missing boolean pageInfo.hasNextPage".into(),
628 })?;
629 if !more {
630 return Ok(None);
631 }
632 let cursor = str_at(info, "endCursor")?;
633 Ok(Some(Cursor(cursor.into())))
634}