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