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:{eqIgnoreCase:…}}}` for what an item must carry — one per label, gathered under `or:` where any one of them will do — and `labels:{every:{name:{neqIgnoreCase:…}}}` for what it must not. Linear's `StringComparator` has no case-insensitive list operator; see the note beside `filter`. |
32//! | `filter_by_status` | **Supported and proven,** and spelled twice. An issue narrows with `state:{type:{in:[…]}}` over `WorkflowState.type`; a project narrows with `status:{type:{in:[…]}}` over `ProjectStatusType`, a different member of a different filter over a different vocabulary. See the ruling below. |
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. Linear types every one of them `dependency`; see the ruling below on the edge that has no spelling here. |
37//! | `max_page_size` | **Supported and proven.** 100; every read pages with Relay `first`/`after`. Linear's connection maximum is 250 and its complexity budget is the tighter bound — see [`MAX_PAGE_SIZE`]. |
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//! ## Ruling: a project's filter is not an issue's, and neither is its status
74//!
75//! Linear's `IssueFilter` and `ProjectFilter` read as one filter over two kinds of row.
76//! They are two input types, and this source built one object for both until 2026-09-04,
77//! which put two members into `projects(filter:)` that Linear does not have there. It
78//! refused the first outright — `Field "team" is not defined by type "ProjectFilter". Did
79//! you mean "lead"?` — and would have refused the second next.
80//!
81//! A project has no team; it has the teams it is accessible from, so the configured team
82//! reaches `accessibleTeams:{some:{key:{eqIgnoreCase:…}}}`. And a project's status is not
83//! an issue's state: the counterpart of `IssueFilter.state` is `ProjectFilter.status`,
84//! while `ProjectFilter.state` exists and is a bare `StringComparator` over something else.
85//! The two do not even share a vocabulary — `ProjectStatus.type` is the `ProjectStatusType`
86//! enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`, where a
87//! workflow state is `backlog`, `unstarted`, `started`, `completed`, `canceled`, `triage`.
88//! So `planned` is where `unstarted` would be, `paused` reads as in progress and has no
89//! issue counterpart, and a filter spelled in the other level's words matches nothing while
90//! being refused by nothing.
91//!
92//! **Neither of those could be caught by reading a document, and that is the general
93//! lesson.** A filter is built at runtime and handed over as `$filter`, so it appears in no
94//! operation this crate declares, and the two pinned-schema checks that parse those
95//! operations could not see it — Linear was the only reader, one refusal per round trip.
96//! `every_variables_object_this_source_sends_conforms_to_the_pinned_schema` closes that:
97//! it drives this source's whole surface, records what really went out, and walks every
98//! variables object against the pinned type of the argument it stands at.
99//!
100//! ## Ruling: a Linear project relation is always an ordering
101//!
102//! This one is Linear's too, and the validator says so in as many words. Asked on
103//! 2026-09-04 for a project relation typed `related` — and separately `blocks` and
104//! `dependsOn` — the real API refused each with `Argument Validation Error` and
105//! `constraints: {"isEnum": "type must be one of the following values: dependency"}`. That
106//! enumeration has one member and it is a timeline dependency, which is why the input
107//! carries an anchor at each end at all.
108//!
109//! So a project edge carrying no ordering has nowhere here to land, and this source
110//! **refuses it by name** before the write rather than sending a value Linear will reject
111//! or quietly promoting it to a dependency it does not mean. `DependencyKind::Related`
112//! keeps its issue-level spelling, `related`, because `IssueRelationCreateInput` really
113//! does take it: the two relations are different relations with different vocabularies,
114//! and each level's read accepts only its own.
115//!
116//! Which end of a project relation waits is carried by the two anchors and not by the two
117//! id slots — measured, not reasoned, from Linear's own `ProjectFilter.hasBlockedByRelations`
118//! against relations written both ways round. `tests/fixtures/README.md` records the whole
119//! probe, and `write_relations` records why the pair this source sends is the oriented one.
120//!
121//! Caller metadata is canonical JSON in a trailing
122//! `<!-- onetaskgraph.metadata ... -->` Markdown comment in the item's description. The
123//! visible description is returned unchanged without that slot. Writes put the same
124//! canonical encoding back beside the visible description, and use Linear issue/project
125//! relations for same-source dependencies. Only cross-source far ends use the reserved
126//! `onetaskgraph.depends_on` metadata key.
127//!
128//! Fixture provenance is recorded in `tests/fixtures/README.md`. The live journey in
129//! `tests/live.rs` drives every field of the table above against Linear itself: it builds its own fixture
130//! on the scratch team `LINEAR_WRITE_TEAM` names — two projects, one issue filed under
131//! each, one filed under neither, two labels and two workflow states — because that shape
132//! is what tells an honoured predicate from an ignored one, and a workspace where every
133//! issue carries the label answers a filter the same way either way. The two searches are
134//! asserted as what they are declared: the wider set, unnarrowed. Everything the lane
135//! creates it deletes whether its assertions passed or failed, and it clears residue named
136//! the way it names its own before it starts. A failed live cleanup is reported as a test
137//! failure and may require manual deletion from that scratch team.
138#![deny(missing_docs)]
139
140use chrono::{DateTime, Utc};
141use onetaskgraph_plugin_api::{
142    Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
143    Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label, LabelFilter, Location,
144    NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver,
145    SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
146    TaskSource, WriteSupport,
147};
148use schemars::{Schema, schema_for};
149use secrecy::{ExposeSecret, SecretString};
150use serde::Deserialize;
151use serde_json::{Value, json};
152
153/// The plugin kind a `linear` source's `plugin:` field names.
154pub const KIND: &str = "linear";
155
156/// The largest page this source will ask Linear for, and the capability it declares.
157///
158/// **Not Linear's connection maximum, which is 250, because a connection maximum is not
159/// the only thing bounding a page.** Linear also scores each document for complexity and
160/// refuses one over 10000 with HTTP 400 and `The query is too complex.` — and the
161/// `projects` document this source sends scores 17475 at `first: 250`, because its nested
162/// `labels` connection, which names no `first` of its own, is charged Linear's default of
163/// 50 per node. Measured against the real API on 2026-09-04: the largest `first` that
164/// document is accepted at is **143**, exactly, and the filter it carries adds nothing.
165/// The `issues` document is accepted at 250, so this is the tighter of the two and a
166/// single declared maximum has to be the tighter one.
167///
168/// 100 rather than 143 because 143 is the cliff. A field added to either selection moves
169/// it, and a page size chosen at the edge of a budget nobody here controls fails in the
170/// live lane rather than in a check. This leaves 30% of the budget spare.
171///
172/// Nothing offline can hold this: complexity is scored by Linear's own runtime and appears
173/// in no schema, so `every_variables_object_this_source_sends_conforms_to_the_pinned_schema`
174/// cannot see it. What guards it is the live journey, which walks a real `projects` page at
175/// exactly this size.
176pub const MAX_PAGE_SIZE: u32 = 100;
177const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
178
179/// Exact GraphQL query documents issued by this plugin.
180///
181/// Fixture servers consume these constants so their recognized contract cannot drift
182/// from the production requests.
183pub mod graphql {
184    /// Check the authenticated viewer.
185    pub const VIEWER: &str = "query { viewer { id } }";
186    /// Fetch one issue.
187    pub const ISSUE: &str = "query($id:String!){ issue(id:$id){ id title description url createdAt updatedAt archivedAt state{name type} labels{nodes{id name color}} project{id} } }";
188    /// Fetch one project.
189    pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name description url createdAt updatedAt archivedAt status{name type} labels{nodes{id name color}} } }";
190    /// List issues.
191    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} } }";
192    /// List projects.
193    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} } }";
194    /// List issue labels.
195    pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
196    /// Fetch issue dependency relations.
197    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}} } }";
198    /// Fetch project dependency relations.
199    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}} } }";
200    /// Resolve the configured team key to Linear's backend id.
201    pub const TEAM: &str =
202        "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
203    /// Resolve an issue workflow-state display name.
204    ///
205    /// `$team` is an `ID!` and `$name` a `String!` because that is what each one's
206    /// *location* declares, not because of what this source passes: both carry a Linear
207    /// identifier string. `WorkflowStateFilter.team` is a `NullableTeamFilter`, whose `id`
208    /// is an `IDComparator`, whose `eq` is an `ID`; the sibling `name` reaches a
209    /// `StringComparator.eqIgnoreCase`, which is a `String`.
210    ///
211    /// That distinction is what the live lane was refused for on 2026-09-04, with HTTP 400
212    /// and `Variable "$team" of type "String!" used in position expecting type "ID".`
213    /// GraphQL admits a variable at a location only when the variable's type is the
214    /// location's type or that type's non-null form, and `String` is not `ID` however the
215    /// value is spelled — so `String!` there fails validation before any field is read,
216    /// while `ID!` is the non-null form of the location's own type and is accepted.
217    ///
218    /// It reached Linear because a variable inside an inline filter literal is not a root
219    /// argument, and the pinned-schema checks only compared root arguments. They now walk
220    /// into these literals too, so this class of drift fails here rather than in the live
221    /// lane.
222    pub const ISSUE_STATE: &str = "query($name:String!,$team:ID!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
223    /// List the workspace's project statuses, so one can be resolved by display name.
224    ///
225    /// Unlike `teams`, `workflowStates` and the two label connections, Linear's
226    /// `projectStatuses` accepts no `filter` argument: asking for one is refused outright
227    /// with `Unknown argument "filter" on field "Query.projectStatuses"`. The display name
228    /// is therefore matched locally over the whole connection, which a workspace holds few
229    /// enough of to answer in one page.
230    // llmlint: ignore[changed_behavior_has_e2e] The uncovered case the rule names — a status
231    // on a later page — is not a test that is missing but a document this repository has no
232    // evidence Linear would accept: `tests/fixtures/schema.graphql` pins `after` alone,
233    // because Linear's own refusal is where that correction came from, and its
234    // `ProjectStatusConnection` declares `nodes` and no `pageInfo`. Selecting a cursor field
235    // to page on would fail `pinned_schema_checks_selected_fields_arguments_and_fixture_keys`
236    // here and risk, against Linear, the same `GRAPHQL_VALIDATION_FAILED` this document was
237    // changed to stop sending. Reading one page is not what changed either: `teams`,
238    // `workflowStates` and `projectLabels` resolve a display name through the same `one_id`
239    // over the same unpaged connections, and did before this change. What did change is
240    // driven end to end — the CLI journey
241    // `linear_project_and_task_copies_write_native_relations_and_record_only_cross_source_edges`
242    // copies a project whose status is resolved this way, and
243    // `a_project_status_is_matched_locally_because_linear_narrows_that_connection_for_nobody`
244    // holds the match, the ambiguity and the absence against a real HTTP server.
245    pub const PROJECT_STATUS: &str = "query{ projectStatuses{nodes{id name}} }";
246    /// Resolve an issue-label display name.
247    pub const ISSUE_LABEL: &str =
248        "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
249    /// Resolve a project-label display name.
250    pub const PROJECT_LABEL: &str =
251        "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
252    /// Create an issue.
253    pub const ISSUE_CREATE: &str =
254        "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
255    /// Update an issue.
256    pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
257    /// Create a project.
258    pub const PROJECT_CREATE: &str =
259        "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
260    /// Update a project.
261    pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
262    /// Create a native issue dependency.
263    pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
264    /// Create a native project dependency.
265    pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
266    /// Delete a native issue dependency before replacing its full edge set.
267    pub const ISSUE_RELATION_DELETE: &str =
268        "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
269    /// Delete a native project dependency before replacing its full edge set.
270    pub const PROJECT_RELATION_DELETE: &str =
271        "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
272    /// Delete an issue, so a copy that could not finish can take back what it created.
273    pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
274    /// Delete a project, for the same reason and on the same terms.
275    pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
276    /// Fetch one document.
277    pub const DOCUMENT: &str = "query($id:String!){ document(id:$id){ id title content url createdAt updatedAt archivedAt project{id} } }";
278    /// List documents.
279    ///
280    /// `first` is an `Int` rather than an `Int!` because that is what Linear's `documents`
281    /// connection declares, unlike its `issues` one.
282    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} } }";
283    /// Create a document.
284    pub const DOCUMENT_CREATE: &str = "mutation($input:DocumentCreateInput!){ documentCreate(input:$input){success document{id}} }";
285    /// Update a document.
286    pub const DOCUMENT_UPDATE: &str = "mutation($id:String!,$input:DocumentUpdateInput!){ documentUpdate(id:$id,input:$input){success document{id}} }";
287    /// Delete a document, so a copy that could not finish can take back what it created.
288    pub const DOCUMENT_DELETE: &str = "mutation($id:String!){ documentDelete(id:$id){success} }";
289}
290
291use graphql::{
292    DOCUMENT, DOCUMENTS, ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS,
293    PROJECTS, VIEWER,
294};
295
296/// Configuration contains only the credential variable's name, never its value.
297#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
298#[serde(default, deny_unknown_fields)]
299pub struct LinearConfig {
300    /// Environment variable resolved by the host.
301    #[schemars(with = "String")]
302    api_key_env: EnvName,
303    /// Linear team key/id used to narrow reads and required for item writes.
304    #[schemars(with = "Option<String>")]
305    team: Option<Team>,
306    /// GraphQL endpoint override, primarily for fixture servers.
307    #[schemars(with = "String")]
308    endpoint: Endpoint,
309}
310
311#[derive(Debug, Clone, Deserialize)]
312#[serde(try_from = "String")]
313struct EnvName(String);
314impl TryFrom<String> for EnvName {
315    type Error = String;
316    fn try_from(value: String) -> Result<Self, Self::Error> {
317        let mut bytes = value.bytes();
318        if bytes
319            .next()
320            .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
321            && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
322        {
323            Ok(Self(value))
324        } else {
325            Err("must be an uppercase environment-variable name".into())
326        }
327    }
328}
329#[derive(Debug, Clone, Deserialize)]
330#[serde(try_from = "String")]
331struct Team(String);
332impl TryFrom<String> for Team {
333    type Error = String;
334    fn try_from(value: String) -> Result<Self, Self::Error> {
335        if value.trim().is_empty() {
336            Err("must not be empty".into())
337        } else {
338            Ok(Self(value))
339        }
340    }
341}
342#[derive(Debug, Clone, Deserialize)]
343#[serde(try_from = "String")]
344struct Endpoint(String);
345impl TryFrom<String> for Endpoint {
346    type Error = String;
347    fn try_from(value: String) -> Result<Self, Self::Error> {
348        let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
349        if matches!(url.scheme(), "http" | "https") {
350            Ok(Self(value))
351        } else {
352            Err("must use http or https".into())
353        }
354    }
355}
356
357impl Default for LinearConfig {
358    fn default() -> Self {
359        Self {
360            api_key_env: EnvName("LINEAR_API_KEY".into()),
361            team: None,
362            endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
363        }
364    }
365}
366
367/// The Linear plugin factory.
368#[derive(Debug, Clone, Copy, Default)]
369pub struct Plugin;
370
371impl SourcePlugin for Plugin {
372    fn kind(&self) -> &'static str {
373        KIND
374    }
375    fn config_schema(&self) -> Schema {
376        schema_for!(LinearConfig)
377    }
378    fn build(
379        &self,
380        name: &SourceName,
381        config: &Value,
382        secrets: &dyn SecretResolver,
383    ) -> Result<Box<dyn TaskSource>, SourceError> {
384        let config: LinearConfig =
385            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
386                message: format!("source {name}: {e}"),
387            })?;
388        let key = secrets
389            .get(&config.api_key_env.0)
390            .filter(|v| !v.expose_secret().trim().is_empty())
391            .ok_or_else(|| SourceError::Auth {
392                message: format!("set environment variable {}", config.api_key_env.0),
393            })?;
394        Ok(Box::new(LinearSource {
395            client: reqwest::Client::new(),
396            endpoint: config.endpoint,
397            key,
398            team: config.team,
399            name: name.clone(),
400        }))
401    }
402}
403
404struct LinearSource {
405    client: reqwest::Client,
406    endpoint: Endpoint,
407    key: SecretString,
408    team: Option<Team>,
409    /// This source's configured name, kept for one comparison: a far end recorded as
410    /// `<this name>:<native>` is a Linear item Linear itself relates, so the reserved key
411    /// is refused for it exactly as a bare id of the same kind is.
412    name: SourceName,
413}
414#[derive(Clone, Copy)]
415enum WriteKind {
416    Task,
417    Project,
418}
419enum Lookup<'a> {
420    Team(&'a str),
421    IssueState { name: &'a str, team: &'a NativeId },
422    ProjectStatus(&'a str),
423    IssueLabel(&'a str),
424    ProjectLabel(&'a str),
425}
426impl Lookup<'_> {
427    fn query(&self) -> &'static str {
428        match self {
429            Self::Team(_) => graphql::TEAM,
430            Self::IssueState { .. } => graphql::ISSUE_STATE,
431            Self::ProjectStatus(_) => graphql::PROJECT_STATUS,
432            Self::IssueLabel(_) => graphql::ISSUE_LABEL,
433            Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
434        }
435    }
436    fn connection(&self) -> &'static str {
437        match self {
438            Self::Team(_) => "teams",
439            Self::IssueState { .. } => "workflowStates",
440            Self::ProjectStatus(_) => "projectStatuses",
441            Self::IssueLabel(_) => "issueLabels",
442            Self::ProjectLabel(_) => "projectLabels",
443        }
444    }
445    fn diagnostic(&self) -> String {
446        match self {
447            Self::Team(_) => "configured team".into(),
448            Self::IssueState { name, .. } => format!("workflow state {name:?}"),
449            Self::ProjectStatus(name) => format!("project status {name:?}"),
450            Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
451        }
452    }
453    fn variables(&self) -> Value {
454        match self {
455            Self::Team(key) => json!({"key":key}),
456            Self::IssueState { name, team } => json!({"name":name,"team":team.0}),
457            Self::IssueLabel(name) | Self::ProjectLabel(name) => json!({"name":name}),
458            // `PROJECT_STATUS` names nothing, for the reason recorded on that document.
459            Self::ProjectStatus(_) => json!({}),
460        }
461    }
462    /// The display name `one_id` matches locally, for the one lookup whose connection
463    /// Linear will not narrow server-side.
464    fn local_name(&self) -> Option<&str> {
465        match self {
466            Self::ProjectStatus(name) => Some(name),
467            _ => None,
468        }
469    }
470}
471#[derive(Clone, Copy)]
472enum MutationRoot {
473    IssueCreate,
474    IssueUpdate,
475    ProjectCreate,
476    ProjectUpdate,
477    IssueRelationCreate,
478    ProjectRelationCreate,
479    IssueRelationDelete,
480    ProjectRelationDelete,
481    IssueDelete,
482    ProjectDelete,
483    DocumentCreate,
484    DocumentUpdate,
485    DocumentDelete,
486}
487impl MutationRoot {
488    fn as_str(self) -> &'static str {
489        match self {
490            Self::IssueCreate => "issueCreate",
491            Self::IssueUpdate => "issueUpdate",
492            Self::ProjectCreate => "projectCreate",
493            Self::ProjectUpdate => "projectUpdate",
494            Self::IssueRelationCreate => "issueRelationCreate",
495            Self::ProjectRelationCreate => "projectRelationCreate",
496            Self::IssueRelationDelete => "issueRelationDelete",
497            Self::ProjectRelationDelete => "projectRelationDelete",
498            Self::IssueDelete => "issueDelete",
499            Self::ProjectDelete => "projectDelete",
500            Self::DocumentCreate => "documentCreate",
501            Self::DocumentUpdate => "documentUpdate",
502            Self::DocumentDelete => "documentDelete",
503        }
504    }
505}
506
507#[derive(Deserialize)]
508struct Envelope {
509    // 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.
510    data: Option<Value>,
511    #[serde(default)]
512    errors: Vec<GqlError>,
513}
514#[derive(Deserialize)]
515struct GqlError {
516    message: String,
517    // Held raw rather than typed, for two reasons. Linear puts the whole of *why* it
518    // refused in here — `message` is a category name like `Argument Validation Error`,
519    // which named neither the field nor the value when the live project-relation write
520    // was refused by it — so a refusal carries this verbatim and a reader diagnoses from
521    // it. And a typed shape with a required `code` fails the whole envelope's
522    // deserialization when Linear sends extensions without one, turning a refusal this
523    // source could explain into an unexplained malformed response.
524    extensions: Option<Value>,
525}
526#[derive(Deserialize)]
527#[serde(rename_all = "camelCase")]
528struct GqlExtensions {
529    code: GqlErrorCode,
530    retry_after: Option<u64>,
531}
532impl GqlError {
533    /// The rate-limit shape of [`Self::extensions`], when it has one.
534    fn coded(&self) -> Option<GqlExtensions> {
535        self.extensions
536            .as_ref()
537            .and_then(|value| serde_json::from_value(value.clone()).ok())
538    }
539    /// Everything Linear said about this refusal, on one line and cut to [`SAID_LIMIT`].
540    ///
541    /// Linear's own sentence comes first, then the raw envelope, because only the first
542    /// of those two is short enough to survive [`SAID_LIMIT`] on its merits. `message` is
543    /// a category name — `Argument Validation Error` — and the sentence naming the field
544    /// and the values it would have taken is `extensions.userPresentableMessage`, one of
545    /// several keys in an envelope whose `validationErrors` echoes the whole rejected
546    /// input back. Observed against the real API on 2026-09-04, a `projectRelationCreate`
547    /// refusal rendered past the cut, and the echo is what got cut.
548    ///
549    /// That the sentence itself did not was luck: this build of `serde_json` renders an
550    /// object's keys sorted, and `userPresentableMessage` happens to sort ahead of
551    /// `validationErrors`. Nobody chose that — Linear sends the echo first — and any key
552    /// Linear adds sorting between the two would move the sentence behind an echo longer
553    /// than the whole limit, as would turning `preserve_order` on. Leading with it makes
554    /// what a reader diagnoses from independent of both.
555    fn said(&self) -> String {
556        let Some(extensions) = &self.extensions else {
557            return elided(&self.message);
558        };
559        match extensions
560            .get("userPresentableMessage")
561            .and_then(Value::as_str)
562            .filter(|sentence| !sentence.is_empty())
563        {
564            Some(sentence) => elided(&format!("{}: {sentence} {extensions}", self.message)),
565            None => elided(&format!("{}: {extensions}", self.message)),
566        }
567    }
568}
569#[derive(Deserialize)]
570enum GqlErrorCode {
571    #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
572    RateLimited,
573    #[serde(other)]
574    Other,
575}
576
577/// How much of a failed response's body a refusal carries.
578///
579/// Enough for Linear's own error envelope, which is one or two sentences naming the field
580/// or argument it would not accept, and short enough that a proxy's HTML error page does
581/// not become the whole message.
582const SAID_LIMIT: usize = 400;
583
584/// `said` made safe to put in a message: one line of printable text, cut to [`SAID_LIMIT`].
585///
586/// A failed response's body is whatever answered — Linear's error envelope, or an HTML
587/// page from a proxy in front of it — and this message is written to a terminal. So every
588/// control character goes, escape sequences with them, and each run of whitespace becomes
589/// one space: a body cannot move the cursor, repaint the line or hide the rest of the
590/// diagnostic behind itself. Cut by characters rather than bytes, because slicing UTF-8
591/// mid-codepoint would panic inside the path that exists to explain a failure.
592fn elided(said: &str) -> String {
593    let mut printable = String::new();
594    let mut spaced = true;
595    for character in said.chars() {
596        if character.is_control() || character.is_whitespace() {
597            if !spaced {
598                printable.push(' ');
599                spaced = true;
600            }
601            continue;
602        }
603        printable.push(character);
604        spaced = false;
605    }
606    let printable = printable.trim_end();
607    if printable.chars().count() <= SAID_LIMIT {
608        return printable.to_owned();
609    }
610    let kept: String = printable.chars().take(SAID_LIMIT).collect();
611    format!("{kept}…")
612}
613
614impl LinearSource {
615    // 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.
616    async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
617        let response = self
618            .client
619            .post(&self.endpoint.0)
620            .header("Authorization", self.key.expose_secret())
621            .json(&json!({"query": query, "variables": variables}))
622            .send()
623            .await
624            .map_err(|e| SourceError::Unavailable {
625                message: e.to_string(),
626            })?;
627        let status = response.status();
628        let retry = response
629            .headers()
630            .get("retry-after")
631            .and_then(|v| v.to_str().ok())
632            .and_then(|v| v.parse().ok());
633        if status.as_u16() == 429 {
634            return Err(SourceError::RateLimited {
635                retry_after_seconds: retry,
636                // Linear has one rate limiter and the status is the whole of what it said,
637                // so there is nothing to add beyond the kind — which is what an absent
638                // message means.
639                message: None,
640            });
641        }
642        if status.as_u16() == 401 || status.as_u16() == 403 {
643            return Err(SourceError::Auth {
644                message: "Linear rejected the configured credential".into(),
645            });
646        }
647        if !status.is_success() {
648            // Linear puts its GraphQL error envelope in the *body* of a 400, so the status
649            // alone names the whole call and nothing about what Linear objected to. The
650            // body is Linear's answer to this request and holds no credential; it is cut
651            // because a proxy in front of Linear can answer with a page.
652            let said = elided(&response.text().await.unwrap_or_default());
653            return Err(SourceError::Unavailable {
654                message: if said.is_empty() {
655                    format!("Linear returned HTTP {status}")
656                } else {
657                    format!("Linear returned HTTP {status}: {said}")
658                },
659            });
660        }
661        let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
662            message: e.to_string(),
663        })?;
664        if let Some(error) = body.errors.first() {
665            if let Some(extensions) = error
666                .coded()
667                .filter(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
668            {
669                return Err(SourceError::RateLimited {
670                    retry_after_seconds: extensions.retry_after.or(retry),
671                    message: None,
672                });
673            }
674            return Err(SourceError::Refused {
675                message: error.said(),
676            });
677        }
678        body.data.ok_or_else(|| SourceError::Malformed {
679            message: "GraphQL response has no data".into(),
680        })
681    }
682
683    // 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.
684    /// The label predicates, which really are spelled the same at both levels.
685    ///
686    /// `IssueFilter.labels` is an `IssueLabelCollectionFilter` and `ProjectFilter.labels`
687    /// is a `ProjectLabelCollectionFilter` — two types — but `some`, `every` and a `name`
688    /// of `StringComparator` are members of both, so one spelling satisfies each. That is
689    /// the whole of what the two filters have in common, and everything else about them is
690    /// built separately for the reason recorded on the two builders below.
691    ///
692    /// "At least one of these" is a disjunction of `eqIgnoreCase` rather than one
693    /// case-insensitive list operator, because Linear has no such operator. This source
694    /// sent `labels:{some:{name:{inIgnoreCase:[…]}}}` until Linear refused it outright,
695    /// HTTP 400, on the first read of the live lane that ever reached a label filter:
696    ///
697    /// ```text
698    /// Variable "$filter" got invalid value { inIgnoreCase: […] } at
699    /// "filter.and[1].labels.some.name"; Field "inIgnoreCase" is not defined by
700    /// type "StringComparator". Did you mean "eqIgnoreCase" or "neqIgnoreCase"?
701    /// ```
702    ///
703    /// That refusal is also the evidence for the replacement: Linear named the two members
704    /// of `StringComparator` closest to what it was sent, and `eqIgnoreCase` is one of
705    /// them — the same operator `all_of` below has always sent and the live lane has always
706    /// exercised. `in` exists there too and would need no `or`, but it is case-sensitive,
707    /// so `any_of` would stop agreeing with `all_of` and `none_of` and with what the table
708    /// at the top of this file says this source does.
709    fn label_parts(labels: &onetaskgraph_plugin_api::LabelFilter) -> Vec<Value> {
710        let mut parts = Vec::new();
711        if !labels.any_of.is_empty() {
712            parts.push(json!({"or": labels
713                .any_of
714                .iter()
715                .map(|name| json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}))
716                .collect::<Vec<_>>()}));
717        }
718        for name in &labels.all_of {
719            parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
720        }
721        for name in &labels.none_of {
722            parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
723        }
724        parts
725    }
726    fn narrowed(mut parts: Vec<Value>) -> Value {
727        if parts.len() == 1 {
728            parts.pop().unwrap()
729        } else {
730            json!({"and": parts})
731        }
732    }
733    /// The filter this source sends to `issues(filter:)`.
734    ///
735    /// **`IssueFilter` and `ProjectFilter` are different input types, and one builder for
736    /// both is what put two wrong fields on the wire.** They read as though they were the
737    /// same filter over different rows — the label member really is spelled alike, and the
738    /// `and`/`or` are identical — and a single builder producing one object for both
739    /// connections had shipped `team` and the issue's `state` shape into `projects(filter:)`
740    /// since long before this branch. Linear refused the first outright:
741    ///
742    /// ```text
743    /// Variable "$filter" got invalid value { team: { key: [Object] } };
744    /// Field "team" is not defined by type "ProjectFilter". Did you mean "lead"?
745    /// ```
746    ///
747    /// So there are two builders, and each names its own type's members. Adding a predicate
748    /// means deciding twice, on purpose, rather than once by accident.
749    fn issue_filter(
750        &self,
751        labels: &onetaskgraph_plugin_api::LabelFilter,
752        statuses: &[StatusCategory],
753        project: &ProjectFilter,
754    ) -> Value {
755        let mut parts = Vec::new();
756        if let Some(team) = &self.team {
757            parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
758        }
759        parts.extend(Self::label_parts(labels));
760        if !statuses.is_empty() {
761            parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(workflow_state_types).collect::<Vec<_>>()}}}));
762        }
763        match project {
764            ProjectFilter::Orphans => parts.push(json!({"project": {"null": true}})),
765            ProjectFilter::Is(id) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
766            _ => {}
767        }
768        Self::narrowed(parts)
769    }
770    /// The filter this source sends to `projects(filter:)`.
771    ///
772    /// Two members differ from [`Self::issue_filter`] and both are Linear's doing; see that
773    /// builder for why they are written out twice rather than shared.
774    ///
775    /// **A project has no `team`.** It has the teams it is accessible from, and
776    /// `ProjectFilter.accessibleTeams` is a `TeamCollectionFilter`, so the same team key
777    /// reaches it under `some:`. `leadTeam` is the other team-shaped member and is a
778    /// different set — one designated team rather than every team the project is in — so
779    /// narrowing by it would drop projects the configured team really does hold.
780    ///
781    /// **A project's status is not an issue's state, and they do not even share a
782    /// vocabulary.** An issue's is `WorkflowState`, reached through `IssueFilter.state`,
783    /// and its `type` is `backlog`, `unstarted`, `started`, `completed`, `canceled` or
784    /// `triage`. A project's is `ProjectStatus`, reached through `ProjectFilter.status` —
785    /// `ProjectFilter.state` exists and is *not* it: that member is a bare
786    /// `StringComparator` over a different thing — and its `type` is the `ProjectStatusType`
787    /// enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`. So the
788    /// nearest thing to an issue's `unstarted` is a project's `planned`, and `paused` has no
789    /// issue counterpart at all. [`project_status_types`] is that vocabulary and
790    /// [`workflow_state_types`] is the other; sending either one's words to the other's
791    /// connection matches nothing while refusing nothing, which is the worst way to be
792    /// wrong.
793    fn project_filter(
794        &self,
795        labels: &onetaskgraph_plugin_api::LabelFilter,
796        statuses: &[StatusCategory],
797    ) -> Value {
798        let mut parts = Vec::new();
799        if let Some(team) = &self.team {
800            parts.push(json!({"accessibleTeams": {"some": {"key": {"eqIgnoreCase": team.0}}}}));
801        }
802        parts.extend(Self::label_parts(labels));
803        if !statuses.is_empty() {
804            parts.push(json!({"status": {"type": {"in": statuses.iter().flat_map(project_status_types).collect::<Vec<_>>()}}}));
805        }
806        Self::narrowed(parts)
807    }
808    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
809
810    async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
811        let data = self.send(lookup.query(), lookup.variables()).await?;
812        let connection = lookup.connection();
813        let nodes = data
814            .get(connection)
815            .and_then(|v| v.get("nodes"))
816            .and_then(Value::as_array)
817            .ok_or_else(|| SourceError::Malformed {
818                message: format!("missing {connection}.nodes"),
819            })?;
820        // A node this comparison cannot read is malformed rather than a nonmatch: dropping
821        // it would turn Linear having answered nonsense into this source reporting no such
822        // status, which is a different thing and reads as the caller's mistake.
823        let matched = match lookup.local_name() {
824            Some(name) => {
825                let mut matched = Vec::new();
826                for node in nodes {
827                    if str_at(node, "name")?.eq_ignore_ascii_case(name) {
828                        matched.push(node);
829                    }
830                }
831                matched
832            }
833            None => nodes.iter().collect::<Vec<_>>(),
834        };
835        if matched.len() != 1 {
836            return Err(SourceError::Refused {
837                message: format!(
838                    "source {} cannot resolve {} uniquely",
839                    self.name,
840                    lookup.diagnostic()
841                ),
842            });
843        }
844        Ok(NativeId(backend_id(matched[0], "id")?.to_owned()))
845    }
846    async fn team_id(&self) -> Result<NativeId, SourceError> {
847        let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
848            message: format!(
849                "source {} needs config.team before it can create Linear items",
850                self.name
851            ),
852        })?;
853        self.one_id(Lookup::Team(&team.0)).await
854    }
855    async fn label_ids(
856        &self,
857        labels: &[Label],
858        kind: WriteKind,
859    ) -> Result<Vec<NativeId>, SourceError> {
860        let mut ids = Vec::with_capacity(labels.len());
861        for label in labels {
862            ids.push(
863                self.one_id(if matches!(kind, WriteKind::Project) {
864                    Lookup::ProjectLabel(&label.name)
865                } else {
866                    Lookup::IssueLabel(&label.name)
867                })
868                .await?,
869            );
870        }
871        Ok(ids)
872    }
873    fn write_description(
874        &self,
875        content: Option<&str>,
876        metadata: &std::collections::BTreeMap<String, Value>,
877        repositories: &[Repository],
878        edges: &[DependencyEdge],
879        kind: WriteKind,
880    ) -> Result<Option<String>, SourceError> {
881        let recorded = edges
882            .iter()
883            .filter(|edge| {
884                edge.to.kind
885                    != match kind {
886                        WriteKind::Task => ItemKind::Task,
887                        WriteKind::Project => ItemKind::Project,
888                    }
889                    || edge
890                        .to
891                        .id()
892                        .split_once(':')
893                        .is_some_and(|(source, _)| source != self.name.as_str())
894            })
895            .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
896            .collect::<Vec<_>>();
897        Self::long_form(content, metadata, repositories, recorded)
898    }
899
900    /// The one long-form field a Linear item has, with this source's own slot at the end.
901    ///
902    /// Shared by every kind this source writes rather than reimplemented per kind: a
903    /// document keeps caller metadata in exactly the slot an issue and a project do, which
904    /// is what lets the same read side take it back out.
905    fn long_form(
906        content: Option<&str>,
907        metadata: &std::collections::BTreeMap<String, Value>,
908        repositories: &[Repository],
909        recorded: Vec<Value>,
910    ) -> Result<Option<String>, SourceError> {
911        let mut metadata = metadata.clone();
912        if repositories.is_empty() {
913            metadata.remove(Repository::METADATA_KEY);
914        } else {
915            metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
916        }
917        if recorded.is_empty() {
918            metadata.remove(DependencyEdge::RECORDED_KEY);
919        } else {
920            metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
921        }
922        let visible = content.unwrap_or_default();
923        if metadata.is_empty() {
924            return Ok((!visible.is_empty()).then(|| visible.to_owned()));
925        }
926        let encoded = serde_json::to_string(&metadata).map_err(|error| SourceError::Malformed {
927            message: error.to_string(),
928        })?;
929        Ok(Some(if visible.is_empty() {
930            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
931        } else {
932            format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
933        }))
934    }
935    /// What this source says when asked for a project edge carrying no ordering.
936    ///
937    /// Linear's project relations have exactly one type and it is an ordering. Asked on
938    /// 2026-09-04 to create one typed `related` — and separately `blocks` and `dependsOn`
939    /// — the real API refused each with `Argument Validation Error` and
940    /// `constraints: {"isEnum": "type must be one of the following values: dependency"}`.
941    /// That is Linear's own enumeration of the field, from the validator behind GraphQL
942    /// where introspection cannot reach it, and it has one member. An issue relation is a
943    /// different relation with a different set, which does include `related`, so this
944    /// reaches projects alone.
945    fn unordered_project_relation(&self, near: &NativeId, far: &str) -> SourceError {
946        SourceError::Refused {
947            message: format!(
948                "source {} cannot carry an unordered dependency between projects, because \
949                 Linear types every project relation `dependency` and that is an ordering; \
950                 record {near} to {far} as a dependency, or between tasks",
951                self.name,
952                near = near.0,
953            ),
954        }
955    }
956    /// The one edge [`Self::unordered_project_relation`] refuses, if there is one here.
957    fn unordered_project_edge(edges: &[DependencyEdge]) -> Option<&DependencyEdge> {
958        edges
959            .iter()
960            .find(|edge| edge.to.kind == ItemKind::Project && edge.kind == DependencyKind::Related)
961    }
962    async fn write_relations(
963        &self,
964        near: &NativeId,
965        edges: &[DependencyEdge],
966        kind: WriteKind,
967    ) -> Result<(), SourceError> {
968        let mut cursor: Option<Cursor> = None;
969        loop {
970            let data = self
971                .send(
972                    if matches!(kind, WriteKind::Project) {
973                        PROJECT_RELATIONS
974                    } else {
975                        ISSUE_RELATIONS
976                    },
977                    json!({"id":near.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
978                )
979                .await?;
980            let root = data
981                .get(if matches!(kind, WriteKind::Project) {
982                    "project"
983                } else {
984                    "issue"
985                })
986                .ok_or_else(|| SourceError::Malformed {
987                    message: "missing relation item".into(),
988                })?;
989            let relations = root
990                .get("relations")
991                .ok_or_else(|| SourceError::Malformed {
992                    message: "missing relations".into(),
993                })?;
994            for relation in relations
995                .get("nodes")
996                .and_then(Value::as_array)
997                .ok_or_else(|| SourceError::Malformed {
998                    message: "missing relations.nodes".into(),
999                })?
1000            {
1001                let id = backend_id(relation, "id")?;
1002                let (query, mutation) = if matches!(kind, WriteKind::Project) {
1003                    (
1004                        graphql::PROJECT_RELATION_DELETE,
1005                        MutationRoot::ProjectRelationDelete,
1006                    )
1007                } else {
1008                    (
1009                        graphql::ISSUE_RELATION_DELETE,
1010                        MutationRoot::IssueRelationDelete,
1011                    )
1012                };
1013                let deleted = self.send(query, json!({"id":id})).await?;
1014                mutation_payload(&deleted, mutation)?;
1015            }
1016            let Some(next) = page_next(relations)? else {
1017                break;
1018            };
1019            cursor = Some(next);
1020        }
1021        // Linear requires an anchor at each end of a project relation and validates both
1022        // against an enum GraphQL cannot see: `ProjectRelationCreateInput` declares them
1023        // `String!` and enumerates nothing, and the field descriptions read as a choice
1024        // between the project and a milestone, which is not what they are. Linear's own
1025        // refusal enumerates them — sent `project` in both, it answered `anchorType must
1026        // be one of the following values: start, end, milestone` — and `milestone` needs
1027        // an id this source never sends, so the two whole-project anchors are the whole of
1028        // what it can send.
1029        //
1030        // **Which of them goes where carries the direction, and the two id slots do not.**
1031        // Linear stores whatever pair it is given and reads a backwards dependency as
1032        // readily as the right one, so acceptance settles nothing; what does is Linear's
1033        // own reading of a stored relation, published as the computed `ProjectFilter`
1034        // members `hasBlockingRelations` ("projects which are blocking") and
1035        // `hasBlockedByRelations` ("projects which are blocked"). Three relations between
1036        // two scratch projects, read back through them on 2026-09-04:
1037        //
1038        // | `projectId` | `anchorType` | `relatedProjectId` | `relatedAnchorType` | blocked | blocking |
1039        // | ----------- | ------------ | ------------------ | ------------------- | ------- | -------- |
1040        // | A           | `start`      | B                  | `end`               | A       | B        |
1041        // | A           | `end`        | B                  | `start`             | B       | A        |
1042        // | B           | `end`        | A                  | `start`             | A       | B        |
1043        //
1044        // Rows one and three exchange the ids and the anchors together and read alike;
1045        // rows one and two exchange only the anchors and the reading flips. So the project
1046        // anchored `start` is the one that waits, whichever slot it sits in, and row one is
1047        // what this source sends — `near`, the item that depends, in `projectId`. Linear's
1048        // own callers put the blocker there instead, so copying their `end`/`start` pair
1049        // across by position would state every dependency backwards in the workspace, and
1050        // nothing would refuse it.
1051        const NEAR_ANCHOR: &str = "start";
1052        const FAR_ANCHOR: &str = "end";
1053        for edge in edges {
1054            if edge.to.kind
1055                != match kind {
1056                    WriteKind::Task => ItemKind::Task,
1057                    WriteKind::Project => ItemKind::Project,
1058                }
1059            {
1060                continue;
1061            }
1062            let far = match edge.to.id().split_once(':') {
1063                Some((source, native)) if source == self.name.as_str() => native,
1064                Some(_) => continue,
1065                None => edge.to.id(),
1066            };
1067            // A project relation is not spelled the way an issue relation is, and this is
1068            // the whole of what a project's `type` may say.
1069            //
1070            // `blocks` there is what the live journey's project write was refused for
1071            // once the two anchors above stopped being missing: Linear answered HTTP 200
1072            // with `Argument Validation Error`, the message class its input validator
1073            // raises for a value outside an accepted set, having already accepted every
1074            // field of the same input by name — which is what tells that refusal apart
1075            // from the missing-field one before it, and what says the anchors were not the
1076            // cause.
1077            //
1078            // Which field, and what it takes, was measured against the real API on
1079            // 2026-09-04 rather than inferred. Each of `blocks`, `dependsOn`, `related`
1080            // and `DEPENDENCY` was refused with `property: "type"` and
1081            // `constraints: {"isEnum": "type must be one of the following values:
1082            // dependency"}`; `dependency` was accepted. That enumeration, like the
1083            // anchors' above, reaches this source through the validator's `extensions`;
1084            // see `GqlError::said`.
1085            //
1086            // A `Related` project edge is refused at the top of this function by that same
1087            // enumeration: it has one member and it is an ordering. An issue relation is a
1088            // different relation with a different set, which does include `related`.
1089            let relation_type = match (kind, edge.kind) {
1090                (WriteKind::Project, DependencyKind::Blocks) => "dependency",
1091                (WriteKind::Task, DependencyKind::Blocks) => "blocks",
1092                (WriteKind::Task, DependencyKind::Related) => "related",
1093                // Unreachable past `write_project`'s guard, and an error rather than a
1094                // skip so it stays that way: an edge dropped here would be a copy
1095                // reporting success for a dependency the destination does not hold.
1096                (WriteKind::Project, DependencyKind::Related) => {
1097                    return Err(self.unordered_project_relation(near, edge.to.id()));
1098                }
1099            };
1100            let (query, input) = if matches!(kind, WriteKind::Project) {
1101                (
1102                    graphql::PROJECT_RELATION_CREATE,
1103                    json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type,"anchorType":NEAR_ANCHOR,"relatedAnchorType":FAR_ANCHOR}),
1104                )
1105            } else {
1106                (
1107                    graphql::ISSUE_RELATION_CREATE,
1108                    json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
1109                )
1110            };
1111            let data = self.send(query, json!({"input":input})).await?;
1112            let mutation = if matches!(kind, WriteKind::Project) {
1113                MutationRoot::ProjectRelationCreate
1114            } else {
1115                MutationRoot::IssueRelationCreate
1116            };
1117            let payload = mutation_payload(&data, mutation)?;
1118            let relation = payload
1119                .get(if matches!(kind, WriteKind::Project) {
1120                    "projectRelation"
1121                } else {
1122                    "issueRelation"
1123                })
1124                .ok_or_else(|| SourceError::Malformed {
1125                    message: format!("missing {} relation", mutation.as_str()),
1126                })?;
1127            backend_id(relation, "id")?;
1128        }
1129        Ok(())
1130    }
1131
1132    async fn prepare_edges(
1133        &self,
1134        edges: &[DependencyEdge],
1135        kind: WriteKind,
1136    ) -> Result<Vec<DependencyEdge>, SourceError> {
1137        let mut prepared = Vec::with_capacity(edges.len());
1138        for edge in edges {
1139            let mut edge = edge.clone();
1140            if edge.to.kind
1141                == match kind {
1142                    WriteKind::Task => ItemKind::Task,
1143                    WriteKind::Project => ItemKind::Project,
1144                }
1145                && edge
1146                    .to
1147                    .id()
1148                    .split_once(':')
1149                    .is_some_and(|(source, _)| source != self.name.as_str())
1150            {
1151                let mut cursor: Option<Cursor> = None;
1152                loop {
1153                    let data = self.send(if matches!(kind, WriteKind::Project) { PROJECTS } else { ISSUES }, json!({"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":{}})).await?;
1154                    let (items, next) = if matches!(kind, WriteKind::Project) {
1155                        let page = connection(&data, "projects", map_project)?;
1156                        (
1157                            page.items
1158                                .into_iter()
1159                                .map(|item| (item.id, item.metadata))
1160                                .collect::<Vec<_>>(),
1161                            page.next,
1162                        )
1163                    } else {
1164                        let page = connection(&data, "issues", map_task)?;
1165                        (
1166                            page.items
1167                                .into_iter()
1168                                .map(|item| (item.id, item.metadata))
1169                                .collect::<Vec<_>>(),
1170                            page.next,
1171                        )
1172                    };
1173                    if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
1174                        metadata.get("onetaskgraph.origin").and_then(Value::as_str)
1175                            == Some(edge.to.id())
1176                    }) {
1177                        edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
1178                        break;
1179                    }
1180                    let Some(next) = next else { break };
1181                    cursor = Some(next);
1182                }
1183            }
1184            prepared.push(edge);
1185        }
1186        Ok(prepared)
1187    }
1188}
1189
1190#[async_trait::async_trait]
1191impl TaskSource for LinearSource {
1192    fn kind(&self) -> &'static str {
1193        KIND
1194    }
1195    fn capabilities(&self) -> Capabilities {
1196        Capabilities {
1197            projects: Support::Native,
1198            documents: Support::Native,
1199            orphan_tasks: Support::Native,
1200            filter_by_label: Support::Native,
1201            filter_by_status: Support::Native,
1202            search_title: Support::Unsupported,
1203            search_content: Support::Unsupported,
1204            task_dependencies: DependencySupport::BothDirections,
1205            project_dependencies: DependencySupport::BothDirections,
1206            max_page_size: MAX_PAGE_SIZE,
1207        }
1208    }
1209    fn writes(&self) -> WriteSupport {
1210        WriteSupport::Supported
1211    }
1212    async fn health(&self) -> Result<Health, SourceError> {
1213        let data = self.send(VIEWER, json!({})).await?;
1214        str_at(
1215            data.get("viewer").ok_or_else(|| SourceError::Malformed {
1216                message: "missing viewer".into(),
1217            })?,
1218            "id",
1219        )?;
1220        Ok(Health {
1221            reachable: true,
1222            detail: None,
1223        })
1224    }
1225    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1226        let d = self.send(ISSUE, json!({"id":id.0})).await?;
1227        optional(&d, "issue", map_task)
1228    }
1229    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1230        let d = self.send(PROJECT, json!({"id":id.0})).await?;
1231        optional(&d, "project", map_project)
1232    }
1233    async fn query_tasks(
1234        &self,
1235        query: &TaskQuery,
1236        page: &PageRequest,
1237    ) -> Result<Page<Task>, SourceError> {
1238        let d=self.send(ISSUES,json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.issue_filter(&query.labels,&query.statuses,&query.project)})).await?;
1239        connection(&d, "issues", map_task)
1240    }
1241    async fn query_projects(
1242        &self,
1243        query: &ProjectQuery,
1244        page: &PageRequest,
1245    ) -> Result<Page<Project>, SourceError> {
1246        // llmlint: ignore[changed_behavior_has_e2e] The shared CLI journey `every_complete_dataset_source_filters_projects_by_label_status_and_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.
1247        let d=self.send(PROJECTS,json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.project_filter(&query.labels,&query.statuses)})).await?;
1248        connection(&d, "projects", map_project)
1249    }
1250    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1251        let d = self
1252            .send(
1253                LABELS,
1254                json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0)}),
1255            )
1256            .await?;
1257        connection(&d, "issueLabels", map_label)
1258    }
1259    async fn task_dependencies(
1260        &self,
1261        id: &NativeId,
1262        direction: Direction,
1263        page: &PageRequest,
1264    ) -> Result<Page<DependencyEdge>, SourceError> {
1265        self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
1266            .await
1267    }
1268    async fn project_dependencies(
1269        &self,
1270        id: &NativeId,
1271        direction: Direction,
1272        page: &PageRequest,
1273    ) -> Result<Page<DependencyEdge>, SourceError> {
1274        self.dependencies(
1275            PROJECT_RELATIONS,
1276            DependencyRoot::Project,
1277            id,
1278            direction,
1279            page,
1280        )
1281        .await
1282    }
1283    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1284        let edges = self
1285            .prepare_edges(&write.depends_on, WriteKind::Task)
1286            .await?;
1287        let team = self.team_id().await?;
1288        let state = self
1289            .one_id(Lookup::IssueState {
1290                name: &write.item.status.name,
1291                team: &team,
1292            })
1293            .await?;
1294        let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
1295        let description = self.write_description(
1296            write.item.content.as_deref(),
1297            &write.item.metadata,
1298            &write.item.repositories,
1299            &edges,
1300            WriteKind::Task,
1301        )?;
1302        let input = json!({"title":write.item.title,"description":description,"stateId":state,"labelIds":labels,"projectId":write.item.project.as_ref().map(|id| id.0.clone())});
1303        let (query, variables, root) = match &write.target {
1304            Some(id) => (
1305                graphql::ISSUE_UPDATE,
1306                json!({"id":id.0,"input":input}),
1307                MutationRoot::IssueUpdate,
1308            ),
1309            None => (
1310                graphql::ISSUE_CREATE,
1311                {
1312                    let mut input = input;
1313                    input["teamId"] = Value::String(team.0);
1314                    json!({"input":input})
1315                },
1316                MutationRoot::IssueCreate,
1317            ),
1318        };
1319        let data = self.send(query, variables).await?;
1320        let issue =
1321            mutation_payload(&data, root)?
1322                .get("issue")
1323                .ok_or_else(|| SourceError::Malformed {
1324                    message: format!("missing {}.issue", root.as_str()),
1325                })?;
1326        let id = NativeId(backend_id(issue, "id")?.into());
1327        self.write_relations(&id, &edges, WriteKind::Task).await?;
1328        Ok(id)
1329    }
1330    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1331        // Before anything is read or written, and before the item's own description
1332        // records these edges: an edge Linear will never accept has to refuse the whole
1333        // write, or a copy would create the project and then fail relating it, leaving the
1334        // undo to clean up a write that could have been refused without a call at all.
1335        if let Some(edge) = Self::unordered_project_edge(&write.depends_on) {
1336            return Err(self.unordered_project_relation(&write.item.id, edge.to.id()));
1337        }
1338        let edges = self
1339            .prepare_edges(&write.depends_on, WriteKind::Project)
1340            .await?;
1341        let team = self.team_id().await?;
1342        let status = self
1343            .one_id(Lookup::ProjectStatus(&write.item.status.name))
1344            .await?;
1345        let labels = self
1346            .label_ids(&write.item.labels, WriteKind::Project)
1347            .await?;
1348        let description = self.write_description(
1349            write.item.content.as_deref(),
1350            &write.item.metadata,
1351            &write.item.repositories,
1352            &edges,
1353            WriteKind::Project,
1354        )?;
1355        let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
1356        let (query, variables, root) = match &write.target {
1357            Some(id) => (
1358                graphql::PROJECT_UPDATE,
1359                json!({"id":id.0,"input":input}),
1360                MutationRoot::ProjectUpdate,
1361            ),
1362            None => (
1363                graphql::PROJECT_CREATE,
1364                {
1365                    let mut input = input;
1366                    input["teamIds"] = json!([team]);
1367                    json!({"input":input})
1368                },
1369                MutationRoot::ProjectCreate,
1370            ),
1371        };
1372        let data = self.send(query, variables).await?;
1373        let project = mutation_payload(&data, root)?
1374            .get("project")
1375            .ok_or_else(|| SourceError::Malformed {
1376                message: format!("missing {}.project", root.as_str()),
1377            })?;
1378        let id = NativeId(backend_id(project, "id")?.into());
1379        self.write_relations(&id, &edges, WriteKind::Project)
1380            .await?;
1381        Ok(id)
1382    }
1383    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
1384        // An id naming nothing is the state this asks for, not an error — Linear reports
1385        // an unknown issue as an errored response rather than an unsuccessful payload, and
1386        // `get_task` answering `None` is what says the item is already gone.
1387        if self.get_task(id).await?.is_none() {
1388            return Ok(());
1389        }
1390        let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
1391        mutation_payload(&data, MutationRoot::IssueDelete)?;
1392        Ok(())
1393    }
1394    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
1395        // An id naming nothing is the state this asks for, on exactly the terms
1396        // `delete_task` reads it on.
1397        if self.get_project(id).await?.is_none() {
1398            return Ok(());
1399        }
1400        let data = self
1401            .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
1402            .await?;
1403        mutation_payload(&data, MutationRoot::ProjectDelete)?;
1404        Ok(())
1405    }
1406    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
1407        // Read as an optional although the pinned `document(id:)` returns `Document!`, for
1408        // the reason `delete_task` records: Linear answers an id naming nothing with an
1409        // errored response rather than a null, and reading the null defensively is what
1410        // keeps a responder that does answer one from being a malformed-response failure.
1411        let d = self.send(DOCUMENT, json!({"id":id.0})).await?;
1412        optional(&d, "document", map_document)
1413    }
1414    async fn query_documents(
1415        &self,
1416        query: &DocumentQuery,
1417        page: &PageRequest,
1418    ) -> Result<Page<Document>, SourceError> {
1419        // `query.text` is read by nothing here on purpose. Both searches are declared
1420        // `Unsupported`, and capability rule 2 says an ignored predicate returns the
1421        // *wider* set for the engine to narrow — half-applying one is what would drop rows.
1422        let want = page.limit.min(MAX_PAGE_SIZE) as usize;
1423        let mut filter = serde_json::Map::new();
1424        if let ProjectFilter::Is(id) = &query.project {
1425            filter.insert("project".into(), json!({"id": {"eq": id.0}}));
1426        }
1427        let filter = Value::Object(filter);
1428        let mut items = Vec::new();
1429        let mut cursor = page.cursor.clone();
1430        loop {
1431            // Only what is still owed, so the predicates applied here can never make this
1432            // return more than the caller asked for, and never drop what it fetched.
1433            let first = want.saturating_sub(items.len()).max(1);
1434            let d = self
1435                .send(
1436                    DOCUMENTS,
1437                    json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
1438                )
1439                .await?;
1440            let fetched = connection(&d, "documents", map_document)?;
1441            items.extend(
1442                fetched
1443                    .items
1444                    .into_iter()
1445                    .filter(|document| document_matches(document, &query.project, &query.labels)),
1446            );
1447            cursor = fetched.next;
1448            if cursor.is_none() || items.len() >= want {
1449                return Ok(Page {
1450                    items,
1451                    next: cursor,
1452                });
1453            }
1454        }
1455    }
1456    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
1457        // Two refusals by name rather than two silent drops. Linear's own document type
1458        // has no labels and a document is not work, so neither a label nor a dependency
1459        // has anywhere here to land — and a copy that dropped one would report success for
1460        // an item the destination does not hold.
1461        if !write.item.labels.is_empty() {
1462            let named = write
1463                .item
1464                .labels
1465                .iter()
1466                .map(|label| label.name.as_str())
1467                .collect::<Vec<_>>()
1468                .join(", ");
1469            return Err(SourceError::Refused {
1470                message: format!(
1471                    "source {} cannot carry a document's labels, because Linear's own \
1472                     document type has none: {named}",
1473                    self.name
1474                ),
1475            });
1476        }
1477        if !write.depends_on.is_empty()
1478            || write
1479                .item
1480                .metadata
1481                .contains_key(DependencyEdge::RECORDED_KEY)
1482        {
1483            return Err(SourceError::Refused {
1484                message: format!(
1485                    "source {} cannot carry {} on a document, because a document is not \
1486                     work and nothing may depend on one",
1487                    self.name,
1488                    DependencyEdge::RECORDED_KEY
1489                ),
1490            });
1491        }
1492        let content = Self::long_form(
1493            write.item.content.as_deref(),
1494            &write.item.metadata,
1495            &write.item.repositories,
1496            Vec::new(),
1497        )?;
1498        let project = write.item.project.as_ref().map(|id| id.0.clone());
1499        let (query, variables, root) = match &write.target {
1500            Some(id) => {
1501                // A target this workspace does not hold is refused rather than created:
1502                // the engine established that id before asking, so an absent one is a race
1503                // this destination must not paper over by writing a second document.
1504                if self.get_document(id).await?.is_none() {
1505                    return Err(SourceError::Refused {
1506                        message: format!("source {} holds no document {}", self.name, id.0),
1507                    });
1508                }
1509                (
1510                    graphql::DOCUMENT_UPDATE,
1511                    json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
1512                    MutationRoot::DocumentUpdate,
1513                )
1514            }
1515            None => {
1516                let mut input = json!({"title":write.item.title,"content":content});
1517                // A Linear document lives in a project, an initiative, an issue or a team.
1518                // One filed under no project needs the configured team to be its home, and
1519                // one filed under a project already has one — so the team is asked for
1520                // only where it is the answer, rather than made a condition of every write.
1521                //
1522                // **`projectId` is left out rather than sent as null, and that is Linear's
1523                // rule rather than tidiness.** `documentCreate` refuses an input that names
1524                // more than one home — `Exactly one of initiativeId, teamId, issueId,
1525                // releaseId, cycleId or projectId must be defined.` — and it counts a
1526                // *present* key, observed on 2026-09-04: `{projectId: null, teamId: …}` is
1527                // refused where `{teamId: …}` is accepted. So a document filed under no
1528                // project must carry no `projectId` at all. `documentUpdate` is the
1529                // opposite and keeps its explicit null, because there the null is the
1530                // instruction — it is how a document is moved out of a project, and
1531                // omitting the key would leave it where it was.
1532                match &project {
1533                    Some(project) => input["projectId"] = Value::String(project.clone()),
1534                    None => input["teamId"] = Value::String(self.team_id().await?.0),
1535                }
1536                (
1537                    graphql::DOCUMENT_CREATE,
1538                    json!({ "input": input }),
1539                    MutationRoot::DocumentCreate,
1540                )
1541            }
1542        };
1543        let data = self.send(query, variables).await?;
1544        let document = mutation_payload(&data, root)?
1545            .get("document")
1546            .ok_or_else(|| SourceError::Malformed {
1547                message: format!("missing {}.document", root.as_str()),
1548            })?;
1549        Ok(NativeId(backend_id(document, "id")?.into()))
1550    }
1551    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
1552        // An id naming nothing is the state this asks for, on exactly the terms
1553        // `delete_task` reads it on.
1554        if self.get_document(id).await?.is_none() {
1555            return Ok(());
1556        }
1557        let data = self
1558            .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
1559            .await?;
1560        mutation_payload(&data, MutationRoot::DocumentDelete)?;
1561        Ok(())
1562    }
1563}
1564
1565/// Linear relates one Linear item to another and nothing else, so an edge whose far end
1566/// is in a different source is the one edge no `relations` entry can hold. Those edges
1567/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
1568/// are served *after* the native relations are spent: a page under this cursor is the
1569/// recorded tail of the same walk, which keeps the native pages exactly what they were.
1570const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1571
1572impl LinearSource {
1573    async fn dependencies(
1574        &self,
1575        query: &str,
1576        root: DependencyRoot,
1577        id: &NativeId,
1578        direction: Direction,
1579        page: &PageRequest,
1580    ) -> Result<Page<DependencyEdge>, SourceError> {
1581        let limit = page.limit.min(MAX_PAGE_SIZE);
1582        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1583        if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
1584            // This cursor resumes the *forward* tail and only a forward walk ever issues
1585            // one, so a reverse read carrying it is resuming a walk it did not come from.
1586            // Serving it would answer a reverse read with forward edges, which is the one
1587            // thing a recorded edge must never do — its reverse is derived from the far
1588            // end and is never written down here.
1589            if direction != Direction::DependsOn {
1590                return Err(SourceError::Malformed {
1591                    message: format!(
1592                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a                          reverse dependency read never issues; resume it in the direction                          that reported it"
1593                    ),
1594                });
1595            }
1596            let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
1597                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
1598            })?;
1599            let d = self
1600                .send(query, json!({"id":id.0,"first":1,"after":null}))
1601                .await?;
1602            return Ok(recorded_page(
1603                recorded(&d, root, id, &self.name)?,
1604                offset,
1605                limit as usize,
1606            ));
1607        }
1608        let d = self
1609            .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
1610            .await?;
1611        let mut answered = relation_page(&d, root, id, direction)?;
1612        // Only forwards: the reverse of a recorded edge is derived from the far end, never
1613        // written down on the near item.
1614        if answered.next.is_none()
1615            && direction == Direction::DependsOn
1616            && !recorded(&d, root, id, &self.name)?.is_empty()
1617        {
1618            answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1619        }
1620        Ok(answered)
1621    }
1622}
1623
1624fn recorded(
1625    d: &Value,
1626    root: DependencyRoot,
1627    id: &NativeId,
1628    name: &SourceName,
1629) -> Result<Vec<DependencyEdge>, SourceError> {
1630    let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
1631        message: format!("missing {}", root.as_str()),
1632    })?;
1633    let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
1634    // `relations` on an issue holds issues and on a project holds projects, both of this
1635    // workspace — so a same-kind far end in this same source is one Linear itself was
1636    // supposed to hold, and the key is refused rather than quietly read, whether the entry
1637    // left the source out or spelled this one.
1638    DependencyEdge::recorded(
1639        &metadata,
1640        id,
1641        root.item_kind(),
1642        name,
1643        Some(root.item_kind()),
1644    )
1645    .map_err(|message| SourceError::Malformed { message })
1646}
1647
1648fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1649    let total = edges.len();
1650    let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
1651    let end = offset.saturating_add(items.len());
1652    Page {
1653        items,
1654        next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
1655    }
1656}
1657
1658// 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.
1659/// A category as `WorkflowState.type` spells it — the vocabulary an **issue**'s state has.
1660///
1661/// Linear's workflow states are triage, backlog, unstarted, started, completed and
1662/// canceled. None of them is a draft, so `Draft` narrows to nothing exactly as `Unknown`
1663/// does rather than filtering on a state Linear does not have.
1664fn workflow_state_types(s: &StatusCategory) -> Vec<&'static str> {
1665    match s {
1666        StatusCategory::Draft => vec![],
1667        StatusCategory::Backlog => vec!["backlog"],
1668        StatusCategory::Todo => vec!["unstarted"],
1669        StatusCategory::InProgress => vec!["started"],
1670        StatusCategory::Done => vec!["completed"],
1671        StatusCategory::Cancelled => vec!["canceled"],
1672        StatusCategory::Unknown => vec![],
1673    }
1674}
1675/// A category as `ProjectStatus.type` spells it — a **different** vocabulary, and a
1676/// different enum: Linear declares that field `ProjectStatusType!`, whose members are
1677/// backlog, planned, started, paused, completed and canceled.
1678///
1679/// Two of them have no issue counterpart and are why this cannot be the function above.
1680/// `planned` is where `unstarted` would be, so it is what `Todo` narrows to; a project
1681/// filtered with `unstarted` matches nothing and is refused by nothing, which is how this
1682/// went unnoticed. And `paused` is a project that has started and is neither finished nor
1683/// cancelled, so it reads as in progress — the same reading [`status`] gives it, which is
1684/// what keeps this narrowing and that mapping the same claim rather than two.
1685fn project_status_types(s: &StatusCategory) -> Vec<&'static str> {
1686    match s {
1687        StatusCategory::Draft => vec![],
1688        StatusCategory::Backlog => vec!["backlog"],
1689        StatusCategory::Todo => vec!["planned"],
1690        StatusCategory::InProgress => vec!["started", "paused"],
1691        StatusCategory::Done => vec!["completed"],
1692        StatusCategory::Cancelled => vec!["canceled"],
1693        StatusCategory::Unknown => vec![],
1694    }
1695}
1696/// The category a Linear status name and type normalise to, at either level.
1697///
1698/// One mapper for both vocabularies, because the two are disjoint where they differ: no
1699/// issue is ever `planned` or `paused`, and no project is ever `unstarted` or `triage`. It
1700/// is the inverse of [`workflow_state_types`] and [`project_status_types`] together, and
1701/// has to stay so: a category this reports and that filter cannot ask for is capability
1702/// rule 1 broken, and the row would go missing rather than be refused.
1703fn status(v: &Value) -> Result<Status, SourceError> {
1704    let name = str_at(v, "name")?.into();
1705    let category = match str_at(v, "type")? {
1706        "backlog" => StatusCategory::Backlog,
1707        "unstarted" | "planned" => StatusCategory::Todo,
1708        "started" | "paused" => StatusCategory::InProgress,
1709        "completed" => StatusCategory::Done,
1710        "canceled" => StatusCategory::Cancelled,
1711        _ => StatusCategory::Unknown,
1712    };
1713    Ok(Status { category, name })
1714}
1715// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1716fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
1717    v.get(k)
1718        .and_then(Value::as_str)
1719        .ok_or_else(|| SourceError::Malformed {
1720            message: format!("missing string field {k}"),
1721        })
1722}
1723fn map_label(v: &Value) -> Result<Label, SourceError> {
1724    Ok(Label {
1725        id: NativeId(str_at(v, "id")?.into()),
1726        name: str_at(v, "name")?.into(),
1727        color: optional_string(v, "color")?,
1728    })
1729}
1730fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
1731    v.get("nodes")
1732        .and_then(Value::as_array)
1733        .ok_or_else(|| SourceError::Malformed {
1734            message: "missing label nodes".into(),
1735        })?
1736        .iter()
1737        .map(map_label)
1738        .collect()
1739}
1740fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1741    optional_str(v, k)?
1742        .map(|s| {
1743            s.parse().map_err(|e| SourceError::Malformed {
1744                message: format!("invalid {k}: {e}"),
1745            })
1746        })
1747        .transpose()
1748}
1749fn map_task(v: &Value) -> Result<Task, SourceError> {
1750    let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1751    let repositories = Repository::from_metadata(&metadata)
1752        .map_err(|message| SourceError::Malformed { message })?;
1753    let url = optional_string(v, "url")?;
1754    Ok(Task {
1755        id: NativeId(str_at(v, "id")?.into()),
1756        title: str_at(v, "title")?.into(),
1757        content,
1758        status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
1759            message: "missing state".into(),
1760        })?)?,
1761        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1762            message: "missing labels".into(),
1763        })?)?,
1764        project: filed_under(v)?,
1765        location: web_address(url.as_deref()),
1766        url,
1767        created_at: time(v, "createdAt")?,
1768        updated_at: time(v, "updatedAt")?,
1769        metadata,
1770        repositories,
1771    })
1772}
1773fn map_project(v: &Value) -> Result<Project, SourceError> {
1774    let (content, metadata) = metadata_description(optional_string(v, "description")?)?;
1775    let repositories = Repository::from_metadata(&metadata)
1776        .map_err(|message| SourceError::Malformed { message })?;
1777    let url = optional_string(v, "url")?;
1778    Ok(Project {
1779        id: NativeId(str_at(v, "id")?.into()),
1780        title: str_at(v, "name")?.into(),
1781        content,
1782        status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
1783            message: "missing status".into(),
1784        })?)?,
1785        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
1786            message: "missing project labels".into(),
1787        })?)?,
1788        location: web_address(url.as_deref()),
1789        url,
1790        created_at: time(v, "createdAt")?,
1791        updated_at: time(v, "updatedAt")?,
1792        metadata,
1793        repositories,
1794    })
1795}
1796
1797/// Where a Linear entity is: the web address Linear itself reports for it, as a link.
1798///
1799/// Every issue, project and document of a Linear workspace has a page a person can open,
1800/// so this source says so for all three — the counterpart of a folder of Markdown
1801/// reporting the path of the file behind an item. A source that reported nothing here is
1802/// what leaves a reader holding an opaque id, and `None` is reserved for the case Linear
1803/// really did not say, which is not the same as saying the entity is nowhere.
1804fn web_address(url: Option<&str>) -> Option<Location> {
1805    url.map(|url| Location::Url(url.to_owned()))
1806}
1807
1808/// The project a Linear item is filed under, or `None` for one filed under nothing.
1809///
1810/// One reader for issues and documents alike, because the field is the same field: an
1811/// absent `project` key is a malformed response, a null one is an orphan.
1812fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
1813    match v.get("project") {
1814        None => Err(SourceError::Malformed {
1815            message: "missing project field".into(),
1816        }),
1817        Some(Value::Null) => Ok(None),
1818        Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
1819    }
1820}
1821
1822fn map_document(v: &Value) -> Result<Document, SourceError> {
1823    let (content, metadata) = metadata_description(optional_string(v, "content")?)?;
1824    let repositories = Repository::from_metadata(&metadata)
1825        .map_err(|message| SourceError::Malformed { message })?;
1826    let url = optional_string(v, "url")?;
1827    Ok(Document {
1828        id: NativeId(str_at(v, "id")?.into()),
1829        title: str_at(v, "title")?.into(),
1830        content,
1831        project: filed_under(v)?,
1832        // Linear's `Document` carries no labels, and that is the published schema rather
1833        // than a gap here: the types of it that carry `labels` are `Issue`, `Project`,
1834        // `Team`, `Initiative` and `Organization`. Reporting none is what a source with no
1835        // native slot owes; standing one up beside a first-class type is what this source
1836        // exists not to do, and `write_document` refuses a label by name for the same
1837        // reason rather than dropping it.
1838        labels: Vec::new(),
1839        location: web_address(url.as_deref()),
1840        url,
1841        created_at: time(v, "createdAt")?,
1842        updated_at: time(v, "updatedAt")?,
1843        metadata,
1844        repositories,
1845    })
1846}
1847
1848/// Whether this document satisfies the predicates this source applies to a fetched page.
1849///
1850/// Two of them reach a page rather than the `documents(filter:)` variables, and each for a
1851/// reason of Linear's own. `DocumentFilter.project` is a `ProjectFilter` where
1852/// `IssueFilter.project` is a `NullableProjectFilter`, so only the issue side can be asked
1853/// for the items belonging to no project. And a Linear document carries no label at all,
1854/// so a query demanding one keeps nothing and a query excluding one keeps everything —
1855/// which is this source *applying* the predicate it declares native, over the labels the
1856/// document really has, rather than ignoring it.
1857fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
1858    let carries = |name: &String| {
1859        document
1860            .labels
1861            .iter()
1862            .any(|label| label.name.eq_ignore_ascii_case(name))
1863    };
1864    let filed = match project {
1865        ProjectFilter::Any => true,
1866        ProjectFilter::Orphans => document.project.is_none(),
1867        ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
1868    };
1869    filed
1870        && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
1871        && labels.all_of.iter().all(&carries)
1872        && !labels.none_of.iter().any(&carries)
1873}
1874
1875fn optional<T>(
1876    d: &Value,
1877    k: &str,
1878    f: fn(&Value) -> Result<T, SourceError>,
1879) -> Result<Option<T>, SourceError> {
1880    match d.get(k) {
1881        None => Err(SourceError::Malformed {
1882            message: format!("missing {k}"),
1883        }),
1884        Some(Value::Null) => Ok(None),
1885        // An item Linear no longer shows is not an item this source holds, and Linear says
1886        // so with `archivedAt` rather than by answering null.
1887        //
1888        // **None of Linear's three `delete` verbs removes anything.** `issueDelete`,
1889        // `projectDelete` and `documentDelete` move the item to the trash: observed on
1890        // 2026-09-04, each answered `success: true` and the item still read back by id,
1891        // carrying `archivedAt` and `trashed: true`. Its separate *archive* verb is a third
1892        // state — `archivedAt` set, `trashed` null — and Linear excludes both from every
1893        // connection, so `issues`, `projects` and `documents` had already stopped returning
1894        // them while a read by id still did.
1895        //
1896        // `archivedAt` rather than `trashed` for exactly that reason: it is the marker both
1897        // states share, so a read by id answers what a listing answers, and a delete means
1898        // what a copy's undo needs it to mean — the item this run created is gone.
1899        Some(value) if !matches!(value.get("archivedAt"), None | Some(Value::Null)) => Ok(None),
1900        Some(value) => f(value).map(Some),
1901    }
1902}
1903fn connection<T>(
1904    d: &Value,
1905    k: &str,
1906    f: fn(&Value) -> Result<T, SourceError>,
1907) -> Result<Page<T>, SourceError> {
1908    let c = d.get(k).ok_or_else(|| SourceError::Malformed {
1909        message: format!("missing {k} connection"),
1910    })?;
1911    let items = c
1912        .get("nodes")
1913        .and_then(Value::as_array)
1914        .ok_or_else(|| SourceError::Malformed {
1915            message: "missing nodes".into(),
1916        })?
1917        .iter()
1918        .map(f)
1919        .collect::<Result<_, _>>()?;
1920    let next = page_next(c)?;
1921    Ok(Page { items, next })
1922}
1923#[derive(Clone, Copy)]
1924enum DependencyRoot {
1925    Issue,
1926    Project,
1927}
1928impl DependencyRoot {
1929    const fn item_kind(self) -> ItemKind {
1930        match self {
1931            Self::Issue => ItemKind::Task,
1932            Self::Project => ItemKind::Project,
1933        }
1934    }
1935    const fn as_str(self) -> &'static str {
1936        match self {
1937            Self::Issue => "issue",
1938            Self::Project => "project",
1939        }
1940    }
1941}
1942fn relation_page(
1943    d: &Value,
1944    root: DependencyRoot,
1945    id: &NativeId,
1946    direction: Direction,
1947) -> Result<Page<DependencyEdge>, SourceError> {
1948    let key = if direction == Direction::DependsOn {
1949        "relations"
1950    } else {
1951        "inverseRelations"
1952    };
1953    let c = d
1954        .get(root.as_str())
1955        .and_then(|v| v.get(key))
1956        .ok_or_else(|| SourceError::Malformed {
1957            message: format!("missing {key}"),
1958        })?;
1959    let nodes = c
1960        .get("nodes")
1961        .and_then(Value::as_array)
1962        .ok_or_else(|| SourceError::Malformed {
1963            message: "missing relation nodes".into(),
1964        })?;
1965    let mut items = Vec::new();
1966    for n in nodes {
1967        let other = n
1968            .get(if direction == Direction::DependsOn {
1969                "relatedIssue"
1970            } else {
1971                "issue"
1972            })
1973            .or_else(|| {
1974                n.get(if direction == Direction::DependsOn {
1975                    "relatedProject"
1976                } else {
1977                    "project"
1978                })
1979            })
1980            .and_then(|v| v.get("id"))
1981            .and_then(Value::as_str)
1982            .ok_or_else(|| SourceError::Malformed {
1983                message: "missing related id".into(),
1984            })?;
1985        let (from, to) = if direction == Direction::DependsOn {
1986            (id.clone(), NativeId(other.into()))
1987        } else {
1988            (NativeId(other.into()), id.clone())
1989        };
1990        // 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 deliberately rejects every undocumented value, and real-HTTP tests prove both accepted values and rejection.
1991        let relation_type =
1992            n.get("type")
1993                .and_then(Value::as_str)
1994                .ok_or_else(|| SourceError::Malformed {
1995                    message: "missing relation type".into(),
1996                })?;
1997        // An issue relation and a project relation do not share a vocabulary. Linear
1998        // spells a project dependency `dependency`, where an issue's is `blocks`; the
1999        // write side sends exactly that pair and says why. So each root reads only its
2000        // own, and a value the other root would have accepted is refused here rather than
2001        // read as an edge this source could not have written.
2002        //
2003        // `related` is one of those values, and only an issue relation has it. Linear's
2004        // validator enumerates a project relation's `type` as `dependency` alone — see
2005        // the write side, which had `related` refused by the real API on 2026-09-04 — so
2006        // a project relation typed `related` is not a relation this workspace can hold.
2007        let kind = match (root, relation_type) {
2008            (DependencyRoot::Issue, "blocks") | (DependencyRoot::Project, "dependency") => {
2009                DependencyKind::Blocks
2010            }
2011            (DependencyRoot::Issue, "related") => DependencyKind::Related,
2012            _ => {
2013                return Err(SourceError::Malformed {
2014                    message: format!(
2015                        "invalid relation type: {relation_type} on a {} relation",
2016                        root.as_str()
2017                    ),
2018                });
2019            }
2020        };
2021        // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2022        let item_kind = root.item_kind();
2023        items.push(DependencyEdge {
2024            from: DependencyEndpoint::from_native(from, item_kind),
2025            to: DependencyEndpoint::from_native(to, item_kind),
2026            kind,
2027        });
2028    }
2029    let next = page_next(c)?;
2030    Ok(Page { items, next })
2031}
2032
2033fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
2034    match v.get(k) {
2035        None => Err(SourceError::Malformed {
2036            message: format!("missing field {k}"),
2037        }),
2038        Some(Value::Null) => Ok(None),
2039        Some(value) => value
2040            .as_str()
2041            .map(Some)
2042            .ok_or_else(|| SourceError::Malformed {
2043                message: format!("field {k} is not a string"),
2044            }),
2045    }
2046}
2047
2048/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment
2049/// at the end of `description`; its later write side must use this exact encoding.
2050const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2051const METADATA_CLOSE: &str = "\n-->";
2052
2053fn metadata_description(
2054    description: Option<String>,
2055) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
2056    let Some(description) = description else {
2057        return Ok((None, Default::default()));
2058    };
2059    let Some(start) = description.rfind(METADATA_OPEN) else {
2060        return Ok((Some(description), Default::default()));
2061    };
2062    let encoded_start = start + METADATA_OPEN.len();
2063    let Some(relative_end) = description[encoded_start..].find(METADATA_CLOSE) else {
2064        return Err(SourceError::Malformed {
2065            message: "unterminated onetaskgraph metadata slot in Linear description".into(),
2066        });
2067    };
2068    let encoded_end = encoded_start + relative_end;
2069    if !description[encoded_end + METADATA_CLOSE.len()..]
2070        .trim()
2071        .is_empty()
2072    {
2073        return Ok((Some(description), Default::default()));
2074    }
2075    let metadata =
2076        serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
2077            SourceError::Malformed {
2078                message: format!(
2079                    "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
2080                ),
2081            }
2082        })?;
2083    let visible = description[..start].trim_end();
2084    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2085}
2086
2087fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
2088    Ok(optional_str(v, k)?.map(Into::into))
2089}
2090fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2091    let id = str_at(value, field)?;
2092    (!id.is_empty())
2093        .then_some(id)
2094        .ok_or_else(|| SourceError::Malformed {
2095            message: format!("field {field} is an empty backend id"),
2096        })
2097}
2098fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
2099    let root = root.as_str();
2100    let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
2101        message: format!("missing {root}"),
2102    })?;
2103    match payload.get("success").and_then(Value::as_bool) {
2104        Some(true) => Ok(payload),
2105        Some(false) => Err(SourceError::Refused {
2106            message: format!("Linear reported {root} was unsuccessful"),
2107        }),
2108        None => Err(SourceError::Malformed {
2109            message: format!("missing boolean {root}.success"),
2110        }),
2111    }
2112}
2113fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
2114    let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
2115        message: "missing pageInfo".into(),
2116    })?;
2117    let more = info
2118        .get("hasNextPage")
2119        .and_then(Value::as_bool)
2120        .ok_or_else(|| SourceError::Malformed {
2121            message: "missing boolean pageInfo.hasNextPage".into(),
2122        })?;
2123    if !more {
2124        return Ok(None);
2125    }
2126    let cursor = str_at(info, "endCursor")?;
2127    Ok(Some(Cursor(cursor.into())))
2128}