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