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