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