Skip to main content

onetaskgraph_linear/
lib.rs

1//! A read/write source over Linear's published GraphQL API.
2//!
3//! Linear `Issue` maps to [`Task`], `Project` to [`Project`], `IssueLabel` and
4//! `ProjectLabel` to [`Label`], and `WorkflowState.name` is preserved while its
5//! `type` (`backlog`, `unstarted`, `started`, `completed`, or `canceled`) maps to
6//! the normalized status category. Issue `relations`/`inverseRelations` and
7//! project relations provide native dependency traversal in both directions.
8//!
9//! Label, workflow-state, project, and orphan filters are sent in the
10//! `issues(filter:)`/`projects(filter:)` variables. Linear's separate search
11//! connection cannot restrict matching to title versus description, so all
12//! three text predicates are deliberately unsupported and ignored here for
13//! sound engine compensation. Pagination uses Relay `first` and `after`.
14//!
15//! Caller metadata is canonical JSON in a trailing
16//! `<!-- onetaskgraph.metadata ... -->` Markdown comment in the item's description. The
17//! visible description is returned unchanged without that slot. Writes put the same
18//! canonical encoding back beside the visible description, and use Linear issue/project
19//! relations for same-source dependencies. Only cross-source far ends use the reserved
20//! `onetaskgraph.depends_on` metadata key.
21//!
22//! Fixture provenance is recorded in `tests/fixtures/README.md`. The ignored live lane
23//! exercises reads by default; its mutation journey requires an explicitly named scratch
24//! team and immediately requests deletion of its scratch issue. A failed live cleanup is
25//! reported as a test failure and may require manual deletion from that scratch team.
26#![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
40/// The plugin kind a `linear` source's `plugin:` field names.
41pub const KIND: &str = "linear";
42const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
43
44/// Exact GraphQL query documents issued by this plugin.
45///
46/// Fixture servers consume these constants so their recognized contract cannot drift
47/// from the production requests.
48pub mod graphql {
49    /// Check the authenticated viewer.
50    pub const VIEWER: &str = "query { viewer { id } }";
51    /// Fetch one issue.
52    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    /// Fetch one project.
54    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    /// List issues.
56    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    /// List projects.
58    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    /// List issue labels.
60    pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
61    /// Fetch issue dependency relations.
62    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    /// Fetch project dependency relations.
64    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    /// Resolve the configured team key to Linear's backend id.
66    pub const TEAM: &str =
67        "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
68    /// Resolve an issue workflow-state display name.
69    pub const ISSUE_STATE: &str = "query($name:String!,$team:String!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
70    /// Resolve a project status display name.
71    pub const PROJECT_STATUS: &str =
72        "query($name:String!){ projectStatuses(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
73    /// Resolve an issue-label display name.
74    pub const ISSUE_LABEL: &str =
75        "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
76    /// Resolve a project-label display name.
77    pub const PROJECT_LABEL: &str =
78        "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
79    /// Create an issue.
80    pub const ISSUE_CREATE: &str =
81        "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
82    /// Update an issue.
83    pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
84    /// Create a project.
85    pub const PROJECT_CREATE: &str =
86        "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
87    /// Update a project.
88    pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
89    /// Create a native issue dependency.
90    pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
91    /// Create a native project dependency.
92    pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
93    /// Delete a native issue dependency before replacing its full edge set.
94    pub const ISSUE_RELATION_DELETE: &str =
95        "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
96    /// Delete a native project dependency before replacing its full edge set.
97    pub const PROJECT_RELATION_DELETE: &str =
98        "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
99    /// Delete an issue created only by the ignored live write journey.
100    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/// Configuration contains only the credential variable's name, never its value.
108#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
109#[serde(default, deny_unknown_fields)]
110pub struct LinearConfig {
111    /// Environment variable resolved by the host.
112    #[schemars(with = "String")]
113    api_key_env: EnvName,
114    /// Linear team key/id used to narrow reads and required for item writes.
115    #[schemars(with = "Option<String>")]
116    team: Option<Team>,
117    /// GraphQL endpoint override, primarily for fixture servers.
118    #[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/// The Linear plugin factory.
179#[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    /// This source's configured name, kept for one comparison: a far end recorded as
221    /// `<this name>:<native>` is a Linear item Linear itself relates, so the reserved key
222    /// is refused for it exactly as a bare id of the same kind is.
223    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    // llmlint: ignore[invalid_states_unrepresentable] One transport envelope carries eight distinct GraphQL data shapes; each operation immediately validates its own complete mapper into typed plugin-api values, so malformed external data cannot cross the plugin boundary and a union here would duplicate every query response solely inside transport code.
303    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    // llmlint: ignore[invalid_states_unrepresentable] This private generic transport accepts only variables constructed immediately at typed TaskSource call sites, never untrusted input; per-operation response mappers validate every external field before returning public values.
328    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    // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] These operators follow the accepted 2026-08-24 Linear contract, but Linear exposes their authoritative definitions only through an authenticated unversioned explorer; the real-HTTP tests assert every serialized operator and the shared CLI journeys assert resulting rows without making credentials required.
387    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    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
421
422    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        // llmlint: ignore[changed_behavior_has_e2e] The shared CLI journey `every_source_filters_its_projects_by_label_by_status_and_by_text` asserts that Linear status filtering returns only P-2 and reports native pushdown; this lower-level HTTP test separately asserts the serialized `started` predicate.
741        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
872/// Linear relates one Linear item to another and nothing else, so an edge whose far end
873/// is in a different source is the one edge no `relations` entry can hold. Those edges
874/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
875/// are served *after* the native relations are spent: a page under this cursor is the
876/// recorded tail of the same walk, which keeps the native pages exactly what they were.
877const 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            // This cursor resumes the *forward* tail and only a forward walk ever issues
892            // one, so a reverse read carrying it is resuming a walk it did not come from.
893            // Serving it would answer a reverse read with forward edges, which is the one
894            // thing a recorded edge must never do — its reverse is derived from the far
895            // end and is never written down here.
896            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        // Only forwards: the reverse of a recorded edge is derived from the far end, never
920        // written down on the near item.
921        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    // `relations` on an issue holds issues and on a project holds projects, both of this
942    // workspace — so a same-kind far end in this same source is one Linear itself was
943    // supposed to hold, and the key is refused rather than quietly read, whether the entry
944    // left the source out or spelled this one.
945    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
965// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] Linear's workflow-state strings follow the accepted 2026-08-24 contract; its authoritative enum is exposed only through an authenticated unversioned explorer, while real-HTTP tests cover every serialized and parsed value.
966fn linear_statuses(s: &StatusCategory) -> Vec<&'static str> {
967    match s {
968        StatusCategory::Backlog => vec!["backlog"],
969        StatusCategory::Todo => vec!["unstarted"],
970        StatusCategory::InProgress => vec!["started"],
971        StatusCategory::Done => vec!["completed"],
972        StatusCategory::Cancelled => vec!["canceled"],
973        StatusCategory::Unknown => vec![],
974    }
975}
976fn status(v: &Value) -> Result<Status, SourceError> {
977    let name = str_at(v, "name")?.into();
978    let category = match str_at(v, "type")? {
979        "backlog" => StatusCategory::Backlog,
980        "unstarted" => StatusCategory::Todo,
981        "started" => StatusCategory::InProgress,
982        "completed" => StatusCategory::Done,
983        "canceled" => StatusCategory::Cancelled,
984        _ => StatusCategory::Unknown,
985    };
986    Ok(Status { category, name })
987}
988// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
989fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
990    v.get(k)
991        .and_then(Value::as_str)
992        .ok_or_else(|| SourceError::Malformed {
993            message: format!("missing string field {k}"),
994        })
995}
996fn map_label(v: &Value) -> Result<Label, SourceError> {
997    Ok(Label {
998        id: NativeId(str_at(v, "id")?.into()),
999        name: str_at(v, "name")?.into(),
1000        color: optional_string(v, "color")?,
1001    })
1002}
1003fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
1004    v.get("nodes")
1005        .and_then(Value::as_array)
1006        .ok_or_else(|| SourceError::Malformed {
1007            message: "missing label nodes".into(),
1008        })?
1009        .iter()
1010        .map(map_label)
1011        .collect()
1012}
1013fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1014    optional_str(v, k)?
1015        .map(|s| {
1016            s.parse().map_err(|e| SourceError::Malformed {
1017                message: format!("invalid {k}: {e}"),
1018            })
1019        })
1020        .transpose()
1021}
1022fn map_task(v: &Value) -> Result<Task, SourceError> {
1023    let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1024    let repositories = Repository::from_metadata(&metadata)
1025        .map_err(|message| SourceError::Malformed { message })?;
1026    Ok(Task {
1027        id: NativeId(str_at(v, "id")?.into()),
1028        title: str_at(v, "title")?.into(),
1029        content,
1030        status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
1031            message: "missing state".into(),
1032        })?)?,
1033        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1034            message: "missing labels".into(),
1035        })?)?,
1036        project: match v.get("project") {
1037            None => {
1038                return Err(SourceError::Malformed {
1039                    message: "missing project field".into(),
1040                });
1041            }
1042            Some(Value::Null) => None,
1043            Some(p) => Some(NativeId(str_at(p, "id")?.into())),
1044        },
1045        url: optional_string(v, "url")?,
1046        created_at: time(v, "createdAt")?,
1047        updated_at: time(v, "updatedAt")?,
1048        metadata,
1049        repositories,
1050    })
1051}
1052fn map_project(v: &Value) -> Result<Project, SourceError> {
1053    let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1054    let repositories = Repository::from_metadata(&metadata)
1055        .map_err(|message| SourceError::Malformed { message })?;
1056    Ok(Project {
1057        id: NativeId(str_at(v, "id")?.into()),
1058        title: str_at(v, "name")?.into(),
1059        content,
1060        status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
1061            message: "missing status".into(),
1062        })?)?,
1063        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1064            message: "missing project labels".into(),
1065        })?)?,
1066        url: optional_string(v, "url")?,
1067        created_at: time(v, "createdAt")?,
1068        updated_at: time(v, "updatedAt")?,
1069        metadata,
1070        repositories,
1071    })
1072}
1073fn optional<T>(
1074    d: &Value,
1075    k: &str,
1076    f: fn(&Value) -> Result<T, SourceError>,
1077) -> Result<Option<T>, SourceError> {
1078    match d.get(k) {
1079        None => Err(SourceError::Malformed {
1080            message: format!("missing {k}"),
1081        }),
1082        Some(Value::Null) => Ok(None),
1083        Some(value) => f(value).map(Some),
1084    }
1085}
1086fn connection<T>(
1087    d: &Value,
1088    k: &str,
1089    f: fn(&Value) -> Result<T, SourceError>,
1090) -> Result<Page<T>, SourceError> {
1091    let c = d.get(k).ok_or_else(|| SourceError::Malformed {
1092        message: format!("missing {k} connection"),
1093    })?;
1094    let items = c
1095        .get("nodes")
1096        .and_then(Value::as_array)
1097        .ok_or_else(|| SourceError::Malformed {
1098            message: "missing nodes".into(),
1099        })?
1100        .iter()
1101        .map(f)
1102        .collect::<Result<_, _>>()?;
1103    let next = page_next(c)?;
1104    Ok(Page { items, next })
1105}
1106#[derive(Clone, Copy)]
1107enum DependencyRoot {
1108    Issue,
1109    Project,
1110}
1111impl DependencyRoot {
1112    const fn item_kind(self) -> ItemKind {
1113        match self {
1114            Self::Issue => ItemKind::Task,
1115            Self::Project => ItemKind::Project,
1116        }
1117    }
1118    const fn as_str(self) -> &'static str {
1119        match self {
1120            Self::Issue => "issue",
1121            Self::Project => "project",
1122        }
1123    }
1124}
1125fn relation_page(
1126    d: &Value,
1127    root: DependencyRoot,
1128    id: &NativeId,
1129    direction: Direction,
1130) -> Result<Page<DependencyEdge>, SourceError> {
1131    let key = if direction == Direction::DependsOn {
1132        "relations"
1133    } else {
1134        "inverseRelations"
1135    };
1136    let c = d
1137        .get(root.as_str())
1138        .and_then(|v| v.get(key))
1139        .ok_or_else(|| SourceError::Malformed {
1140            message: format!("missing {key}"),
1141        })?;
1142    let nodes = c
1143        .get("nodes")
1144        .and_then(Value::as_array)
1145        .ok_or_else(|| SourceError::Malformed {
1146            message: "missing relation nodes".into(),
1147        })?;
1148    let mut items = Vec::new();
1149    for n in nodes {
1150        let other = n
1151            .get(if direction == Direction::DependsOn {
1152                "relatedIssue"
1153            } else {
1154                "issue"
1155            })
1156            .or_else(|| {
1157                n.get(if direction == Direction::DependsOn {
1158                    "relatedProject"
1159                } else {
1160                    "project"
1161                })
1162            })
1163            .and_then(|v| v.get("id"))
1164            .and_then(Value::as_str)
1165            .ok_or_else(|| SourceError::Malformed {
1166                message: "missing related id".into(),
1167            })?;
1168        let (from, to) = if direction == Direction::DependsOn {
1169            (id.clone(), NativeId(other.into()))
1170        } else {
1171            (NativeId(other.into()), id.clone())
1172        };
1173        // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] Linear publishes relation type as a string in the accepted 2026-08-24 schema; this boundary enum deliberately rejects every undocumented value, and real-HTTP tests prove both accepted values and rejection.
1174        #[derive(Deserialize)]
1175        #[serde(rename_all = "camelCase")]
1176        enum RelationKind {
1177            Blocks,
1178            Related,
1179        }
1180        let kind = match serde_json::from_value::<RelationKind>(n.get("type").cloned().ok_or_else(
1181            || SourceError::Malformed {
1182                message: "missing relation type".into(),
1183            },
1184        )?)
1185        .map_err(|e| SourceError::Malformed {
1186            message: format!("invalid relation type: {e}"),
1187        })? {
1188            RelationKind::Blocks => DependencyKind::Blocks,
1189            RelationKind::Related => DependencyKind::Related,
1190        };
1191        // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1192        let item_kind = root.item_kind();
1193        items.push(DependencyEdge {
1194            from: DependencyEndpoint::from_native(from, item_kind),
1195            to: DependencyEndpoint::from_native(to, item_kind),
1196            kind,
1197        });
1198    }
1199    let next = page_next(c)?;
1200    Ok(Page { items, next })
1201}
1202
1203fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
1204    match v.get(k) {
1205        None => Err(SourceError::Malformed {
1206            message: format!("missing field {k}"),
1207        }),
1208        Some(Value::Null) => Ok(None),
1209        Some(value) => value
1210            .as_str()
1211            .map(Some)
1212            .ok_or_else(|| SourceError::Malformed {
1213                message: format!("field {k} is not a string"),
1214            }),
1215    }
1216}
1217
1218/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment
1219/// at the end of `description`; its later write side must use this exact encoding.
1220const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
1221const METADATA_CLOSE: &str = "\n-->";
1222
1223fn metadata_description(
1224    description: Option<String>,
1225) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
1226    let Some(description) = description else {
1227        return Ok((None, Default::default()));
1228    };
1229    let Some(start) = description.rfind(METADATA_OPEN) else {
1230        return Ok((Some(description), Default::default()));
1231    };
1232    let encoded_start = start + METADATA_OPEN.len();
1233    let Some(relative_end) = description[encoded_start..].find(METADATA_CLOSE) else {
1234        return Err(SourceError::Malformed {
1235            message: "unterminated onetaskgraph metadata slot in Linear description".into(),
1236        });
1237    };
1238    let encoded_end = encoded_start + relative_end;
1239    if !description[encoded_end + METADATA_CLOSE.len()..]
1240        .trim()
1241        .is_empty()
1242    {
1243        return Ok((Some(description), Default::default()));
1244    }
1245    let metadata =
1246        serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
1247            SourceError::Malformed {
1248                message: format!(
1249                    "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
1250                ),
1251            }
1252        })?;
1253    let visible = description[..start].trim_end();
1254    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
1255}
1256
1257fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
1258    Ok(optional_str(v, k)?.map(Into::into))
1259}
1260fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
1261    let id = str_at(value, field)?;
1262    (!id.is_empty())
1263        .then_some(id)
1264        .ok_or_else(|| SourceError::Malformed {
1265            message: format!("field {field} is an empty backend id"),
1266        })
1267}
1268fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
1269    let root = root.as_str();
1270    let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
1271        message: format!("missing {root}"),
1272    })?;
1273    match payload.get("success").and_then(Value::as_bool) {
1274        Some(true) => Ok(payload),
1275        Some(false) => Err(SourceError::Refused {
1276            message: format!("Linear reported {root} was unsuccessful"),
1277        }),
1278        None => Err(SourceError::Malformed {
1279            message: format!("missing boolean {root}.success"),
1280        }),
1281    }
1282}
1283fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
1284    let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
1285        message: "missing pageInfo".into(),
1286    })?;
1287    let more = info
1288        .get("hasNextPage")
1289        .and_then(Value::as_bool)
1290        .ok_or_else(|| SourceError::Malformed {
1291            message: "missing boolean pageInfo.hasNextPage".into(),
1292        })?;
1293    if !more {
1294        return Ok(None);
1295    }
1296    let cursor = str_at(info, "endCursor")?;
1297    Ok(Some(Cursor(cursor.into())))
1298}