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