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