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