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