1#![deny(missing_docs)]
67
68use chrono::{DateTime, Utc};
69use onetaskgraph_plugin_api::{
70 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
71 Direction, Health, ItemKind, ItemWrite, Label, NativeId, Page, PageRequest, Project,
72 ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin,
73 Status, StatusCategory, Support, Task, TaskQuery, TaskSource, WriteSupport,
74};
75use schemars::{Schema, schema_for};
76use secrecy::{ExposeSecret, SecretString};
77use serde::Deserialize;
78use serde_json::{Value, json};
79
80pub const KIND: &str = "linear";
82const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
83
84pub mod graphql {
89 pub const VIEWER: &str = "query { viewer { id } }";
91 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} } }";
93 pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name description url createdAt updatedAt status{name type} labels{nodes{id name color}} } }";
95 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} } }";
97 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} } }";
99 pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
101 pub const ISSUE_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ issue(id:$id){ description relations(first:$first,after:$after){nodes{id type relatedIssue{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{id type issue{id}} pageInfo{hasNextPage endCursor}} } }";
103 pub const PROJECT_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ description relations(first:$first,after:$after){nodes{id type relatedProject{id}} pageInfo{hasNextPage endCursor}} inverseRelations(first:$first,after:$after){nodes{id type project{id}} pageInfo{hasNextPage endCursor}} } }";
105 pub const TEAM: &str =
107 "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
108 pub const ISSUE_STATE: &str = "query($name:String!,$team:String!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
110 pub const PROJECT_STATUS: &str =
112 "query($name:String!){ projectStatuses(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
113 pub const ISSUE_LABEL: &str =
115 "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
116 pub const PROJECT_LABEL: &str =
118 "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
119 pub const ISSUE_CREATE: &str =
121 "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
122 pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
124 pub const PROJECT_CREATE: &str =
126 "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
127 pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
129 pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
131 pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
133 pub const ISSUE_RELATION_DELETE: &str =
135 "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
136 pub const PROJECT_RELATION_DELETE: &str =
138 "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
139 pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
141 pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
143}
144
145use graphql::{
146 ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS, PROJECTS, VIEWER,
147};
148
149#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
151#[serde(default, deny_unknown_fields)]
152pub struct LinearConfig {
153 #[schemars(with = "String")]
155 api_key_env: EnvName,
156 #[schemars(with = "Option<String>")]
158 team: Option<Team>,
159 #[schemars(with = "String")]
161 endpoint: Endpoint,
162}
163
164#[derive(Debug, Clone, Deserialize)]
165#[serde(try_from = "String")]
166struct EnvName(String);
167impl TryFrom<String> for EnvName {
168 type Error = String;
169 fn try_from(value: String) -> Result<Self, Self::Error> {
170 let mut bytes = value.bytes();
171 if bytes
172 .next()
173 .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
174 && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
175 {
176 Ok(Self(value))
177 } else {
178 Err("must be an uppercase environment-variable name".into())
179 }
180 }
181}
182#[derive(Debug, Clone, Deserialize)]
183#[serde(try_from = "String")]
184struct Team(String);
185impl TryFrom<String> for Team {
186 type Error = String;
187 fn try_from(value: String) -> Result<Self, Self::Error> {
188 if value.trim().is_empty() {
189 Err("must not be empty".into())
190 } else {
191 Ok(Self(value))
192 }
193 }
194}
195#[derive(Debug, Clone, Deserialize)]
196#[serde(try_from = "String")]
197struct Endpoint(String);
198impl TryFrom<String> for Endpoint {
199 type Error = String;
200 fn try_from(value: String) -> Result<Self, Self::Error> {
201 let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
202 if matches!(url.scheme(), "http" | "https") {
203 Ok(Self(value))
204 } else {
205 Err("must use http or https".into())
206 }
207 }
208}
209
210impl Default for LinearConfig {
211 fn default() -> Self {
212 Self {
213 api_key_env: EnvName("LINEAR_API_KEY".into()),
214 team: None,
215 endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Copy, Default)]
222pub struct Plugin;
223
224impl SourcePlugin for Plugin {
225 fn kind(&self) -> &'static str {
226 KIND
227 }
228 fn config_schema(&self) -> Schema {
229 schema_for!(LinearConfig)
230 }
231 fn build(
232 &self,
233 name: &SourceName,
234 config: &Value,
235 secrets: &dyn SecretResolver,
236 ) -> Result<Box<dyn TaskSource>, SourceError> {
237 let config: LinearConfig =
238 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
239 message: format!("source {name}: {e}"),
240 })?;
241 let key = secrets
242 .get(&config.api_key_env.0)
243 .filter(|v| !v.expose_secret().trim().is_empty())
244 .ok_or_else(|| SourceError::Auth {
245 message: format!("set environment variable {}", config.api_key_env.0),
246 })?;
247 Ok(Box::new(LinearSource {
248 client: reqwest::Client::new(),
249 endpoint: config.endpoint,
250 key,
251 team: config.team,
252 name: name.clone(),
253 }))
254 }
255}
256
257struct LinearSource {
258 client: reqwest::Client,
259 endpoint: Endpoint,
260 key: SecretString,
261 team: Option<Team>,
262 name: SourceName,
266}
267#[derive(Clone, Copy)]
268enum WriteKind {
269 Task,
270 Project,
271}
272enum Lookup<'a> {
273 Team(&'a str),
274 IssueState { name: &'a str, team: &'a NativeId },
275 ProjectStatus(&'a str),
276 IssueLabel(&'a str),
277 ProjectLabel(&'a str),
278}
279impl Lookup<'_> {
280 fn query(&self) -> &'static str {
281 match self {
282 Self::Team(_) => graphql::TEAM,
283 Self::IssueState { .. } => graphql::ISSUE_STATE,
284 Self::ProjectStatus(_) => graphql::PROJECT_STATUS,
285 Self::IssueLabel(_) => graphql::ISSUE_LABEL,
286 Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
287 }
288 }
289 fn connection(&self) -> &'static str {
290 match self {
291 Self::Team(_) => "teams",
292 Self::IssueState { .. } => "workflowStates",
293 Self::ProjectStatus(_) => "projectStatuses",
294 Self::IssueLabel(_) => "issueLabels",
295 Self::ProjectLabel(_) => "projectLabels",
296 }
297 }
298 fn diagnostic(&self) -> String {
299 match self {
300 Self::Team(_) => "configured team".into(),
301 Self::IssueState { name, .. } => format!("workflow state {name:?}"),
302 Self::ProjectStatus(name) => format!("project status {name:?}"),
303 Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
304 }
305 }
306 fn variables(&self) -> Value {
307 match self {
308 Self::Team(key) => json!({"key":key}),
309 Self::IssueState { name, team } => json!({"name":name,"team":team.0}),
310 Self::ProjectStatus(name) | Self::IssueLabel(name) | Self::ProjectLabel(name) => {
311 json!({"name":name})
312 }
313 }
314 }
315}
316#[derive(Clone, Copy)]
317enum MutationRoot {
318 IssueCreate,
319 IssueUpdate,
320 ProjectCreate,
321 ProjectUpdate,
322 IssueRelationCreate,
323 ProjectRelationCreate,
324 IssueRelationDelete,
325 ProjectRelationDelete,
326 IssueDelete,
327 ProjectDelete,
328}
329impl MutationRoot {
330 fn as_str(self) -> &'static str {
331 match self {
332 Self::IssueCreate => "issueCreate",
333 Self::IssueUpdate => "issueUpdate",
334 Self::ProjectCreate => "projectCreate",
335 Self::ProjectUpdate => "projectUpdate",
336 Self::IssueRelationCreate => "issueRelationCreate",
337 Self::ProjectRelationCreate => "projectRelationCreate",
338 Self::IssueRelationDelete => "issueRelationDelete",
339 Self::ProjectRelationDelete => "projectRelationDelete",
340 Self::IssueDelete => "issueDelete",
341 Self::ProjectDelete => "projectDelete",
342 }
343 }
344}
345
346#[derive(Deserialize)]
347struct Envelope {
348 data: Option<Value>,
350 #[serde(default)]
351 errors: Vec<GqlError>,
352}
353#[derive(Deserialize)]
354struct GqlError {
355 message: String,
356 extensions: Option<GqlExtensions>,
357}
358#[derive(Deserialize)]
359#[serde(rename_all = "camelCase")]
360struct GqlExtensions {
361 code: GqlErrorCode,
362 retry_after: Option<u64>,
363}
364#[derive(Deserialize)]
365enum GqlErrorCode {
366 #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
367 RateLimited,
368 #[serde(other)]
369 Other,
370}
371
372impl LinearSource {
373 async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
375 let response = self
376 .client
377 .post(&self.endpoint.0)
378 .header("Authorization", self.key.expose_secret())
379 .json(&json!({"query": query, "variables": variables}))
380 .send()
381 .await
382 .map_err(|e| SourceError::Unavailable {
383 message: e.to_string(),
384 })?;
385 let status = response.status();
386 let retry = response
387 .headers()
388 .get("retry-after")
389 .and_then(|v| v.to_str().ok())
390 .and_then(|v| v.parse().ok());
391 if status.as_u16() == 429 {
392 return Err(SourceError::RateLimited {
393 retry_after_seconds: retry,
394 });
395 }
396 if status.as_u16() == 401 || status.as_u16() == 403 {
397 return Err(SourceError::Auth {
398 message: "Linear rejected the configured credential".into(),
399 });
400 }
401 if !status.is_success() {
402 return Err(SourceError::Unavailable {
403 message: format!("Linear returned HTTP {status}"),
404 });
405 }
406 let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
407 message: e.to_string(),
408 })?;
409 if let Some(error) = body.errors.first() {
410 if error
411 .extensions
412 .as_ref()
413 .is_some_and(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
414 {
415 let hint = error
416 .extensions
417 .as_ref()
418 .and_then(|value| value.retry_after);
419 return Err(SourceError::RateLimited {
420 retry_after_seconds: hint.or(retry),
421 });
422 }
423 return Err(SourceError::Refused {
424 message: error.message.clone(),
425 });
426 }
427 body.data.ok_or_else(|| SourceError::Malformed {
428 message: "GraphQL response has no data".into(),
429 })
430 }
431
432 fn filter(
434 &self,
435 labels: &onetaskgraph_plugin_api::LabelFilter,
436 statuses: &[StatusCategory],
437 project: Option<&ProjectFilter>,
438 ) -> Value {
439 let mut parts = Vec::new();
440 if let Some(team) = &self.team {
441 parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
442 }
443 if !labels.any_of.is_empty() {
444 parts.push(json!({"labels": {"some": {"name": {"inIgnoreCase": labels.any_of}}}}));
445 }
446 for name in &labels.all_of {
447 parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
448 }
449 for name in &labels.none_of {
450 parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
451 }
452 if !statuses.is_empty() {
453 parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(linear_statuses).collect::<Vec<_>>()}}}));
454 }
455 match project {
456 Some(ProjectFilter::Orphans) => parts.push(json!({"project": {"null": true}})),
457 Some(ProjectFilter::Is(id)) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
458 _ => {}
459 }
460 if parts.len() == 1 {
461 parts.pop().unwrap()
462 } else {
463 json!({"and": parts})
464 }
465 }
466 async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
469 let data = self.send(lookup.query(), lookup.variables()).await?;
470 let connection = lookup.connection();
471 let nodes = data
472 .get(connection)
473 .and_then(|v| v.get("nodes"))
474 .and_then(Value::as_array)
475 .ok_or_else(|| SourceError::Malformed {
476 message: format!("missing {connection}.nodes"),
477 })?;
478 if nodes.len() != 1 {
479 return Err(SourceError::Refused {
480 message: format!(
481 "source {} cannot resolve {} uniquely",
482 self.name,
483 lookup.diagnostic()
484 ),
485 });
486 }
487 Ok(NativeId(backend_id(&nodes[0], "id")?.to_owned()))
488 }
489 async fn team_id(&self) -> Result<NativeId, SourceError> {
490 let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
491 message: format!(
492 "source {} needs config.team before it can create Linear items",
493 self.name
494 ),
495 })?;
496 self.one_id(Lookup::Team(&team.0)).await
497 }
498 async fn label_ids(
499 &self,
500 labels: &[Label],
501 kind: WriteKind,
502 ) -> Result<Vec<NativeId>, SourceError> {
503 let mut ids = Vec::with_capacity(labels.len());
504 for label in labels {
505 ids.push(
506 self.one_id(if matches!(kind, WriteKind::Project) {
507 Lookup::ProjectLabel(&label.name)
508 } else {
509 Lookup::IssueLabel(&label.name)
510 })
511 .await?,
512 );
513 }
514 Ok(ids)
515 }
516 fn write_description(
517 &self,
518 content: Option<&str>,
519 metadata: &std::collections::BTreeMap<String, Value>,
520 repositories: &[Repository],
521 edges: &[DependencyEdge],
522 kind: WriteKind,
523 ) -> Result<Option<String>, SourceError> {
524 let mut metadata = metadata.clone();
525 if repositories.is_empty() {
526 metadata.remove(Repository::METADATA_KEY);
527 } else {
528 metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
529 }
530 let recorded = edges
531 .iter()
532 .filter(|edge| {
533 edge.to.kind
534 != match kind {
535 WriteKind::Task => ItemKind::Task,
536 WriteKind::Project => ItemKind::Project,
537 }
538 || edge
539 .to
540 .id()
541 .split_once(':')
542 .is_some_and(|(source, _)| source != self.name.as_str())
543 })
544 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
545 .collect::<Vec<_>>();
546 if recorded.is_empty() {
547 metadata.remove(DependencyEdge::RECORDED_KEY);
548 } else {
549 metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
550 }
551 let visible = content.unwrap_or_default();
552 if metadata.is_empty() {
553 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
554 }
555 let encoded = serde_json::to_string(&metadata).map_err(|error| SourceError::Malformed {
556 message: error.to_string(),
557 })?;
558 Ok(Some(if visible.is_empty() {
559 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
560 } else {
561 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
562 }))
563 }
564 async fn write_relations(
565 &self,
566 near: &NativeId,
567 edges: &[DependencyEdge],
568 kind: WriteKind,
569 ) -> Result<(), SourceError> {
570 let mut cursor: Option<Cursor> = None;
571 loop {
572 let data = self
573 .send(
574 if matches!(kind, WriteKind::Project) {
575 PROJECT_RELATIONS
576 } else {
577 ISSUE_RELATIONS
578 },
579 json!({"id":near.0,"first":250,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
580 )
581 .await?;
582 let root = data
583 .get(if matches!(kind, WriteKind::Project) {
584 "project"
585 } else {
586 "issue"
587 })
588 .ok_or_else(|| SourceError::Malformed {
589 message: "missing relation item".into(),
590 })?;
591 let relations = root
592 .get("relations")
593 .ok_or_else(|| SourceError::Malformed {
594 message: "missing relations".into(),
595 })?;
596 for relation in relations
597 .get("nodes")
598 .and_then(Value::as_array)
599 .ok_or_else(|| SourceError::Malformed {
600 message: "missing relations.nodes".into(),
601 })?
602 {
603 let id = backend_id(relation, "id")?;
604 let (query, mutation) = if matches!(kind, WriteKind::Project) {
605 (
606 graphql::PROJECT_RELATION_DELETE,
607 MutationRoot::ProjectRelationDelete,
608 )
609 } else {
610 (
611 graphql::ISSUE_RELATION_DELETE,
612 MutationRoot::IssueRelationDelete,
613 )
614 };
615 let deleted = self.send(query, json!({"id":id})).await?;
616 mutation_payload(&deleted, mutation)?;
617 }
618 let Some(next) = page_next(relations)? else {
619 break;
620 };
621 cursor = Some(next);
622 }
623 for edge in edges {
624 if edge.to.kind
625 != match kind {
626 WriteKind::Task => ItemKind::Task,
627 WriteKind::Project => ItemKind::Project,
628 }
629 {
630 continue;
631 }
632 let far = match edge.to.id().split_once(':') {
633 Some((source, native)) if source == self.name.as_str() => native,
634 Some(_) => continue,
635 None => edge.to.id(),
636 };
637 let relation_type = match edge.kind {
638 DependencyKind::Blocks => "blocks",
639 DependencyKind::Related => "related",
640 };
641 let (query, input) = if matches!(kind, WriteKind::Project) {
642 (
643 graphql::PROJECT_RELATION_CREATE,
644 json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type}),
645 )
646 } else {
647 (
648 graphql::ISSUE_RELATION_CREATE,
649 json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
650 )
651 };
652 let data = self.send(query, json!({"input":input})).await?;
653 let mutation = if matches!(kind, WriteKind::Project) {
654 MutationRoot::ProjectRelationCreate
655 } else {
656 MutationRoot::IssueRelationCreate
657 };
658 let payload = mutation_payload(&data, mutation)?;
659 let relation = payload
660 .get(if matches!(kind, WriteKind::Project) {
661 "projectRelation"
662 } else {
663 "issueRelation"
664 })
665 .ok_or_else(|| SourceError::Malformed {
666 message: format!("missing {} relation", mutation.as_str()),
667 })?;
668 backend_id(relation, "id")?;
669 }
670 Ok(())
671 }
672
673 async fn prepare_edges(
674 &self,
675 edges: &[DependencyEdge],
676 kind: WriteKind,
677 ) -> Result<Vec<DependencyEdge>, SourceError> {
678 let mut prepared = Vec::with_capacity(edges.len());
679 for edge in edges {
680 let mut edge = edge.clone();
681 if edge.to.kind
682 == match kind {
683 WriteKind::Task => ItemKind::Task,
684 WriteKind::Project => ItemKind::Project,
685 }
686 && edge
687 .to
688 .id()
689 .split_once(':')
690 .is_some_and(|(source, _)| source != self.name.as_str())
691 {
692 let mut cursor: Option<Cursor> = None;
693 loop {
694 let data = self.send(if matches!(kind, WriteKind::Project) { PROJECTS } else { ISSUES }, json!({"first":250,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":{}})).await?;
695 let (items, next) = if matches!(kind, WriteKind::Project) {
696 let page = connection(&data, "projects", map_project)?;
697 (
698 page.items
699 .into_iter()
700 .map(|item| (item.id, item.metadata))
701 .collect::<Vec<_>>(),
702 page.next,
703 )
704 } else {
705 let page = connection(&data, "issues", map_task)?;
706 (
707 page.items
708 .into_iter()
709 .map(|item| (item.id, item.metadata))
710 .collect::<Vec<_>>(),
711 page.next,
712 )
713 };
714 if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
715 metadata.get("onetaskgraph.origin").and_then(Value::as_str)
716 == Some(edge.to.id())
717 }) {
718 edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
719 break;
720 }
721 let Some(next) = next else { break };
722 cursor = Some(next);
723 }
724 }
725 prepared.push(edge);
726 }
727 Ok(prepared)
728 }
729}
730
731#[async_trait::async_trait]
732impl TaskSource for LinearSource {
733 fn kind(&self) -> &'static str {
734 KIND
735 }
736 fn capabilities(&self) -> Capabilities {
737 Capabilities {
738 projects: Support::Native,
739 documents: Support::Unsupported,
740 orphan_tasks: Support::Native,
741 filter_by_label: Support::Native,
742 filter_by_status: Support::Native,
743 search_title: Support::Unsupported,
744 search_content: Support::Unsupported,
745 task_dependencies: DependencySupport::BothDirections,
746 project_dependencies: DependencySupport::BothDirections,
747 max_page_size: 250,
748 }
749 }
750 fn writes(&self) -> WriteSupport {
751 WriteSupport::Supported
752 }
753 async fn health(&self) -> Result<Health, SourceError> {
754 let data = self.send(VIEWER, json!({})).await?;
755 str_at(
756 data.get("viewer").ok_or_else(|| SourceError::Malformed {
757 message: "missing viewer".into(),
758 })?,
759 "id",
760 )?;
761 Ok(Health {
762 reachable: true,
763 detail: None,
764 })
765 }
766 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
767 let d = self.send(ISSUE, json!({"id":id.0})).await?;
768 optional(&d, "issue", map_task)
769 }
770 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
771 let d = self.send(PROJECT, json!({"id":id.0})).await?;
772 optional(&d, "project", map_project)
773 }
774 async fn query_tasks(
775 &self,
776 query: &TaskQuery,
777 page: &PageRequest,
778 ) -> Result<Page<Task>, SourceError> {
779 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?;
780 connection(&d, "issues", map_task)
781 }
782 async fn query_projects(
783 &self,
784 query: &ProjectQuery,
785 page: &PageRequest,
786 ) -> Result<Page<Project>, SourceError> {
787 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?;
789 connection(&d, "projects", map_project)
790 }
791 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
792 let d = self
793 .send(
794 LABELS,
795 json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)}),
796 )
797 .await?;
798 connection(&d, "issueLabels", map_label)
799 }
800 async fn task_dependencies(
801 &self,
802 id: &NativeId,
803 direction: Direction,
804 page: &PageRequest,
805 ) -> Result<Page<DependencyEdge>, SourceError> {
806 self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
807 .await
808 }
809 async fn project_dependencies(
810 &self,
811 id: &NativeId,
812 direction: Direction,
813 page: &PageRequest,
814 ) -> Result<Page<DependencyEdge>, SourceError> {
815 self.dependencies(
816 PROJECT_RELATIONS,
817 DependencyRoot::Project,
818 id,
819 direction,
820 page,
821 )
822 .await
823 }
824 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
825 let edges = self
826 .prepare_edges(&write.depends_on, WriteKind::Task)
827 .await?;
828 let team = self.team_id().await?;
829 let state = self
830 .one_id(Lookup::IssueState {
831 name: &write.item.status.name,
832 team: &team,
833 })
834 .await?;
835 let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
836 let description = self.write_description(
837 write.item.content.as_deref(),
838 &write.item.metadata,
839 &write.item.repositories,
840 &edges,
841 WriteKind::Task,
842 )?;
843 let input = json!({"title":write.item.title,"description":description,"stateId":state,"labelIds":labels,"projectId":write.item.project.as_ref().map(|id| id.0.clone())});
844 let (query, variables, root) = match &write.target {
845 Some(id) => (
846 graphql::ISSUE_UPDATE,
847 json!({"id":id.0,"input":input}),
848 MutationRoot::IssueUpdate,
849 ),
850 None => (
851 graphql::ISSUE_CREATE,
852 {
853 let mut input = input;
854 input["teamId"] = Value::String(team.0);
855 json!({"input":input})
856 },
857 MutationRoot::IssueCreate,
858 ),
859 };
860 let data = self.send(query, variables).await?;
861 let issue =
862 mutation_payload(&data, root)?
863 .get("issue")
864 .ok_or_else(|| SourceError::Malformed {
865 message: format!("missing {}.issue", root.as_str()),
866 })?;
867 let id = NativeId(backend_id(issue, "id")?.into());
868 self.write_relations(&id, &edges, WriteKind::Task).await?;
869 Ok(id)
870 }
871 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
872 let edges = self
873 .prepare_edges(&write.depends_on, WriteKind::Project)
874 .await?;
875 let team = self.team_id().await?;
876 let status = self
877 .one_id(Lookup::ProjectStatus(&write.item.status.name))
878 .await?;
879 let labels = self
880 .label_ids(&write.item.labels, WriteKind::Project)
881 .await?;
882 let description = self.write_description(
883 write.item.content.as_deref(),
884 &write.item.metadata,
885 &write.item.repositories,
886 &edges,
887 WriteKind::Project,
888 )?;
889 let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
890 let (query, variables, root) = match &write.target {
891 Some(id) => (
892 graphql::PROJECT_UPDATE,
893 json!({"id":id.0,"input":input}),
894 MutationRoot::ProjectUpdate,
895 ),
896 None => (
897 graphql::PROJECT_CREATE,
898 {
899 let mut input = input;
900 input["teamIds"] = json!([team]);
901 json!({"input":input})
902 },
903 MutationRoot::ProjectCreate,
904 ),
905 };
906 let data = self.send(query, variables).await?;
907 let project = mutation_payload(&data, root)?
908 .get("project")
909 .ok_or_else(|| SourceError::Malformed {
910 message: format!("missing {}.project", root.as_str()),
911 })?;
912 let id = NativeId(backend_id(project, "id")?.into());
913 self.write_relations(&id, &edges, WriteKind::Project)
914 .await?;
915 Ok(id)
916 }
917 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
918 if self.get_task(id).await?.is_none() {
922 return Ok(());
923 }
924 let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
925 mutation_payload(&data, MutationRoot::IssueDelete)?;
926 Ok(())
927 }
928 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
929 if self.get_project(id).await?.is_none() {
932 return Ok(());
933 }
934 let data = self
935 .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
936 .await?;
937 mutation_payload(&data, MutationRoot::ProjectDelete)?;
938 Ok(())
939 }
940}
941
942const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
948
949impl LinearSource {
950 async fn dependencies(
951 &self,
952 query: &str,
953 root: DependencyRoot,
954 id: &NativeId,
955 direction: Direction,
956 page: &PageRequest,
957 ) -> Result<Page<DependencyEdge>, SourceError> {
958 let limit = page.limit.min(250);
959 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
960 if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
961 if direction != Direction::DependsOn {
967 return Err(SourceError::Malformed {
968 message: format!(
969 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a reverse dependency read never issues; resume it in the direction that reported it"
970 ),
971 });
972 }
973 let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
974 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
975 })?;
976 let d = self
977 .send(query, json!({"id":id.0,"first":1,"after":null}))
978 .await?;
979 return Ok(recorded_page(
980 recorded(&d, root, id, &self.name)?,
981 offset,
982 limit as usize,
983 ));
984 }
985 let d = self
986 .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
987 .await?;
988 let mut answered = relation_page(&d, root, id, direction)?;
989 if answered.next.is_none()
992 && direction == Direction::DependsOn
993 && !recorded(&d, root, id, &self.name)?.is_empty()
994 {
995 answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
996 }
997 Ok(answered)
998 }
999}
1000
1001fn recorded(
1002 d: &Value,
1003 root: DependencyRoot,
1004 id: &NativeId,
1005 name: &SourceName,
1006) -> Result<Vec<DependencyEdge>, SourceError> {
1007 let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
1008 message: format!("missing {}", root.as_str()),
1009 })?;
1010 let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
1011 DependencyEdge::recorded(
1016 &metadata,
1017 id,
1018 root.item_kind(),
1019 name,
1020 Some(root.item_kind()),
1021 )
1022 .map_err(|message| SourceError::Malformed { message })
1023}
1024
1025fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1026 let total = edges.len();
1027 let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
1028 let end = offset.saturating_add(items.len());
1029 Page {
1030 items,
1031 next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
1032 }
1033}
1034
1035fn linear_statuses(s: &StatusCategory) -> Vec<&'static str> {
1037 match s {
1038 StatusCategory::Draft => vec![],
1042 StatusCategory::Backlog => vec!["backlog"],
1043 StatusCategory::Todo => vec!["unstarted"],
1044 StatusCategory::InProgress => vec!["started"],
1045 StatusCategory::Done => vec!["completed"],
1046 StatusCategory::Cancelled => vec!["canceled"],
1047 StatusCategory::Unknown => vec![],
1048 }
1049}
1050fn status(v: &Value) -> Result<Status, SourceError> {
1051 let name = str_at(v, "name")?.into();
1052 let category = match str_at(v, "type")? {
1053 "backlog" => StatusCategory::Backlog,
1054 "unstarted" => StatusCategory::Todo,
1055 "started" => StatusCategory::InProgress,
1056 "completed" => StatusCategory::Done,
1057 "canceled" => StatusCategory::Cancelled,
1058 _ => StatusCategory::Unknown,
1059 };
1060 Ok(Status { category, name })
1061}
1062fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
1064 v.get(k)
1065 .and_then(Value::as_str)
1066 .ok_or_else(|| SourceError::Malformed {
1067 message: format!("missing string field {k}"),
1068 })
1069}
1070fn map_label(v: &Value) -> Result<Label, SourceError> {
1071 Ok(Label {
1072 id: NativeId(str_at(v, "id")?.into()),
1073 name: str_at(v, "name")?.into(),
1074 color: optional_string(v, "color")?,
1075 })
1076}
1077fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
1078 v.get("nodes")
1079 .and_then(Value::as_array)
1080 .ok_or_else(|| SourceError::Malformed {
1081 message: "missing label nodes".into(),
1082 })?
1083 .iter()
1084 .map(map_label)
1085 .collect()
1086}
1087fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1088 optional_str(v, k)?
1089 .map(|s| {
1090 s.parse().map_err(|e| SourceError::Malformed {
1091 message: format!("invalid {k}: {e}"),
1092 })
1093 })
1094 .transpose()
1095}
1096fn map_task(v: &Value) -> Result<Task, SourceError> {
1097 let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1098 let repositories = Repository::from_metadata(&metadata)
1099 .map_err(|message| SourceError::Malformed { message })?;
1100 Ok(Task {
1101 id: NativeId(str_at(v, "id")?.into()),
1102 title: str_at(v, "title")?.into(),
1103 content,
1104 status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
1105 message: "missing state".into(),
1106 })?)?,
1107 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1108 message: "missing labels".into(),
1109 })?)?,
1110 project: match v.get("project") {
1111 None => {
1112 return Err(SourceError::Malformed {
1113 message: "missing project field".into(),
1114 });
1115 }
1116 Some(Value::Null) => None,
1117 Some(p) => Some(NativeId(str_at(p, "id")?.into())),
1118 },
1119 url: optional_string(v, "url")?,
1120 location: None,
1121 created_at: time(v, "createdAt")?,
1122 updated_at: time(v, "updatedAt")?,
1123 metadata,
1124 repositories,
1125 })
1126}
1127fn map_project(v: &Value) -> Result<Project, SourceError> {
1128 let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1129 let repositories = Repository::from_metadata(&metadata)
1130 .map_err(|message| SourceError::Malformed { message })?;
1131 Ok(Project {
1132 id: NativeId(str_at(v, "id")?.into()),
1133 title: str_at(v, "name")?.into(),
1134 content,
1135 status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
1136 message: "missing status".into(),
1137 })?)?,
1138 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1139 message: "missing project labels".into(),
1140 })?)?,
1141 url: optional_string(v, "url")?,
1142 location: None,
1143 created_at: time(v, "createdAt")?,
1144 updated_at: time(v, "updatedAt")?,
1145 metadata,
1146 repositories,
1147 })
1148}
1149fn optional<T>(
1150 d: &Value,
1151 k: &str,
1152 f: fn(&Value) -> Result<T, SourceError>,
1153) -> Result<Option<T>, SourceError> {
1154 match d.get(k) {
1155 None => Err(SourceError::Malformed {
1156 message: format!("missing {k}"),
1157 }),
1158 Some(Value::Null) => Ok(None),
1159 Some(value) => f(value).map(Some),
1160 }
1161}
1162fn connection<T>(
1163 d: &Value,
1164 k: &str,
1165 f: fn(&Value) -> Result<T, SourceError>,
1166) -> Result<Page<T>, SourceError> {
1167 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
1168 message: format!("missing {k} connection"),
1169 })?;
1170 let items = c
1171 .get("nodes")
1172 .and_then(Value::as_array)
1173 .ok_or_else(|| SourceError::Malformed {
1174 message: "missing nodes".into(),
1175 })?
1176 .iter()
1177 .map(f)
1178 .collect::<Result<_, _>>()?;
1179 let next = page_next(c)?;
1180 Ok(Page { items, next })
1181}
1182#[derive(Clone, Copy)]
1183enum DependencyRoot {
1184 Issue,
1185 Project,
1186}
1187impl DependencyRoot {
1188 const fn item_kind(self) -> ItemKind {
1189 match self {
1190 Self::Issue => ItemKind::Task,
1191 Self::Project => ItemKind::Project,
1192 }
1193 }
1194 const fn as_str(self) -> &'static str {
1195 match self {
1196 Self::Issue => "issue",
1197 Self::Project => "project",
1198 }
1199 }
1200}
1201fn relation_page(
1202 d: &Value,
1203 root: DependencyRoot,
1204 id: &NativeId,
1205 direction: Direction,
1206) -> Result<Page<DependencyEdge>, SourceError> {
1207 let key = if direction == Direction::DependsOn {
1208 "relations"
1209 } else {
1210 "inverseRelations"
1211 };
1212 let c = d
1213 .get(root.as_str())
1214 .and_then(|v| v.get(key))
1215 .ok_or_else(|| SourceError::Malformed {
1216 message: format!("missing {key}"),
1217 })?;
1218 let nodes = c
1219 .get("nodes")
1220 .and_then(Value::as_array)
1221 .ok_or_else(|| SourceError::Malformed {
1222 message: "missing relation nodes".into(),
1223 })?;
1224 let mut items = Vec::new();
1225 for n in nodes {
1226 let other = n
1227 .get(if direction == Direction::DependsOn {
1228 "relatedIssue"
1229 } else {
1230 "issue"
1231 })
1232 .or_else(|| {
1233 n.get(if direction == Direction::DependsOn {
1234 "relatedProject"
1235 } else {
1236 "project"
1237 })
1238 })
1239 .and_then(|v| v.get("id"))
1240 .and_then(Value::as_str)
1241 .ok_or_else(|| SourceError::Malformed {
1242 message: "missing related id".into(),
1243 })?;
1244 let (from, to) = if direction == Direction::DependsOn {
1245 (id.clone(), NativeId(other.into()))
1246 } else {
1247 (NativeId(other.into()), id.clone())
1248 };
1249 #[derive(Deserialize)]
1251 #[serde(rename_all = "camelCase")]
1252 enum RelationKind {
1253 Blocks,
1254 Related,
1255 }
1256 let kind = match serde_json::from_value::<RelationKind>(n.get("type").cloned().ok_or_else(
1257 || SourceError::Malformed {
1258 message: "missing relation type".into(),
1259 },
1260 )?)
1261 .map_err(|e| SourceError::Malformed {
1262 message: format!("invalid relation type: {e}"),
1263 })? {
1264 RelationKind::Blocks => DependencyKind::Blocks,
1265 RelationKind::Related => DependencyKind::Related,
1266 };
1267 let item_kind = root.item_kind();
1269 items.push(DependencyEdge {
1270 from: DependencyEndpoint::from_native(from, item_kind),
1271 to: DependencyEndpoint::from_native(to, item_kind),
1272 kind,
1273 });
1274 }
1275 let next = page_next(c)?;
1276 Ok(Page { items, next })
1277}
1278
1279fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
1280 match v.get(k) {
1281 None => Err(SourceError::Malformed {
1282 message: format!("missing field {k}"),
1283 }),
1284 Some(Value::Null) => Ok(None),
1285 Some(value) => value
1286 .as_str()
1287 .map(Some)
1288 .ok_or_else(|| SourceError::Malformed {
1289 message: format!("field {k} is not a string"),
1290 }),
1291 }
1292}
1293
1294const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
1297const METADATA_CLOSE: &str = "\n-->";
1298
1299fn metadata_description(
1300 description: Option<String>,
1301) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
1302 let Some(description) = description else {
1303 return Ok((None, Default::default()));
1304 };
1305 let Some(start) = description.rfind(METADATA_OPEN) else {
1306 return Ok((Some(description), Default::default()));
1307 };
1308 let encoded_start = start + METADATA_OPEN.len();
1309 let Some(relative_end) = description[encoded_start..].find(METADATA_CLOSE) else {
1310 return Err(SourceError::Malformed {
1311 message: "unterminated onetaskgraph metadata slot in Linear description".into(),
1312 });
1313 };
1314 let encoded_end = encoded_start + relative_end;
1315 if !description[encoded_end + METADATA_CLOSE.len()..]
1316 .trim()
1317 .is_empty()
1318 {
1319 return Ok((Some(description), Default::default()));
1320 }
1321 let metadata =
1322 serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
1323 SourceError::Malformed {
1324 message: format!(
1325 "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
1326 ),
1327 }
1328 })?;
1329 let visible = description[..start].trim_end();
1330 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
1331}
1332
1333fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
1334 Ok(optional_str(v, k)?.map(Into::into))
1335}
1336fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
1337 let id = str_at(value, field)?;
1338 (!id.is_empty())
1339 .then_some(id)
1340 .ok_or_else(|| SourceError::Malformed {
1341 message: format!("field {field} is an empty backend id"),
1342 })
1343}
1344fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
1345 let root = root.as_str();
1346 let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
1347 message: format!("missing {root}"),
1348 })?;
1349 match payload.get("success").and_then(Value::as_bool) {
1350 Some(true) => Ok(payload),
1351 Some(false) => Err(SourceError::Refused {
1352 message: format!("Linear reported {root} was unsuccessful"),
1353 }),
1354 None => Err(SourceError::Malformed {
1355 message: format!("missing boolean {root}.success"),
1356 }),
1357 }
1358}
1359fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
1360 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
1361 message: "missing pageInfo".into(),
1362 })?;
1363 let more = info
1364 .get("hasNextPage")
1365 .and_then(Value::as_bool)
1366 .ok_or_else(|| SourceError::Malformed {
1367 message: "missing boolean pageInfo.hasNextPage".into(),
1368 })?;
1369 if !more {
1370 return Ok(None);
1371 }
1372 let cursor = str_at(info, "endCursor")?;
1373 Ok(Some(Cursor(cursor.into())))
1374}