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