1#![deny(missing_docs)]
91
92use chrono::{DateTime, Utc};
93use onetaskgraph_plugin_api::{
94 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
95 Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label, LabelFilter, Location,
96 NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver,
97 SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
98 TaskSource, WriteSupport,
99};
100use schemars::{Schema, schema_for};
101use secrecy::{ExposeSecret, SecretString};
102use serde::Deserialize;
103use serde_json::{Value, json};
104
105pub const KIND: &str = "linear";
107const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
108
109pub mod graphql {
114 pub const VIEWER: &str = "query { viewer { id } }";
116 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} } }";
118 pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name description url createdAt updatedAt status{name type} labels{nodes{id name color}} } }";
120 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} } }";
122 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} } }";
124 pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
126 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}} } }";
128 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}} } }";
130 pub const TEAM: &str =
132 "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
133 pub const ISSUE_STATE: &str = "query($name:String!,$team:String!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
135 pub const PROJECT_STATUS: &str =
137 "query($name:String!){ projectStatuses(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
138 pub const ISSUE_LABEL: &str =
140 "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
141 pub const PROJECT_LABEL: &str =
143 "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
144 pub const ISSUE_CREATE: &str =
146 "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
147 pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
149 pub const PROJECT_CREATE: &str =
151 "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
152 pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
154 pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
156 pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
158 pub const ISSUE_RELATION_DELETE: &str =
160 "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
161 pub const PROJECT_RELATION_DELETE: &str =
163 "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
164 pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
166 pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
168 pub const DOCUMENT: &str = "query($id:String!){ document(id:$id){ id title content url createdAt updatedAt project{id} } }";
170 pub const DOCUMENTS: &str = "query($first:Int,$after:String,$filter:DocumentFilter){ documents(first:$first,after:$after,filter:$filter){ nodes{id title content url createdAt updatedAt project{id}} pageInfo{hasNextPage endCursor} } }";
175 pub const DOCUMENT_CREATE: &str = "mutation($input:DocumentCreateInput!){ documentCreate(input:$input){success document{id}} }";
177 pub const DOCUMENT_UPDATE: &str = "mutation($id:String!,$input:DocumentUpdateInput!){ documentUpdate(id:$id,input:$input){success document{id}} }";
179 pub const DOCUMENT_DELETE: &str = "mutation($id:String!){ documentDelete(id:$id){success} }";
181}
182
183use graphql::{
184 DOCUMENT, DOCUMENTS, ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS,
185 PROJECTS, VIEWER,
186};
187
188#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
190#[serde(default, deny_unknown_fields)]
191pub struct LinearConfig {
192 #[schemars(with = "String")]
194 api_key_env: EnvName,
195 #[schemars(with = "Option<String>")]
197 team: Option<Team>,
198 #[schemars(with = "String")]
200 endpoint: Endpoint,
201}
202
203#[derive(Debug, Clone, Deserialize)]
204#[serde(try_from = "String")]
205struct EnvName(String);
206impl TryFrom<String> for EnvName {
207 type Error = String;
208 fn try_from(value: String) -> Result<Self, Self::Error> {
209 let mut bytes = value.bytes();
210 if bytes
211 .next()
212 .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
213 && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
214 {
215 Ok(Self(value))
216 } else {
217 Err("must be an uppercase environment-variable name".into())
218 }
219 }
220}
221#[derive(Debug, Clone, Deserialize)]
222#[serde(try_from = "String")]
223struct Team(String);
224impl TryFrom<String> for Team {
225 type Error = String;
226 fn try_from(value: String) -> Result<Self, Self::Error> {
227 if value.trim().is_empty() {
228 Err("must not be empty".into())
229 } else {
230 Ok(Self(value))
231 }
232 }
233}
234#[derive(Debug, Clone, Deserialize)]
235#[serde(try_from = "String")]
236struct Endpoint(String);
237impl TryFrom<String> for Endpoint {
238 type Error = String;
239 fn try_from(value: String) -> Result<Self, Self::Error> {
240 let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
241 if matches!(url.scheme(), "http" | "https") {
242 Ok(Self(value))
243 } else {
244 Err("must use http or https".into())
245 }
246 }
247}
248
249impl Default for LinearConfig {
250 fn default() -> Self {
251 Self {
252 api_key_env: EnvName("LINEAR_API_KEY".into()),
253 team: None,
254 endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, Default)]
261pub struct Plugin;
262
263impl SourcePlugin for Plugin {
264 fn kind(&self) -> &'static str {
265 KIND
266 }
267 fn config_schema(&self) -> Schema {
268 schema_for!(LinearConfig)
269 }
270 fn build(
271 &self,
272 name: &SourceName,
273 config: &Value,
274 secrets: &dyn SecretResolver,
275 ) -> Result<Box<dyn TaskSource>, SourceError> {
276 let config: LinearConfig =
277 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
278 message: format!("source {name}: {e}"),
279 })?;
280 let key = secrets
281 .get(&config.api_key_env.0)
282 .filter(|v| !v.expose_secret().trim().is_empty())
283 .ok_or_else(|| SourceError::Auth {
284 message: format!("set environment variable {}", config.api_key_env.0),
285 })?;
286 Ok(Box::new(LinearSource {
287 client: reqwest::Client::new(),
288 endpoint: config.endpoint,
289 key,
290 team: config.team,
291 name: name.clone(),
292 }))
293 }
294}
295
296struct LinearSource {
297 client: reqwest::Client,
298 endpoint: Endpoint,
299 key: SecretString,
300 team: Option<Team>,
301 name: SourceName,
305}
306#[derive(Clone, Copy)]
307enum WriteKind {
308 Task,
309 Project,
310}
311enum Lookup<'a> {
312 Team(&'a str),
313 IssueState { name: &'a str, team: &'a NativeId },
314 ProjectStatus(&'a str),
315 IssueLabel(&'a str),
316 ProjectLabel(&'a str),
317}
318impl Lookup<'_> {
319 fn query(&self) -> &'static str {
320 match self {
321 Self::Team(_) => graphql::TEAM,
322 Self::IssueState { .. } => graphql::ISSUE_STATE,
323 Self::ProjectStatus(_) => graphql::PROJECT_STATUS,
324 Self::IssueLabel(_) => graphql::ISSUE_LABEL,
325 Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
326 }
327 }
328 fn connection(&self) -> &'static str {
329 match self {
330 Self::Team(_) => "teams",
331 Self::IssueState { .. } => "workflowStates",
332 Self::ProjectStatus(_) => "projectStatuses",
333 Self::IssueLabel(_) => "issueLabels",
334 Self::ProjectLabel(_) => "projectLabels",
335 }
336 }
337 fn diagnostic(&self) -> String {
338 match self {
339 Self::Team(_) => "configured team".into(),
340 Self::IssueState { name, .. } => format!("workflow state {name:?}"),
341 Self::ProjectStatus(name) => format!("project status {name:?}"),
342 Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
343 }
344 }
345 fn variables(&self) -> Value {
346 match self {
347 Self::Team(key) => json!({"key":key}),
348 Self::IssueState { name, team } => json!({"name":name,"team":team.0}),
349 Self::ProjectStatus(name) | Self::IssueLabel(name) | Self::ProjectLabel(name) => {
350 json!({"name":name})
351 }
352 }
353 }
354}
355#[derive(Clone, Copy)]
356enum MutationRoot {
357 IssueCreate,
358 IssueUpdate,
359 ProjectCreate,
360 ProjectUpdate,
361 IssueRelationCreate,
362 ProjectRelationCreate,
363 IssueRelationDelete,
364 ProjectRelationDelete,
365 IssueDelete,
366 ProjectDelete,
367 DocumentCreate,
368 DocumentUpdate,
369 DocumentDelete,
370}
371impl MutationRoot {
372 fn as_str(self) -> &'static str {
373 match self {
374 Self::IssueCreate => "issueCreate",
375 Self::IssueUpdate => "issueUpdate",
376 Self::ProjectCreate => "projectCreate",
377 Self::ProjectUpdate => "projectUpdate",
378 Self::IssueRelationCreate => "issueRelationCreate",
379 Self::ProjectRelationCreate => "projectRelationCreate",
380 Self::IssueRelationDelete => "issueRelationDelete",
381 Self::ProjectRelationDelete => "projectRelationDelete",
382 Self::IssueDelete => "issueDelete",
383 Self::ProjectDelete => "projectDelete",
384 Self::DocumentCreate => "documentCreate",
385 Self::DocumentUpdate => "documentUpdate",
386 Self::DocumentDelete => "documentDelete",
387 }
388 }
389}
390
391#[derive(Deserialize)]
392struct Envelope {
393 data: Option<Value>,
395 #[serde(default)]
396 errors: Vec<GqlError>,
397}
398#[derive(Deserialize)]
399struct GqlError {
400 message: String,
401 extensions: Option<GqlExtensions>,
402}
403#[derive(Deserialize)]
404#[serde(rename_all = "camelCase")]
405struct GqlExtensions {
406 code: GqlErrorCode,
407 retry_after: Option<u64>,
408}
409#[derive(Deserialize)]
410enum GqlErrorCode {
411 #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
412 RateLimited,
413 #[serde(other)]
414 Other,
415}
416
417impl LinearSource {
418 async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
420 let response = self
421 .client
422 .post(&self.endpoint.0)
423 .header("Authorization", self.key.expose_secret())
424 .json(&json!({"query": query, "variables": variables}))
425 .send()
426 .await
427 .map_err(|e| SourceError::Unavailable {
428 message: e.to_string(),
429 })?;
430 let status = response.status();
431 let retry = response
432 .headers()
433 .get("retry-after")
434 .and_then(|v| v.to_str().ok())
435 .and_then(|v| v.parse().ok());
436 if status.as_u16() == 429 {
437 return Err(SourceError::RateLimited {
438 retry_after_seconds: retry,
439 message: None,
443 });
444 }
445 if status.as_u16() == 401 || status.as_u16() == 403 {
446 return Err(SourceError::Auth {
447 message: "Linear rejected the configured credential".into(),
448 });
449 }
450 if !status.is_success() {
451 return Err(SourceError::Unavailable {
452 message: format!("Linear returned HTTP {status}"),
453 });
454 }
455 let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
456 message: e.to_string(),
457 })?;
458 if let Some(error) = body.errors.first() {
459 if error
460 .extensions
461 .as_ref()
462 .is_some_and(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
463 {
464 let hint = error
465 .extensions
466 .as_ref()
467 .and_then(|value| value.retry_after);
468 return Err(SourceError::RateLimited {
469 retry_after_seconds: hint.or(retry),
470 message: None,
471 });
472 }
473 return Err(SourceError::Refused {
474 message: error.message.clone(),
475 });
476 }
477 body.data.ok_or_else(|| SourceError::Malformed {
478 message: "GraphQL response has no data".into(),
479 })
480 }
481
482 fn filter(
484 &self,
485 labels: &onetaskgraph_plugin_api::LabelFilter,
486 statuses: &[StatusCategory],
487 project: Option<&ProjectFilter>,
488 ) -> Value {
489 let mut parts = Vec::new();
490 if let Some(team) = &self.team {
491 parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
492 }
493 if !labels.any_of.is_empty() {
494 parts.push(json!({"labels": {"some": {"name": {"inIgnoreCase": labels.any_of}}}}));
495 }
496 for name in &labels.all_of {
497 parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
498 }
499 for name in &labels.none_of {
500 parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
501 }
502 if !statuses.is_empty() {
503 parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(linear_statuses).collect::<Vec<_>>()}}}));
504 }
505 match project {
506 Some(ProjectFilter::Orphans) => parts.push(json!({"project": {"null": true}})),
507 Some(ProjectFilter::Is(id)) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
508 _ => {}
509 }
510 if parts.len() == 1 {
511 parts.pop().unwrap()
512 } else {
513 json!({"and": parts})
514 }
515 }
516 async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
519 let data = self.send(lookup.query(), lookup.variables()).await?;
520 let connection = lookup.connection();
521 let nodes = data
522 .get(connection)
523 .and_then(|v| v.get("nodes"))
524 .and_then(Value::as_array)
525 .ok_or_else(|| SourceError::Malformed {
526 message: format!("missing {connection}.nodes"),
527 })?;
528 if nodes.len() != 1 {
529 return Err(SourceError::Refused {
530 message: format!(
531 "source {} cannot resolve {} uniquely",
532 self.name,
533 lookup.diagnostic()
534 ),
535 });
536 }
537 Ok(NativeId(backend_id(&nodes[0], "id")?.to_owned()))
538 }
539 async fn team_id(&self) -> Result<NativeId, SourceError> {
540 let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
541 message: format!(
542 "source {} needs config.team before it can create Linear items",
543 self.name
544 ),
545 })?;
546 self.one_id(Lookup::Team(&team.0)).await
547 }
548 async fn label_ids(
549 &self,
550 labels: &[Label],
551 kind: WriteKind,
552 ) -> Result<Vec<NativeId>, SourceError> {
553 let mut ids = Vec::with_capacity(labels.len());
554 for label in labels {
555 ids.push(
556 self.one_id(if matches!(kind, WriteKind::Project) {
557 Lookup::ProjectLabel(&label.name)
558 } else {
559 Lookup::IssueLabel(&label.name)
560 })
561 .await?,
562 );
563 }
564 Ok(ids)
565 }
566 fn write_description(
567 &self,
568 content: Option<&str>,
569 metadata: &std::collections::BTreeMap<String, Value>,
570 repositories: &[Repository],
571 edges: &[DependencyEdge],
572 kind: WriteKind,
573 ) -> Result<Option<String>, SourceError> {
574 let recorded = edges
575 .iter()
576 .filter(|edge| {
577 edge.to.kind
578 != match kind {
579 WriteKind::Task => ItemKind::Task,
580 WriteKind::Project => ItemKind::Project,
581 }
582 || edge
583 .to
584 .id()
585 .split_once(':')
586 .is_some_and(|(source, _)| source != self.name.as_str())
587 })
588 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
589 .collect::<Vec<_>>();
590 Self::long_form(content, metadata, repositories, recorded)
591 }
592
593 fn long_form(
599 content: Option<&str>,
600 metadata: &std::collections::BTreeMap<String, Value>,
601 repositories: &[Repository],
602 recorded: Vec<Value>,
603 ) -> Result<Option<String>, SourceError> {
604 let mut metadata = metadata.clone();
605 if repositories.is_empty() {
606 metadata.remove(Repository::METADATA_KEY);
607 } else {
608 metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
609 }
610 if recorded.is_empty() {
611 metadata.remove(DependencyEdge::RECORDED_KEY);
612 } else {
613 metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
614 }
615 let visible = content.unwrap_or_default();
616 if metadata.is_empty() {
617 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
618 }
619 let encoded = serde_json::to_string(&metadata).map_err(|error| SourceError::Malformed {
620 message: error.to_string(),
621 })?;
622 Ok(Some(if visible.is_empty() {
623 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
624 } else {
625 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
626 }))
627 }
628 async fn write_relations(
629 &self,
630 near: &NativeId,
631 edges: &[DependencyEdge],
632 kind: WriteKind,
633 ) -> Result<(), SourceError> {
634 let mut cursor: Option<Cursor> = None;
635 loop {
636 let data = self
637 .send(
638 if matches!(kind, WriteKind::Project) {
639 PROJECT_RELATIONS
640 } else {
641 ISSUE_RELATIONS
642 },
643 json!({"id":near.0,"first":250,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
644 )
645 .await?;
646 let root = data
647 .get(if matches!(kind, WriteKind::Project) {
648 "project"
649 } else {
650 "issue"
651 })
652 .ok_or_else(|| SourceError::Malformed {
653 message: "missing relation item".into(),
654 })?;
655 let relations = root
656 .get("relations")
657 .ok_or_else(|| SourceError::Malformed {
658 message: "missing relations".into(),
659 })?;
660 for relation in relations
661 .get("nodes")
662 .and_then(Value::as_array)
663 .ok_or_else(|| SourceError::Malformed {
664 message: "missing relations.nodes".into(),
665 })?
666 {
667 let id = backend_id(relation, "id")?;
668 let (query, mutation) = if matches!(kind, WriteKind::Project) {
669 (
670 graphql::PROJECT_RELATION_DELETE,
671 MutationRoot::ProjectRelationDelete,
672 )
673 } else {
674 (
675 graphql::ISSUE_RELATION_DELETE,
676 MutationRoot::IssueRelationDelete,
677 )
678 };
679 let deleted = self.send(query, json!({"id":id})).await?;
680 mutation_payload(&deleted, mutation)?;
681 }
682 let Some(next) = page_next(relations)? else {
683 break;
684 };
685 cursor = Some(next);
686 }
687 for edge in edges {
688 if edge.to.kind
689 != match kind {
690 WriteKind::Task => ItemKind::Task,
691 WriteKind::Project => ItemKind::Project,
692 }
693 {
694 continue;
695 }
696 let far = match edge.to.id().split_once(':') {
697 Some((source, native)) if source == self.name.as_str() => native,
698 Some(_) => continue,
699 None => edge.to.id(),
700 };
701 let relation_type = match edge.kind {
702 DependencyKind::Blocks => "blocks",
703 DependencyKind::Related => "related",
704 };
705 let (query, input) = if matches!(kind, WriteKind::Project) {
706 (
707 graphql::PROJECT_RELATION_CREATE,
708 json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type}),
709 )
710 } else {
711 (
712 graphql::ISSUE_RELATION_CREATE,
713 json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
714 )
715 };
716 let data = self.send(query, json!({"input":input})).await?;
717 let mutation = if matches!(kind, WriteKind::Project) {
718 MutationRoot::ProjectRelationCreate
719 } else {
720 MutationRoot::IssueRelationCreate
721 };
722 let payload = mutation_payload(&data, mutation)?;
723 let relation = payload
724 .get(if matches!(kind, WriteKind::Project) {
725 "projectRelation"
726 } else {
727 "issueRelation"
728 })
729 .ok_or_else(|| SourceError::Malformed {
730 message: format!("missing {} relation", mutation.as_str()),
731 })?;
732 backend_id(relation, "id")?;
733 }
734 Ok(())
735 }
736
737 async fn prepare_edges(
738 &self,
739 edges: &[DependencyEdge],
740 kind: WriteKind,
741 ) -> Result<Vec<DependencyEdge>, SourceError> {
742 let mut prepared = Vec::with_capacity(edges.len());
743 for edge in edges {
744 let mut edge = edge.clone();
745 if edge.to.kind
746 == match kind {
747 WriteKind::Task => ItemKind::Task,
748 WriteKind::Project => ItemKind::Project,
749 }
750 && edge
751 .to
752 .id()
753 .split_once(':')
754 .is_some_and(|(source, _)| source != self.name.as_str())
755 {
756 let mut cursor: Option<Cursor> = None;
757 loop {
758 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?;
759 let (items, next) = if matches!(kind, WriteKind::Project) {
760 let page = connection(&data, "projects", map_project)?;
761 (
762 page.items
763 .into_iter()
764 .map(|item| (item.id, item.metadata))
765 .collect::<Vec<_>>(),
766 page.next,
767 )
768 } else {
769 let page = connection(&data, "issues", map_task)?;
770 (
771 page.items
772 .into_iter()
773 .map(|item| (item.id, item.metadata))
774 .collect::<Vec<_>>(),
775 page.next,
776 )
777 };
778 if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
779 metadata.get("onetaskgraph.origin").and_then(Value::as_str)
780 == Some(edge.to.id())
781 }) {
782 edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
783 break;
784 }
785 let Some(next) = next else { break };
786 cursor = Some(next);
787 }
788 }
789 prepared.push(edge);
790 }
791 Ok(prepared)
792 }
793}
794
795#[async_trait::async_trait]
796impl TaskSource for LinearSource {
797 fn kind(&self) -> &'static str {
798 KIND
799 }
800 fn capabilities(&self) -> Capabilities {
801 Capabilities {
802 projects: Support::Native,
803 documents: Support::Native,
804 orphan_tasks: Support::Native,
805 filter_by_label: Support::Native,
806 filter_by_status: Support::Native,
807 search_title: Support::Unsupported,
808 search_content: Support::Unsupported,
809 task_dependencies: DependencySupport::BothDirections,
810 project_dependencies: DependencySupport::BothDirections,
811 max_page_size: 250,
812 }
813 }
814 fn writes(&self) -> WriteSupport {
815 WriteSupport::Supported
816 }
817 async fn health(&self) -> Result<Health, SourceError> {
818 let data = self.send(VIEWER, json!({})).await?;
819 str_at(
820 data.get("viewer").ok_or_else(|| SourceError::Malformed {
821 message: "missing viewer".into(),
822 })?,
823 "id",
824 )?;
825 Ok(Health {
826 reachable: true,
827 detail: None,
828 })
829 }
830 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
831 let d = self.send(ISSUE, json!({"id":id.0})).await?;
832 optional(&d, "issue", map_task)
833 }
834 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
835 let d = self.send(PROJECT, json!({"id":id.0})).await?;
836 optional(&d, "project", map_project)
837 }
838 async fn query_tasks(
839 &self,
840 query: &TaskQuery,
841 page: &PageRequest,
842 ) -> Result<Page<Task>, SourceError> {
843 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?;
844 connection(&d, "issues", map_task)
845 }
846 async fn query_projects(
847 &self,
848 query: &ProjectQuery,
849 page: &PageRequest,
850 ) -> Result<Page<Project>, SourceError> {
851 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?;
853 connection(&d, "projects", map_project)
854 }
855 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
856 let d = self
857 .send(
858 LABELS,
859 json!({"first":page.limit.min(250),"after":page.cursor.as_ref().map(|c|&c.0)}),
860 )
861 .await?;
862 connection(&d, "issueLabels", map_label)
863 }
864 async fn task_dependencies(
865 &self,
866 id: &NativeId,
867 direction: Direction,
868 page: &PageRequest,
869 ) -> Result<Page<DependencyEdge>, SourceError> {
870 self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
871 .await
872 }
873 async fn project_dependencies(
874 &self,
875 id: &NativeId,
876 direction: Direction,
877 page: &PageRequest,
878 ) -> Result<Page<DependencyEdge>, SourceError> {
879 self.dependencies(
880 PROJECT_RELATIONS,
881 DependencyRoot::Project,
882 id,
883 direction,
884 page,
885 )
886 .await
887 }
888 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
889 let edges = self
890 .prepare_edges(&write.depends_on, WriteKind::Task)
891 .await?;
892 let team = self.team_id().await?;
893 let state = self
894 .one_id(Lookup::IssueState {
895 name: &write.item.status.name,
896 team: &team,
897 })
898 .await?;
899 let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
900 let description = self.write_description(
901 write.item.content.as_deref(),
902 &write.item.metadata,
903 &write.item.repositories,
904 &edges,
905 WriteKind::Task,
906 )?;
907 let input = json!({"title":write.item.title,"description":description,"stateId":state,"labelIds":labels,"projectId":write.item.project.as_ref().map(|id| id.0.clone())});
908 let (query, variables, root) = match &write.target {
909 Some(id) => (
910 graphql::ISSUE_UPDATE,
911 json!({"id":id.0,"input":input}),
912 MutationRoot::IssueUpdate,
913 ),
914 None => (
915 graphql::ISSUE_CREATE,
916 {
917 let mut input = input;
918 input["teamId"] = Value::String(team.0);
919 json!({"input":input})
920 },
921 MutationRoot::IssueCreate,
922 ),
923 };
924 let data = self.send(query, variables).await?;
925 let issue =
926 mutation_payload(&data, root)?
927 .get("issue")
928 .ok_or_else(|| SourceError::Malformed {
929 message: format!("missing {}.issue", root.as_str()),
930 })?;
931 let id = NativeId(backend_id(issue, "id")?.into());
932 self.write_relations(&id, &edges, WriteKind::Task).await?;
933 Ok(id)
934 }
935 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
936 let edges = self
937 .prepare_edges(&write.depends_on, WriteKind::Project)
938 .await?;
939 let team = self.team_id().await?;
940 let status = self
941 .one_id(Lookup::ProjectStatus(&write.item.status.name))
942 .await?;
943 let labels = self
944 .label_ids(&write.item.labels, WriteKind::Project)
945 .await?;
946 let description = self.write_description(
947 write.item.content.as_deref(),
948 &write.item.metadata,
949 &write.item.repositories,
950 &edges,
951 WriteKind::Project,
952 )?;
953 let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
954 let (query, variables, root) = match &write.target {
955 Some(id) => (
956 graphql::PROJECT_UPDATE,
957 json!({"id":id.0,"input":input}),
958 MutationRoot::ProjectUpdate,
959 ),
960 None => (
961 graphql::PROJECT_CREATE,
962 {
963 let mut input = input;
964 input["teamIds"] = json!([team]);
965 json!({"input":input})
966 },
967 MutationRoot::ProjectCreate,
968 ),
969 };
970 let data = self.send(query, variables).await?;
971 let project = mutation_payload(&data, root)?
972 .get("project")
973 .ok_or_else(|| SourceError::Malformed {
974 message: format!("missing {}.project", root.as_str()),
975 })?;
976 let id = NativeId(backend_id(project, "id")?.into());
977 self.write_relations(&id, &edges, WriteKind::Project)
978 .await?;
979 Ok(id)
980 }
981 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
982 if self.get_task(id).await?.is_none() {
986 return Ok(());
987 }
988 let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
989 mutation_payload(&data, MutationRoot::IssueDelete)?;
990 Ok(())
991 }
992 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
993 if self.get_project(id).await?.is_none() {
996 return Ok(());
997 }
998 let data = self
999 .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
1000 .await?;
1001 mutation_payload(&data, MutationRoot::ProjectDelete)?;
1002 Ok(())
1003 }
1004 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
1005 let d = self.send(DOCUMENT, json!({"id":id.0})).await?;
1010 optional(&d, "document", map_document)
1011 }
1012 async fn query_documents(
1013 &self,
1014 query: &DocumentQuery,
1015 page: &PageRequest,
1016 ) -> Result<Page<Document>, SourceError> {
1017 let want = page.limit.min(250) as usize;
1021 let mut filter = serde_json::Map::new();
1022 if let ProjectFilter::Is(id) = &query.project {
1023 filter.insert("project".into(), json!({"id": {"eq": id.0}}));
1024 }
1025 let filter = Value::Object(filter);
1026 let mut items = Vec::new();
1027 let mut cursor = page.cursor.clone();
1028 loop {
1029 let first = want.saturating_sub(items.len()).max(1);
1032 let d = self
1033 .send(
1034 DOCUMENTS,
1035 json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
1036 )
1037 .await?;
1038 let fetched = connection(&d, "documents", map_document)?;
1039 items.extend(
1040 fetched
1041 .items
1042 .into_iter()
1043 .filter(|document| document_matches(document, &query.project, &query.labels)),
1044 );
1045 cursor = fetched.next;
1046 if cursor.is_none() || items.len() >= want {
1047 return Ok(Page {
1048 items,
1049 next: cursor,
1050 });
1051 }
1052 }
1053 }
1054 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
1055 if !write.item.labels.is_empty() {
1060 let named = write
1061 .item
1062 .labels
1063 .iter()
1064 .map(|label| label.name.as_str())
1065 .collect::<Vec<_>>()
1066 .join(", ");
1067 return Err(SourceError::Refused {
1068 message: format!(
1069 "source {} cannot carry a document's labels, because Linear's own \
1070 document type has none: {named}",
1071 self.name
1072 ),
1073 });
1074 }
1075 if !write.depends_on.is_empty()
1076 || write
1077 .item
1078 .metadata
1079 .contains_key(DependencyEdge::RECORDED_KEY)
1080 {
1081 return Err(SourceError::Refused {
1082 message: format!(
1083 "source {} cannot carry {} on a document, because a document is not \
1084 work and nothing may depend on one",
1085 self.name,
1086 DependencyEdge::RECORDED_KEY
1087 ),
1088 });
1089 }
1090 let content = Self::long_form(
1091 write.item.content.as_deref(),
1092 &write.item.metadata,
1093 &write.item.repositories,
1094 Vec::new(),
1095 )?;
1096 let project = write.item.project.as_ref().map(|id| id.0.clone());
1097 let (query, variables, root) = match &write.target {
1098 Some(id) => {
1099 if self.get_document(id).await?.is_none() {
1103 return Err(SourceError::Refused {
1104 message: format!("source {} holds no document {}", self.name, id.0),
1105 });
1106 }
1107 (
1108 graphql::DOCUMENT_UPDATE,
1109 json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
1110 MutationRoot::DocumentUpdate,
1111 )
1112 }
1113 None => {
1114 let mut input =
1115 json!({"title":write.item.title,"content":content,"projectId":project});
1116 if project.is_none() {
1121 input["teamId"] = Value::String(self.team_id().await?.0);
1122 }
1123 (
1124 graphql::DOCUMENT_CREATE,
1125 json!({ "input": input }),
1126 MutationRoot::DocumentCreate,
1127 )
1128 }
1129 };
1130 let data = self.send(query, variables).await?;
1131 let document = mutation_payload(&data, root)?
1132 .get("document")
1133 .ok_or_else(|| SourceError::Malformed {
1134 message: format!("missing {}.document", root.as_str()),
1135 })?;
1136 Ok(NativeId(backend_id(document, "id")?.into()))
1137 }
1138 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
1139 if self.get_document(id).await?.is_none() {
1142 return Ok(());
1143 }
1144 let data = self
1145 .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
1146 .await?;
1147 mutation_payload(&data, MutationRoot::DocumentDelete)?;
1148 Ok(())
1149 }
1150}
1151
1152const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1158
1159impl LinearSource {
1160 async fn dependencies(
1161 &self,
1162 query: &str,
1163 root: DependencyRoot,
1164 id: &NativeId,
1165 direction: Direction,
1166 page: &PageRequest,
1167 ) -> Result<Page<DependencyEdge>, SourceError> {
1168 let limit = page.limit.min(250);
1169 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1170 if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
1171 if direction != Direction::DependsOn {
1177 return Err(SourceError::Malformed {
1178 message: format!(
1179 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a reverse dependency read never issues; resume it in the direction that reported it"
1180 ),
1181 });
1182 }
1183 let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
1184 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
1185 })?;
1186 let d = self
1187 .send(query, json!({"id":id.0,"first":1,"after":null}))
1188 .await?;
1189 return Ok(recorded_page(
1190 recorded(&d, root, id, &self.name)?,
1191 offset,
1192 limit as usize,
1193 ));
1194 }
1195 let d = self
1196 .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
1197 .await?;
1198 let mut answered = relation_page(&d, root, id, direction)?;
1199 if answered.next.is_none()
1202 && direction == Direction::DependsOn
1203 && !recorded(&d, root, id, &self.name)?.is_empty()
1204 {
1205 answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1206 }
1207 Ok(answered)
1208 }
1209}
1210
1211fn recorded(
1212 d: &Value,
1213 root: DependencyRoot,
1214 id: &NativeId,
1215 name: &SourceName,
1216) -> Result<Vec<DependencyEdge>, SourceError> {
1217 let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
1218 message: format!("missing {}", root.as_str()),
1219 })?;
1220 let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
1221 DependencyEdge::recorded(
1226 &metadata,
1227 id,
1228 root.item_kind(),
1229 name,
1230 Some(root.item_kind()),
1231 )
1232 .map_err(|message| SourceError::Malformed { message })
1233}
1234
1235fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1236 let total = edges.len();
1237 let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
1238 let end = offset.saturating_add(items.len());
1239 Page {
1240 items,
1241 next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
1242 }
1243}
1244
1245fn linear_statuses(s: &StatusCategory) -> Vec<&'static str> {
1247 match s {
1248 StatusCategory::Draft => vec![],
1252 StatusCategory::Backlog => vec!["backlog"],
1253 StatusCategory::Todo => vec!["unstarted"],
1254 StatusCategory::InProgress => vec!["started"],
1255 StatusCategory::Done => vec!["completed"],
1256 StatusCategory::Cancelled => vec!["canceled"],
1257 StatusCategory::Unknown => vec![],
1258 }
1259}
1260fn status(v: &Value) -> Result<Status, SourceError> {
1261 let name = str_at(v, "name")?.into();
1262 let category = match str_at(v, "type")? {
1263 "backlog" => StatusCategory::Backlog,
1264 "unstarted" => StatusCategory::Todo,
1265 "started" => StatusCategory::InProgress,
1266 "completed" => StatusCategory::Done,
1267 "canceled" => StatusCategory::Cancelled,
1268 _ => StatusCategory::Unknown,
1269 };
1270 Ok(Status { category, name })
1271}
1272fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
1274 v.get(k)
1275 .and_then(Value::as_str)
1276 .ok_or_else(|| SourceError::Malformed {
1277 message: format!("missing string field {k}"),
1278 })
1279}
1280fn map_label(v: &Value) -> Result<Label, SourceError> {
1281 Ok(Label {
1282 id: NativeId(str_at(v, "id")?.into()),
1283 name: str_at(v, "name")?.into(),
1284 color: optional_string(v, "color")?,
1285 })
1286}
1287fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
1288 v.get("nodes")
1289 .and_then(Value::as_array)
1290 .ok_or_else(|| SourceError::Malformed {
1291 message: "missing label nodes".into(),
1292 })?
1293 .iter()
1294 .map(map_label)
1295 .collect()
1296}
1297fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1298 optional_str(v, k)?
1299 .map(|s| {
1300 s.parse().map_err(|e| SourceError::Malformed {
1301 message: format!("invalid {k}: {e}"),
1302 })
1303 })
1304 .transpose()
1305}
1306fn map_task(v: &Value) -> Result<Task, SourceError> {
1307 let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1308 let repositories = Repository::from_metadata(&metadata)
1309 .map_err(|message| SourceError::Malformed { message })?;
1310 let url = optional_string(v, "url")?;
1311 Ok(Task {
1312 id: NativeId(str_at(v, "id")?.into()),
1313 title: str_at(v, "title")?.into(),
1314 content,
1315 status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
1316 message: "missing state".into(),
1317 })?)?,
1318 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1319 message: "missing labels".into(),
1320 })?)?,
1321 project: filed_under(v)?,
1322 location: web_address(url.as_deref()),
1323 url,
1324 created_at: time(v, "createdAt")?,
1325 updated_at: time(v, "updatedAt")?,
1326 metadata,
1327 repositories,
1328 })
1329}
1330fn map_project(v: &Value) -> Result<Project, SourceError> {
1331 let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1332 let repositories = Repository::from_metadata(&metadata)
1333 .map_err(|message| SourceError::Malformed { message })?;
1334 let url = optional_string(v, "url")?;
1335 Ok(Project {
1336 id: NativeId(str_at(v, "id")?.into()),
1337 title: str_at(v, "name")?.into(),
1338 content,
1339 status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
1340 message: "missing status".into(),
1341 })?)?,
1342 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1343 message: "missing project labels".into(),
1344 })?)?,
1345 location: web_address(url.as_deref()),
1346 url,
1347 created_at: time(v, "createdAt")?,
1348 updated_at: time(v, "updatedAt")?,
1349 metadata,
1350 repositories,
1351 })
1352}
1353
1354fn web_address(url: Option<&str>) -> Option<Location> {
1362 url.map(|url| Location::Url(url.to_owned()))
1363}
1364
1365fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
1370 match v.get("project") {
1371 None => Err(SourceError::Malformed {
1372 message: "missing project field".into(),
1373 }),
1374 Some(Value::Null) => Ok(None),
1375 Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
1376 }
1377}
1378
1379fn map_document(v: &Value) -> Result<Document, SourceError> {
1380 let (content, metadata) = metadata_description(optional_string(v, "content")?)?;
1381 let repositories = Repository::from_metadata(&metadata)
1382 .map_err(|message| SourceError::Malformed { message })?;
1383 let url = optional_string(v, "url")?;
1384 Ok(Document {
1385 id: NativeId(str_at(v, "id")?.into()),
1386 title: str_at(v, "title")?.into(),
1387 content,
1388 project: filed_under(v)?,
1389 labels: Vec::new(),
1396 location: web_address(url.as_deref()),
1397 url,
1398 created_at: time(v, "createdAt")?,
1399 updated_at: time(v, "updatedAt")?,
1400 metadata,
1401 repositories,
1402 })
1403}
1404
1405fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
1415 let carries = |name: &String| {
1416 document
1417 .labels
1418 .iter()
1419 .any(|label| label.name.eq_ignore_ascii_case(name))
1420 };
1421 let filed = match project {
1422 ProjectFilter::Any => true,
1423 ProjectFilter::Orphans => document.project.is_none(),
1424 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
1425 };
1426 filed
1427 && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
1428 && labels.all_of.iter().all(&carries)
1429 && !labels.none_of.iter().any(&carries)
1430}
1431
1432fn optional<T>(
1433 d: &Value,
1434 k: &str,
1435 f: fn(&Value) -> Result<T, SourceError>,
1436) -> Result<Option<T>, SourceError> {
1437 match d.get(k) {
1438 None => Err(SourceError::Malformed {
1439 message: format!("missing {k}"),
1440 }),
1441 Some(Value::Null) => Ok(None),
1442 Some(value) => f(value).map(Some),
1443 }
1444}
1445fn connection<T>(
1446 d: &Value,
1447 k: &str,
1448 f: fn(&Value) -> Result<T, SourceError>,
1449) -> Result<Page<T>, SourceError> {
1450 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
1451 message: format!("missing {k} connection"),
1452 })?;
1453 let items = c
1454 .get("nodes")
1455 .and_then(Value::as_array)
1456 .ok_or_else(|| SourceError::Malformed {
1457 message: "missing nodes".into(),
1458 })?
1459 .iter()
1460 .map(f)
1461 .collect::<Result<_, _>>()?;
1462 let next = page_next(c)?;
1463 Ok(Page { items, next })
1464}
1465#[derive(Clone, Copy)]
1466enum DependencyRoot {
1467 Issue,
1468 Project,
1469}
1470impl DependencyRoot {
1471 const fn item_kind(self) -> ItemKind {
1472 match self {
1473 Self::Issue => ItemKind::Task,
1474 Self::Project => ItemKind::Project,
1475 }
1476 }
1477 const fn as_str(self) -> &'static str {
1478 match self {
1479 Self::Issue => "issue",
1480 Self::Project => "project",
1481 }
1482 }
1483}
1484fn relation_page(
1485 d: &Value,
1486 root: DependencyRoot,
1487 id: &NativeId,
1488 direction: Direction,
1489) -> Result<Page<DependencyEdge>, SourceError> {
1490 let key = if direction == Direction::DependsOn {
1491 "relations"
1492 } else {
1493 "inverseRelations"
1494 };
1495 let c = d
1496 .get(root.as_str())
1497 .and_then(|v| v.get(key))
1498 .ok_or_else(|| SourceError::Malformed {
1499 message: format!("missing {key}"),
1500 })?;
1501 let nodes = c
1502 .get("nodes")
1503 .and_then(Value::as_array)
1504 .ok_or_else(|| SourceError::Malformed {
1505 message: "missing relation nodes".into(),
1506 })?;
1507 let mut items = Vec::new();
1508 for n in nodes {
1509 let other = n
1510 .get(if direction == Direction::DependsOn {
1511 "relatedIssue"
1512 } else {
1513 "issue"
1514 })
1515 .or_else(|| {
1516 n.get(if direction == Direction::DependsOn {
1517 "relatedProject"
1518 } else {
1519 "project"
1520 })
1521 })
1522 .and_then(|v| v.get("id"))
1523 .and_then(Value::as_str)
1524 .ok_or_else(|| SourceError::Malformed {
1525 message: "missing related id".into(),
1526 })?;
1527 let (from, to) = if direction == Direction::DependsOn {
1528 (id.clone(), NativeId(other.into()))
1529 } else {
1530 (NativeId(other.into()), id.clone())
1531 };
1532 #[derive(Deserialize)]
1534 #[serde(rename_all = "camelCase")]
1535 enum RelationKind {
1536 Blocks,
1537 Related,
1538 }
1539 let kind = match serde_json::from_value::<RelationKind>(n.get("type").cloned().ok_or_else(
1540 || SourceError::Malformed {
1541 message: "missing relation type".into(),
1542 },
1543 )?)
1544 .map_err(|e| SourceError::Malformed {
1545 message: format!("invalid relation type: {e}"),
1546 })? {
1547 RelationKind::Blocks => DependencyKind::Blocks,
1548 RelationKind::Related => DependencyKind::Related,
1549 };
1550 let item_kind = root.item_kind();
1552 items.push(DependencyEdge {
1553 from: DependencyEndpoint::from_native(from, item_kind),
1554 to: DependencyEndpoint::from_native(to, item_kind),
1555 kind,
1556 });
1557 }
1558 let next = page_next(c)?;
1559 Ok(Page { items, next })
1560}
1561
1562fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
1563 match v.get(k) {
1564 None => Err(SourceError::Malformed {
1565 message: format!("missing field {k}"),
1566 }),
1567 Some(Value::Null) => Ok(None),
1568 Some(value) => value
1569 .as_str()
1570 .map(Some)
1571 .ok_or_else(|| SourceError::Malformed {
1572 message: format!("field {k} is not a string"),
1573 }),
1574 }
1575}
1576
1577const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
1580const METADATA_CLOSE: &str = "\n-->";
1581
1582fn metadata_description(
1583 description: Option<String>,
1584) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
1585 let Some(description) = description else {
1586 return Ok((None, Default::default()));
1587 };
1588 let Some(start) = description.rfind(METADATA_OPEN) else {
1589 return Ok((Some(description), Default::default()));
1590 };
1591 let encoded_start = start + METADATA_OPEN.len();
1592 let Some(relative_end) = description[encoded_start..].find(METADATA_CLOSE) else {
1593 return Err(SourceError::Malformed {
1594 message: "unterminated onetaskgraph metadata slot in Linear description".into(),
1595 });
1596 };
1597 let encoded_end = encoded_start + relative_end;
1598 if !description[encoded_end + METADATA_CLOSE.len()..]
1599 .trim()
1600 .is_empty()
1601 {
1602 return Ok((Some(description), Default::default()));
1603 }
1604 let metadata =
1605 serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
1606 SourceError::Malformed {
1607 message: format!(
1608 "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
1609 ),
1610 }
1611 })?;
1612 let visible = description[..start].trim_end();
1613 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
1614}
1615
1616fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
1617 Ok(optional_str(v, k)?.map(Into::into))
1618}
1619fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
1620 let id = str_at(value, field)?;
1621 (!id.is_empty())
1622 .then_some(id)
1623 .ok_or_else(|| SourceError::Malformed {
1624 message: format!("field {field} is an empty backend id"),
1625 })
1626}
1627fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
1628 let root = root.as_str();
1629 let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
1630 message: format!("missing {root}"),
1631 })?;
1632 match payload.get("success").and_then(Value::as_bool) {
1633 Some(true) => Ok(payload),
1634 Some(false) => Err(SourceError::Refused {
1635 message: format!("Linear reported {root} was unsuccessful"),
1636 }),
1637 None => Err(SourceError::Malformed {
1638 message: format!("missing boolean {root}.success"),
1639 }),
1640 }
1641}
1642fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
1643 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
1644 message: "missing pageInfo".into(),
1645 })?;
1646 let more = info
1647 .get("hasNextPage")
1648 .and_then(Value::as_bool)
1649 .ok_or_else(|| SourceError::Malformed {
1650 message: "missing boolean pageInfo.hasNextPage".into(),
1651 })?;
1652 if !more {
1653 return Ok(None);
1654 }
1655 let cursor = str_at(info, "endCursor")?;
1656 Ok(Some(Cursor(cursor.into())))
1657}