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` | **Supported and proven.** `issues(filter:{priority:{in:[…]}})` over Linear's own `0`–`4` scale, confirmed against each issue read. |
33//! | `filter_by_comment_activity` | **Supported and proven.** `comments:{some:{or:[{createdAt:{gte:…}},{updatedAt:{gte:…}}]}}` — the issues with a comment created or last edited at or after the instant, over the same two fields a comment read reports. |
34//! | `filter_by_metadata` | **Supported and proven.** `description:{contains:"\"<value>\""}` for each match — the value as every JSON encoder writes it, which a slot holding it contains however it spaces or spells its keys — and every candidate confirmed over the parsed slot, so prose carrying the phrase and a slot holding another value are both kept out. A value with a character an encoder may escape is not sent, and the confirmation decides alone. |
35//! | `filter_by_origin` | **Supported and proven,** on the same terms, for the slot's `onetaskgraph.origin`. |
36//! | `orphan_tasks` | **Supported and proven.** `issues(filter:{project:{null:true}})`. |
37//! | `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`. |
38//! | `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. |
39//! | `search_title` | **Supported and proven.** A task query's text is `title:{containsIgnoreCase:…}`, every candidate confirmed by the contract's case-insensitive substring rule; a project or document query's text is applied by that same rule over the page Linear answered. |
40//! | `search_content` | **Supported and proven,** on the same terms, `description:{containsIgnoreCase:…}`, confirmed over the visible content — the trailing metadata slot Linear's comparator also reads is not part of what the rule confirms. A `title-or-content` search sends the two under one `or`. |
41//! | `task_dependencies` | **Supported and proven,** in both directions: `relations` and `inverseRelations`. |
42//! | `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. |
43//! | `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`]. |
44//!
45//! ## Ruling: the follow-up searches are native, and what each rests on
46//!
47//! Six predicates a follow-up search sends — metadata, origin, comment activity, priority,
48//! and the two text searches — are each sent to Linear as a narrowing of `issues(filter:)`
49//! and confirmed in process before a row is returned. Each narrowing is a candidate set that
50//! cannot miss a row the contract's predicate keeps, which is what makes sending it sound;
51//! the confirmation is what makes the answer exact. Every member below is pinned in
52//! `tests/fixtures/schema.graphql`, and each rests on one observation of the real API,
53//! against the scratch team `TES` on 2026-10-02, which `drive_follow_ups` in `tests/live.rs`
54//! asserts again — the comparators by name, and every narrowing through this source with a
55//! decoy whose prose carries the searched phrases:
56//!
57//! - **`IssueFilter.description.contains` reads the whole stored description, the metadata
58//!   slot included, and is case-sensitive.** An issue whose slot held
59//!   `"caller.key":"needle-…"` was returned for `contains` of that exact phrase, and of the
60//!   `"onetaskgraph.origin":"…"` pair beside it; the same description's prose, upper-cased,
61//!   was returned for `containsIgnoreCase` and *not* for `contains`. So a metadata match or an
62//!   origin is sent as its value in quotes, `"<value>"` — the bytes any JSON encoder writes a
63//!   string as, which a slot holding it contains whether it is the code span this source
64//!   writes, the multi-line slot it wrote before, or one a person spaced by hand — and
65//!   confirmed over the parsed slot. A value holding a character an encoder may escape is not
66//!   sent, and the confirmation decides alone.
67//! - **`IssueFilter.title.containsIgnoreCase` and `description.containsIgnoreCase` match
68//!   regardless of case** — the title `… Alpha Title` was returned for `alpha TITLE`, and not
69//!   for `contains` of it. They are the two text searches; the content search is confirmed
70//!   over the visible content, because the comparator also reads the slot.
71//! - **`IssueFilter.priority.in` narrows by Linear's own number** — an issue at `2` was
72//!   returned for `in:[2]` and not for `in:[3]`.
73//! - **`IssueFilter.comments.some` with `createdAt`/`updatedAt` `gte` narrows by a comment's
74//!   own times** — an issue whose comment had been edited a moment earlier was returned for an
75//!   instant before the edit and not for one a day later, and editing the comment moved its
76//!   `updatedAt` while leaving `createdAt`. The comment read reports those same two fields,
77//!   so the filter and the contract's rule read one value.
78//!
79//! ## Ruling: what Linear does to an HTML comment, settled
80//!
81//! A follow-up tool marks what it writes with HTML comments, and this source keeps its own
82//! metadata in one, so what survives is a fact this crate records rather than assumes.
83//! Observed on 2026-10-02 against the scratch team `TES`, each written and read back by id,
84//! and asserted again — the probe text and its stored form exactly — by `drive_follow_ups` in
85//! `tests/live.rs`, a leg of its `real_linear_applies_every_declared_capability_and_leaves_no_residue`:
86//!
87//! - **A comment's `body` keeps every HTML comment byte for byte** — on one line or across
88//!   several, a bare `-->` closing line, domain-like text and JSON included.
89//! - **An issue's `description` and a document's `content` do not.** Linear stores both as
90//!   Markdown and normalizes the text *inside* an HTML comment exactly as it normalizes prose:
91//!   a domain-like token or a URL is autolinked — `example.com` comes back
92//!   `[example.com](<http://example.com>)`, and a key such as `caller.live` likewise — `[` and
93//!   `]` come back `\[` and `\]`, `~` comes back `\~`, `_y_` comes back `*y*`, the backslash
94//!   of `\"` is dropped, a backslash before a letter is doubled, and a line opening `-->` comes
95//!   back `\-->`. Text with none of those in it — `{"k":"v"}`, `caller.key`, `sha256:…`,
96//!   `gh:I_kwDO…` — comes back as written. An autolink can run on past the token, swallowing
97//!   what follows it up to the next delimiter.
98//! - **One thing in those two fields comes back byte for byte: a code span.** An HTML comment
99//!   on one line whose payload is inside backticks — ``<!-- probe `{…}` -->`` — came back
100//!   identical with every one of the payloads above inside it, and re-writing what Linear
101//!   handed back changed nothing more. So this source writes its own slot that way (see
102//!   `METADATA_OPEN_SPAN`), and a marker meant to survive an issue's description or a
103//!   document's content belongs in one too.
104//!
105//! ## Ruling: a Linear document carries no label, and that is Linear's
106//!
107//! Unlike the two searches above, this one *is* a property of the remote service. The
108//! types of Linear's published schema carrying a `labels` field are `Issue`, `Project`,
109//! `Team`, `Initiative` and `Organization`; `Document` is not among them, re-observed
110//! 2026-09-01 and pinned in `tests/fixtures/schema.graphql`. So this source reports a
111//! document's labels as none and **refuses by name** a document write carrying one, rather
112//! than dropping it or standing a slot up beside a first-class type. The shared journey
113//! table's row says so, and the shared document journeys drive that claim.
114//!
115//! Two predicates therefore reach a fetched page rather than the `documents(filter:)`
116//! variables, and both are still *applied* — which is what `Native` means here, and why
117//! the declaration stays honest. Labels, for the reason above. And orphans, because
118//! `DocumentFilter.project` is a `ProjectFilter` where `IssueFilter.project` is a
119//! `NullableProjectFilter`: only the nullable one carries `null:`, so Linear cannot be
120//! asked for the documents belonging to no project. The page-by-page walk asks for only
121//! what is still owed, so neither predicate can make a read return more than the caller
122//! asked for, and neither can drop a document the walk already fetched.
123//!
124//! ## Ruling: a comment is read backwards, and its author is Linear's to record
125//!
126//! **The order.** The contract owes a task's comments oldest first, across pages, and Linear's
127//! `Issue.comments` takes no sort direction — only `orderBy`, whose members are `createdAt`
128//! (the default) and `updatedAt`. Linear's pagination documentation says results are "ordered
129//! by `createdAt`" and that "to get most recently updated resources, you can alternatively
130//! order by `updatedAt`", which reads that ordering as newest first. So this source walks the
131//! connection from its far end: `last` with `before`, each page reversed, the next page's
132//! cursor being `startCursor` while `hasPreviousPage` holds. Reversing within a page and
133//! walking backwards across them is what makes the whole walk oldest first rather than each
134//! page alone. **That direction is inferred from the documentation's wording rather than
135//! observed against the real API,** which is the one reading here a live run has not yet
136//! confirmed; if Linear is found to list oldest first, the correction is this walk's
137//! direction and nothing else.
138//!
139//! **The author.** Linear records the user whose credential made the request as a comment's
140//! author, and this source authenticates with an API key. `CommentCreateInput.createAsUser`
141//! exists but is, in Linear's own words, "only available to OAuth applications creating
142//! comments in `actor=app` mode", which a key is not. So a comment carrying an author is
143//! **refused before any request is sent**, naming why and what to do instead, rather than
144//! posted under a name other than the one it was given. An author read back is the user's
145//! `displayName`, which Linear keeps unique within a workspace, and is absent when Linear
146//! names no user — a comment an integration or a bot wrote.
147//!
148//! **What "no such comment" means.** An edit or a removal first asks `comment(id:)` which
149//! issue the comment is on, and answers "no such comment" — no mutation sent — unless it is
150//! the task's own issue: a comment on another issue, on no issue at all, or trashed, is not a
151//! comment this task has. The body is Linear's `body`, which its schema describes as markdown
152//! derived from a rich-text document, so what an add or an edit answers with is what Linear
153//! now holds rather than an echo of what was sent.
154//!
155//! ## Ruling: a project's filter is not an issue's, and neither is its status
156//!
157//! Linear's `IssueFilter` and `ProjectFilter` read as one filter over two kinds of row.
158//! They are two input types, and this source built one object for both until 2026-09-04,
159//! which put two members into `projects(filter:)` that Linear does not have there. It
160//! refused the first outright — `Field "team" is not defined by type "ProjectFilter". Did
161//! you mean "lead"?` — and would have refused the second next.
162//!
163//! A project has no team; it has the teams it is accessible from, so the configured team
164//! reaches `accessibleTeams:{some:{key:{eqIgnoreCase:…}}}`. And a project's status is not
165//! an issue's state: the counterpart of `IssueFilter.state` is `ProjectFilter.status`,
166//! while `ProjectFilter.state` exists and is a bare `StringComparator` over something else.
167//! The two do not even share a vocabulary — `ProjectStatus.type` is the `ProjectStatusType`
168//! enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`, where a
169//! workflow state is `backlog`, `unstarted`, `started`, `completed`, `canceled`, `triage`.
170//! So `planned` is where `unstarted` would be, `paused` reads as in progress and has no
171//! issue counterpart, and a filter spelled in the other level's words matches nothing while
172//! being refused by nothing.
173//!
174//! **Neither of those could be caught by reading a document, and that is the general
175//! lesson.** A filter is built at runtime and handed over as `$filter`, so it appears in no
176//! operation this crate declares, and the two pinned-schema checks that parse those
177//! operations could not see it — Linear was the only reader, one refusal per round trip.
178//! `every_variables_object_this_source_sends_conforms_to_the_pinned_schema` closes that:
179//! it drives this source's whole surface, records what really went out, and walks every
180//! variables object against the pinned type of the argument it stands at.
181//!
182//! ## Ruling: a Linear project relation is always an ordering
183//!
184//! This one is Linear's too, and the validator says so in as many words. Asked on
185//! 2026-09-04 for a project relation typed `related` — and separately `blocks` and
186//! `dependsOn` — the real API refused each with `Argument Validation Error` and
187//! `constraints: {"isEnum": "type must be one of the following values: dependency"}`. That
188//! enumeration has one member and it is a timeline dependency, which is why the input
189//! carries an anchor at each end at all.
190//!
191//! So a project edge carrying no ordering has nowhere here to land, and this source
192//! **refuses it by name** before the write rather than sending a value Linear will reject
193//! or quietly promoting it to a dependency it does not mean. `DependencyKind::Related`
194//! keeps its issue-level spelling, `related`, because `IssueRelationCreateInput` really
195//! does take it: the two relations are different relations with different vocabularies,
196//! and each level's read accepts only its own.
197//!
198//! Which end of a project relation waits is carried by the two anchors and not by the two
199//! id slots — measured, not reasoned, from Linear's own `ProjectFilter.hasBlockedByRelations`
200//! against relations written both ways round. `tests/fixtures/README.md` records the whole
201//! probe, and `write_relations` records why the pair this source sends is the oriented one.
202//!
203//! Caller metadata is canonical JSON in a trailing
204//! ``<!-- onetaskgraph.metadata `…` -->`` Markdown comment in the item's description, on one
205//! line with the JSON in a code span — the one spelling Linear keeps byte for byte, see the
206//! ruling above; the multi-line spelling this source wrote before is still read. The visible
207//! description is returned unchanged without that slot. Writes put the same canonical
208//! encoding back beside the visible description, and use Linear issue/project relations for
209//! same-source dependencies. Only cross-source far ends use the reserved
210//! `onetaskgraph.depends_on` metadata key.
211//!
212//! ## Ruling: a status is written by `status_mapping`, and by type where it names none
213//!
214//! A Linear team can hold several workflow states of one type — `Todo` and `Queued` are both
215//! `unstarted`, `Proposed` and `Backlog` both `backlog` — and a category says nothing about
216//! which of them it means. `status_mapping` is what does: a category it names is written as
217//! the state of exactly that name by every write — `set_task_status`, the targeted update and
218//! `write_task`, so `task create` and every copy — and never as another state of the same
219//! type. A name the team does not have is refused before any write, naming the state, the team
220//! and the category, after one read of the team's states through
221//! [`graphql::TEAM_WORKFLOW_STATES`]. A category it sets to `null` is refused as disabled
222//! before any request. Two categories mapped to one name are refused when the configuration is
223//! read, because that state could read back as only one of them.
224//!
225//! On a read, an issue at a state the mapping names is that category under the state's own
226//! name; every other state reads by its type, as it always has — the review states only
227//! people write, `Triage` among them, which nothing here ever writes. `filter_by_status`
228//! returns exactly the issues whose status reads as each category asked for: those at the
229//! state the mapping names for it, and those at an unmapped state whose type falls back to
230//! it — never one at a state mapped to another category. So `--status queued` returns the
231//! issues at `Queued` and never one at `Todo`; `--status in-progress` returns those at
232//! `In Progress` and at an unmapped `started` state such as `In Review`, and never one at
233//! `Needs Attention` when that is mapped to `unknown`; and `--status unknown` also returns
234//! those at a state of a type none of the five categories stands for, `Triage` among them.
235//!
236//! A category the mapping does not mention keeps the behaviour of a source without the key
237//! exactly: `set_task_status` and the targeted update write the team's first state of the
238//! type `workflow_state_types` gives — `backlog`, `todo`, `in-progress`, `done` and
239//! `cancelled` — and refuse `draft`, `queued` and `unknown`, which no type stands for, before
240//! any request; `write_task` resolves the state by the status's own name, as it always has. A
241//! task already in the category asked for keeps the state it is in and nothing is written:
242//! moving an issue from `In Review` to `In Progress` because it was asked to be in progress is
243//! a change nobody asked for.
244//!
245//! ## Ruling: `project` scopes a source to one project
246//!
247//! With `project` set, every issue read carries `project:{id:{eq:…}}` beside the team, a
248//! project read carries `id:{eq:…}` and a document read the same project, and a read by id
249//! of anything filed elsewhere answers as no such item — so a status, a content, a metadata
250//! or a comment write to it is answered the same way. A task or a document written with no
251//! project is placed in that one, and one naming another is refused naming both. A project
252//! write other than to that project itself is refused: a project this source created would be
253//! one none of its reads could find.
254//!
255//! ## Ruling: a narrow metadata write moves only the slot, and a task carries delivery
256//!
257//! `set_task_metadata`, `set_project_metadata` and `set_document_metadata` read the item and
258//! send one update of its long-form field — `description`, `description` and `content` —
259//! that differs from what Linear holds only inside the trailing metadata slot: every byte
260//! above it is kept as it was. A key already holding the value sends nothing. The answer is
261//! the item read back. `set_task_rendering` and `set_document_rendering` replace the content
262//! and the slot's `onetaskgraph.template` entry together in one such update, every other slot
263//! entry kept; this source keeps no template answers. So a copy's `onetaskgraph.copies` link
264//! is recorded on a Linear item rather than reported unrecorded.
265//!
266//! A task's `delivers` and `delivered_by` live in that same slot under
267//! `onetaskgraph.delivers` and `onetaskgraph.delivered_by`, each a list of qualified ids,
268//! with the shape and the rules the GitHub Projects plugin keeps: neither may name the task
269//! itself or name one task twice, which is refused by name before anything is sent; a write
270//! lands the typed lists in place of any caller metadata of those names; and
271//! `set_delivered_by` is one update of the slot. A project or a document naming either key is
272//! refused, because only a task delivers or is delivered.
273//!
274//! ## Ruling: a priority is Linear's own, and content shares a field with the slot
275//!
276//! A task's priority is `Issue.priority`, on Linear's scale: `0` none, `1` urgent, `2` high,
277//! `3` normal — this contract's `medium` — and `4` low. Linear declares the field `Float!`
278//! while `IssueCreateInput.priority` and `IssueUpdateInput.priority` are `Int`, so a read
279//! accepts `2` and `2.0` alike and refuses anything that is not one of the five as a
280//! malformed response naming the field. A copy sends it on a create and on an update, `0`
281//! included, so a task moved back to no priority is not left holding its old one.
282//! `set_task_priority` reads the issue first — no such issue, or a trashed one, is `None`
283//! with nothing written — then sends `issueUpdate` with `priority` alone and answers with
284//! the priority the mutation's own payload reports.
285//!
286//! `set_task_content` sends `issueUpdate` with `description` alone, and that description is
287//! the given content followed by the issue's metadata slot exactly as it was stored, so the
288//! slot, and every key in it, is untouched. What a later read reports as the content is the
289//! given bytes, trailing whitespace included: a read of an issue carrying a slot takes off only
290//! the one blank line that sets the slot off, and a write whose content would not read back as
291//! itself is refused before it is sent.
292//!
293//! Fixture provenance is recorded in `tests/fixtures/README.md`. The live journey in
294//! `tests/live.rs` drives every field of the table above against Linear itself: it builds its own fixture
295//! on the scratch team `LINEAR_WRITE_TEAM` names — two projects, one issue filed under
296//! each, one filed under neither, two labels and two workflow states — because that shape
297//! is what tells an honoured predicate from an ignored one, and a workspace where every
298//! issue carries the label answers a filter the same way either way. Everything the lane
299//! creates it deletes whether its assertions passed or failed, and it clears residue named
300//! the way it names its own before it starts. A failed live cleanup is reported as a test
301//! failure and may require manual deletion from that scratch team.
302#![deny(missing_docs)]
303
304use chrono::{DateTime, Utc};
305use onetaskgraph_plugin_api::{
306    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
307    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
308    LabelFilter, Location, MetadataKey, NativeId, NewComment, Page, PageRequest, Priority, Project,
309    ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin,
310    Status, StatusCategory, Support, Task, TaskQuery, TaskRef, TaskSource, TaskUpdate,
311    TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
312};
313use schemars::{Schema, schema_for};
314use secrecy::{ExposeSecret, SecretString};
315use serde::Deserialize;
316use serde_json::{Value, json};
317
318/// The plugin kind a `linear` source's `plugin:` field names.
319pub const KIND: &str = "linear";
320
321/// The largest page this source will ask Linear for, and the capability it declares.
322///
323/// **Not Linear's connection maximum, which is 250, because a connection maximum is not
324/// the only thing bounding a page.** Linear also scores each document for complexity and
325/// refuses one over 10000 with HTTP 400 and `The query is too complex.` — and the
326/// `projects` document this source sends scores 17475 at `first: 250`, because its nested
327/// `labels` connection, which names no `first` of its own, is charged Linear's default of
328/// 50 per node. Measured against the real API on 2026-09-04: the largest `first` that
329/// document is accepted at is **143**, exactly, and the filter it carries adds nothing.
330/// The `issues` document is accepted at 250, so this is the tighter of the two and a
331/// single declared maximum has to be the tighter one.
332///
333/// 100 rather than 143 because 143 is the cliff. A field added to either selection moves
334/// it, and a page size chosen at the edge of a budget nobody here controls fails in the
335/// live lane rather than in a check. This leaves 30% of the budget spare.
336///
337/// Nothing offline can hold this: complexity is scored by Linear's own runtime and appears
338/// in no schema, so `every_variables_object_this_source_sends_conforms_to_the_pinned_schema`
339/// cannot see it. What guards it is the live journey, which walks a real `projects` page at
340/// exactly this size.
341pub const MAX_PAGE_SIZE: u32 = 100;
342const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
343
344/// Exact GraphQL query documents issued by this plugin.
345///
346/// Fixture servers consume these constants so their recognized contract cannot drift
347/// from the production requests.
348pub mod graphql {
349    /// Check the authenticated viewer.
350    pub const VIEWER: &str = "query { viewer { id } }";
351    /// Fetch one issue.
352    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} } }";
353    /// Fetch one project.
354    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}} } }";
355    /// List issues.
356    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} } }";
357    /// List projects.
358    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} } }";
359    /// List issue labels.
360    pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
361    /// Fetch issue dependency relations.
362    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}} } }";
363    /// Fetch project dependency relations.
364    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}} } }";
365    /// Resolve the configured team key to Linear's backend id.
366    pub const TEAM: &str =
367        "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
368    /// Resolve an issue workflow-state display name.
369    ///
370    /// `$team` is an `ID!` and `$name` a `String!` because that is what each one's
371    /// *location* declares, not because of what this source passes: both carry a Linear
372    /// identifier string. `WorkflowStateFilter.team` is a `NullableTeamFilter`, whose `id`
373    /// is an `IDComparator`, whose `eq` is an `ID`; the sibling `name` reaches a
374    /// `StringComparator.eqIgnoreCase`, which is a `String`.
375    ///
376    /// That distinction is what the live lane was refused for on 2026-09-04, with HTTP 400
377    /// and `Variable "$team" of type "String!" used in position expecting type "ID".`
378    /// GraphQL admits a variable at a location only when the variable's type is the
379    /// location's type or that type's non-null form, and `String` is not `ID` however the
380    /// value is spelled — so `String!` there fails validation before any field is read,
381    /// while `ID!` is the non-null form of the location's own type and is accepted.
382    ///
383    /// It reached Linear because a variable inside an inline filter literal is not a root
384    /// argument, and the pinned-schema checks only compared root arguments. They now walk
385    /// into these literals too, so this class of drift fails here rather than in the live
386    /// lane.
387    pub const ISSUE_STATE: &str = "query($name:String!,$team:ID!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
388    /// Find the configured team's workflow states of one `WorkflowState.type`, so a task's
389    /// status can be set by category alone.
390    ///
391    /// `name` is selected beside `id` because the status a narrow status write answers with
392    /// is the one Linear now holds, and a category alone does not say which of the team's
393    /// states of that type it is. `$type` is a `String!` at `StringComparator.eq`, which is a
394    /// `String`, and `$team` an `ID!` for the reason recorded on [`ISSUE_STATE`].
395    pub const ISSUE_STATE_OF_TYPE: &str = "query($type:String!,$team:ID!){ workflowStates(filter:{type:{eq:$type},team:{id:{eq:$team}}}){nodes{id name}} }";
396    /// Every workflow state of the configured team, with its type.
397    ///
398    /// What a category `status_mapping` names is written through — the state is found by its
399    /// name here, so a name the team lacks is refused naming the state, the team and the
400    /// category rather than as an unexplained empty lookup — and what `sources fields` reports
401    /// each mapped state against. A team holds a few dozen states at most, inside the one page
402    /// Linear answers an unpaged connection with. `$team` is an `ID!` for the reason recorded
403    /// on [`ISSUE_STATE`].
404    pub const TEAM_WORKFLOW_STATES: &str =
405        "query($team:ID!){ workflowStates(filter:{team:{id:{eq:$team}}}){nodes{id name type}} }";
406    /// List the workspace's project statuses, so one can be resolved by display name.
407    ///
408    /// Unlike `teams`, `workflowStates` and the two label connections, Linear's
409    /// `projectStatuses` accepts no `filter` argument: asking for one is refused outright
410    /// with `Unknown argument "filter" on field "Query.projectStatuses"`. The display name
411    /// is therefore matched locally over the whole connection, which a workspace holds few
412    /// enough of to answer in one page.
413    // llmlint: ignore[changed_behavior_has_e2e] The uncovered case the rule names — a status
414    // on a later page — is not a test that is missing but a document this repository has no
415    // evidence Linear would accept: `tests/fixtures/schema.graphql` pins `after` alone,
416    // because Linear's own refusal is where that correction came from, and its
417    // `ProjectStatusConnection` declares `nodes` and no `pageInfo`. Selecting a cursor field
418    // to page on would fail `pinned_schema_checks_selected_fields_arguments_and_fixture_keys`
419    // here and risk, against Linear, the same `GRAPHQL_VALIDATION_FAILED` this document was
420    // changed to stop sending. Reading one page is not what changed either: `teams`,
421    // `workflowStates` and `projectLabels` resolve a display name through the same `one_id`
422    // over the same unpaged connections, and did before this change. What did change is
423    // driven end to end — the CLI journey
424    // `linear_project_and_task_copies_write_native_relations_and_record_only_cross_source_edges`
425    // copies a project whose status is resolved this way, and
426    // `a_project_status_is_matched_locally_because_linear_narrows_that_connection_for_nobody`
427    // holds the match, the ambiguity and the absence against a real HTTP server.
428    pub const PROJECT_STATUS: &str = "query{ projectStatuses{nodes{id name}} }";
429    /// Resolve an issue-label display name.
430    pub const ISSUE_LABEL: &str =
431        "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
432    /// Resolve a project-label display name.
433    pub const PROJECT_LABEL: &str =
434        "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
435    /// Create an issue.
436    pub const ISSUE_CREATE: &str =
437        "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
438    /// Update an issue.
439    pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
440    /// Set an issue's priority on its own, and read back the priority Linear now holds.
441    ///
442    /// The same `issueUpdate` as [`ISSUE_UPDATE`], selecting `priority` in the payload
443    /// because a narrow priority write answers with what the source reads back rather than
444    /// an echo of what it sent. A document of its own rather than a wider [`ISSUE_UPDATE`],
445    /// so every other issue write keeps asking for exactly what it reads.
446    pub const ISSUE_PRIORITY_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id priority}} }";
447    /// Create a project.
448    pub const PROJECT_CREATE: &str =
449        "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
450    /// Update a project.
451    pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
452    /// Create a native issue dependency.
453    pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
454    /// Create a native project dependency.
455    pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
456    /// Delete a native issue dependency before replacing its full edge set.
457    pub const ISSUE_RELATION_DELETE: &str =
458        "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
459    /// Delete a native project dependency before replacing its full edge set.
460    pub const PROJECT_RELATION_DELETE: &str =
461        "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
462    /// Delete an issue, so a copy that could not finish can take back what it created.
463    pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
464    /// Delete a project, for the same reason and on the same terms.
465    pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
466    /// Fetch one document.
467    pub const DOCUMENT: &str = "query($id:String!){ document(id:$id){ id title content url createdAt updatedAt archivedAt project{id} } }";
468    /// List documents.
469    ///
470    /// `first` is an `Int` rather than an `Int!` because that is what Linear's `documents`
471    /// connection declares, unlike its `issues` one.
472    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} } }";
473    /// Create a document.
474    pub const DOCUMENT_CREATE: &str = "mutation($input:DocumentCreateInput!){ documentCreate(input:$input){success document{id}} }";
475    /// Update a document.
476    pub const DOCUMENT_UPDATE: &str = "mutation($id:String!,$input:DocumentUpdateInput!){ documentUpdate(id:$id,input:$input){success document{id}} }";
477    /// Delete a document, so a copy that could not finish can take back what it created.
478    pub const DOCUMENT_DELETE: &str = "mutation($id:String!){ documentDelete(id:$id){success} }";
479    /// One page of an issue's comments, walked backwards.
480    ///
481    /// `last`/`before` rather than `first`/`after`, and `pageInfo{hasPreviousPage
482    /// startCursor}` rather than its forward pair, because Linear lists a connection newest
483    /// first and the contract owes the oldest first — see the ruling on comments in this
484    /// crate's module documentation. `archivedAt` is selected for the reason every by-id read
485    /// here selects it: a trashed issue is not an issue this source holds.
486    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} } } }";
487    /// Place one comment: which issue it is on, if any.
488    ///
489    /// `$id` is a nullable `String` because that is what `Query.comment` declares — it also
490    /// takes a `hash` instead — and a variable has to be exactly its argument's type.
491    pub const COMMENT: &str = "query($id:String){ comment(id:$id){ id archivedAt issue{id} } }";
492    /// Add a comment to an issue.
493    pub const COMMENT_CREATE: &str = "mutation($input:CommentCreateInput!){ commentCreate(input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
494    /// Replace a comment's body.
495    pub const COMMENT_UPDATE: &str = "mutation($id:String!,$input:CommentUpdateInput!){ commentUpdate(id:$id,input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
496    /// Remove a comment.
497    pub const COMMENT_DELETE: &str = "mutation($id:String!){ commentDelete(id:$id){success} }";
498}
499
500use graphql::{
501    DOCUMENT, DOCUMENTS, ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS,
502    PROJECTS, VIEWER,
503};
504
505/// One `linear` source's configuration.
506///
507/// It names the credential's environment variable, never its value. Serializable so the
508/// schema it is published under carries each member's default, which is what a configuration
509/// that leaves the member out means.
510#[derive(Debug, Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
511#[serde(default, deny_unknown_fields)]
512pub struct LinearConfig {
513    /// Environment variable resolved by the host.
514    #[schemars(with = "String")]
515    api_key_env: EnvName,
516    /// Linear team key/id used to narrow reads and required for item writes.
517    team: Option<Team>,
518    /// GraphQL endpoint override, primarily for fixture servers.
519    #[schemars(with = "String")]
520    endpoint: Endpoint,
521    /// Per-instance mapping from a status category to the exact name of one workflow state
522    /// of the configured team, or `null` to disable that category.
523    ///
524    /// The keys are status categories: `draft`, `backlog`, `todo`, `queued`, `in-progress`,
525    /// `done`, `cancelled` and `unknown`. A mapped category is written as the named state by
526    /// every write — never as the first state of that state's type — and an issue at a state
527    /// the mapping names reads as that category, under that state's name; any other state
528    /// reads by its type, and `--status` returns exactly the issues that read as the
529    /// categories it names. A category this does not mention keeps the
530    /// behaviour of a source without the key: `backlog`, `todo`, `in-progress`, `done` and
531    /// `cancelled` are written as the team's first state of the matching type, and `draft`,
532    /// `queued` and `unknown` are disabled. A name the team lacks is refused before any
533    /// write, and two categories mapped to one name are refused when this configuration is
534    /// read.
535    #[schemars(schema_with = "status_mapping_schema")]
536    status_mapping: std::collections::HashMap<StatusCategory, Option<StateName>>,
537    /// The id of one Linear project of the configured team, scoping this source to it.
538    ///
539    /// When set, every task read is narrowed to that project's issues, project and document
540    /// reads return only that project and its documents, a task or a document written with
541    /// no project is placed in it, and one naming another project is refused. Absent, the
542    /// source reads and writes team-wide.
543    project: Option<ProjectScope>,
544}
545
546/// The schema of `status_mapping`: an object whose values are workflow state names or `null`,
547/// and whose keys are the contract's own `StatusCategory`.
548///
549/// Built here rather than derived, because the derived schema of a map says nothing of its
550/// keys; the key schema is the contract's own, referenced rather than restated.
551fn status_mapping_schema(generator: &mut schemars::SchemaGenerator) -> Schema {
552    let mut schema = <std::collections::HashMap<StatusCategory, Option<StateName>> as schemars::JsonSchema>::json_schema(generator);
553    schema.insert(
554        "propertyNames".to_owned(),
555        generator.subschema_for::<StatusCategory>().to_value(),
556    );
557    schema
558}
559
560/// The name of one workflow state of a Linear team.
561///
562/// Validated on the way in rather than checked later, so a blank name — which no workflow
563/// state can have — is a state this type cannot hold.
564#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize, schemars::JsonSchema)]
565#[serde(try_from = "String", into = "String")]
566#[schemars(rename = "LinearWorkflowStateName", extend("minLength" = 1))]
567struct StateName(String);
568impl From<StateName> for String {
569    fn from(value: StateName) -> Self {
570        value.0
571    }
572}
573impl TryFrom<String> for StateName {
574    type Error = String;
575    fn try_from(value: String) -> Result<Self, Self::Error> {
576        if value.trim().is_empty() {
577            Err("a status_mapping workflow state name cannot be blank".into())
578        } else {
579            Ok(Self(value))
580        }
581    }
582}
583
584/// The id of one Linear project, which a scoped source holds alone.
585#[derive(Debug, Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
586#[serde(try_from = "String", into = "String")]
587#[schemars(rename = "LinearProjectId", extend("minLength" = 1))]
588struct ProjectScope(String);
589impl From<ProjectScope> for String {
590    fn from(value: ProjectScope) -> Self {
591        value.0
592    }
593}
594impl TryFrom<String> for ProjectScope {
595    type Error = String;
596    fn try_from(value: String) -> Result<Self, Self::Error> {
597        if value.trim().is_empty() {
598            Err("a project id cannot be blank".into())
599        } else {
600            Ok(Self(value))
601        }
602    }
603}
604
605#[derive(Debug, Clone, Deserialize, serde::Serialize)]
606#[serde(try_from = "String", into = "String")]
607struct EnvName(String);
608impl From<EnvName> for String {
609    fn from(value: EnvName) -> Self {
610        value.0
611    }
612}
613impl TryFrom<String> for EnvName {
614    type Error = String;
615    fn try_from(value: String) -> Result<Self, Self::Error> {
616        let mut bytes = value.bytes();
617        if bytes
618            .next()
619            .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
620            && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
621        {
622            Ok(Self(value))
623        } else {
624            Err("must be an uppercase environment-variable name".into())
625        }
626    }
627}
628/// A Linear team's key or id.
629#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize, schemars::JsonSchema)]
630#[serde(try_from = "String", into = "String")]
631#[schemars(rename = "LinearTeam", extend("minLength" = 1))]
632struct Team(String);
633impl From<Team> for String {
634    fn from(value: Team) -> Self {
635        value.0
636    }
637}
638impl TryFrom<String> for Team {
639    type Error = String;
640    fn try_from(value: String) -> Result<Self, Self::Error> {
641        if value.trim().is_empty() {
642            Err("must not be empty".into())
643        } else {
644            Ok(Self(value))
645        }
646    }
647}
648#[derive(Debug, Clone, Deserialize, serde::Serialize)]
649#[serde(try_from = "String", into = "String")]
650struct Endpoint(String);
651impl From<Endpoint> for String {
652    fn from(value: Endpoint) -> Self {
653        value.0
654    }
655}
656impl TryFrom<String> for Endpoint {
657    type Error = String;
658    fn try_from(value: String) -> Result<Self, Self::Error> {
659        let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
660        if matches!(url.scheme(), "http" | "https") {
661            Ok(Self(value))
662        } else {
663            Err("must use http or https".into())
664        }
665    }
666}
667
668impl Default for LinearConfig {
669    fn default() -> Self {
670        Self {
671            api_key_env: EnvName("LINEAR_API_KEY".into()),
672            team: None,
673            endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
674            status_mapping: std::collections::HashMap::new(),
675            project: None,
676        }
677    }
678}
679
680/// Where `category` sits in the contract's own order — the order a mapping is reported in.
681///
682/// An exhaustive match, so a status category the contract adds fails to compile here rather
683/// than going unordered, and the order is the contract's rather than a list restated beside it.
684const fn category_position(category: StatusCategory) -> usize {
685    match category {
686        StatusCategory::Draft => 0,
687        StatusCategory::Backlog => 1,
688        StatusCategory::Todo => 2,
689        StatusCategory::Queued => 3,
690        StatusCategory::InProgress => 4,
691        StatusCategory::Done => 5,
692        StatusCategory::Cancelled => 6,
693        StatusCategory::Unknown => 7,
694    }
695}
696
697/// A `WorkflowState.type` a status category is written as when the mapping names no state for
698/// it — the closed set this source writes, which Linear's open vocabulary of read types
699/// (`triage`, `duplicate`, …) is wider than.
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701enum WorkflowType {
702    Backlog,
703    Unstarted,
704    Started,
705    Completed,
706    Canceled,
707}
708
709impl WorkflowType {
710    /// Every type this source writes, which is every type that reads as a category other than
711    /// `unknown`: a state of any other type — `triage`, `duplicate`, or one Linear adds — reads
712    /// as `unknown`. Held complete by the assertion beside [`Self::position`].
713    const ALL: [Self; 5] = [
714        Self::Backlog,
715        Self::Unstarted,
716        Self::Started,
717        Self::Completed,
718        Self::Canceled,
719    ];
720
721    /// Where this type sits in [`Self::ALL`] — an exhaustive match, so a type added here and
722    /// left out of that list fails to compile.
723    const fn position(self) -> usize {
724        match self {
725            Self::Backlog => 0,
726            Self::Unstarted => 1,
727            Self::Started => 2,
728            Self::Completed => 3,
729            Self::Canceled => 4,
730        }
731    }
732
733    /// The type a category reads back as and is written as, or `None` for one no type stands
734    /// for — `draft`, `queued` and `unknown`.
735    const fn of(category: StatusCategory) -> Option<Self> {
736        match category {
737            StatusCategory::Backlog => Some(Self::Backlog),
738            StatusCategory::Todo => Some(Self::Unstarted),
739            StatusCategory::InProgress => Some(Self::Started),
740            StatusCategory::Done => Some(Self::Completed),
741            StatusCategory::Cancelled => Some(Self::Canceled),
742            StatusCategory::Draft | StatusCategory::Queued | StatusCategory::Unknown => None,
743        }
744    }
745
746    /// The type as Linear spells it.
747    const fn as_str(self) -> &'static str {
748        match self {
749            Self::Backlog => "backlog",
750            Self::Unstarted => "unstarted",
751            Self::Started => "started",
752            Self::Completed => "completed",
753            Self::Canceled => "canceled",
754        }
755    }
756}
757
758const _: () = {
759    let mut index = 0;
760    while index < WorkflowType::ALL.len() {
761        assert!(WorkflowType::ALL[index].position() == index);
762        index += 1;
763    }
764};
765
766/// Where one category is written, as this instance's `status_mapping` resolves it.
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768enum StateTarget<'a> {
769    /// The team's workflow state of exactly this name.
770    Named(&'a StateName),
771    /// The team's first workflow state of this type: a category the mapping does not mention,
772    /// as a source without the key writes it.
773    FirstOfType(WorkflowType),
774    /// No state, because the mapping sets the category to `null`.
775    DisabledByMapping,
776    /// No state, because the mapping leaves out a category no workflow state type stands for.
777    NoWorkflowType,
778}
779
780/// This instance's `status_mapping`, read in both directions.
781///
782/// One entry per category the configuration names, at most once each and in the contract's
783/// category order, and no two of them naming one state — a state that read back as two
784/// categories would answer a status filter for either with the other's rows.
785#[derive(Debug, Clone, Default)]
786struct StatusMapping {
787    entries: Vec<(StatusCategory, Option<StateName>)>,
788}
789
790impl StatusMapping {
791    fn resolve(
792        configured: std::collections::HashMap<StatusCategory, Option<StateName>>,
793        instance: &SourceName,
794    ) -> Result<Self, SourceError> {
795        let mut configured: Vec<_> = configured.into_iter().collect();
796        configured.sort_by_key(|(category, _)| category_position(*category));
797        let mut entries: Vec<(StatusCategory, Option<StateName>)> = Vec::new();
798        for (category, name) in configured {
799            if let Some(name) = &name
800                && let Some((other, _)) = entries.iter().find(|(_, held)| {
801                    held.as_ref()
802                        .is_some_and(|held| held.0.eq_ignore_ascii_case(&name.0))
803                })
804            {
805                return Err(SourceError::Config {
806                    message: format!(
807                        "source {instance}: status_mapping sends both {} and {} to the workflow \
808                         state {:?}; one state cannot read back as two categories, so map one of \
809                         them to another state or to null",
810                        category_word(*other),
811                        category_word(category),
812                        name.0
813                    ),
814                });
815            }
816            entries.push((category, name));
817        }
818        Ok(Self { entries })
819    }
820
821    fn is_empty(&self) -> bool {
822        self.entries.is_empty()
823    }
824
825    fn target(&self, category: StatusCategory) -> StateTarget<'_> {
826        match self.entries.iter().find(|(held, _)| *held == category) {
827            Some((_, Some(name))) => StateTarget::Named(name),
828            Some((_, None)) => StateTarget::DisabledByMapping,
829            None => WorkflowType::of(category)
830                .map_or(StateTarget::NoWorkflowType, StateTarget::FirstOfType),
831        }
832    }
833
834    /// The category a workflow state of this name reads as, when the mapping names it.
835    fn category_of(&self, state: &str) -> Option<StatusCategory> {
836        self.entries.iter().find_map(|(category, name)| {
837            name.as_ref()
838                .is_some_and(|name| name.0.eq_ignore_ascii_case(state))
839                .then_some(*category)
840        })
841    }
842
843    /// Every workflow state name the mapping names, with its category.
844    fn named(&self) -> impl Iterator<Item = (StatusCategory, &StateName)> {
845        self.entries
846            .iter()
847            .filter_map(|(category, name)| name.as_ref().map(|name| (*category, name)))
848    }
849}
850
851/// The Linear plugin factory.
852#[derive(Debug, Clone, Copy, Default)]
853pub struct Plugin;
854
855impl SourcePlugin for Plugin {
856    fn kind(&self) -> &'static str {
857        KIND
858    }
859    fn config_schema(&self) -> Schema {
860        schema_for!(LinearConfig)
861    }
862    fn build(
863        &self,
864        name: &SourceName,
865        config: &Value,
866        secrets: &dyn SecretResolver,
867    ) -> Result<Box<dyn TaskSource>, SourceError> {
868        let config: LinearConfig =
869            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
870                message: format!("source {name}: {e}"),
871            })?;
872        Ok(Box::new(LinearSource::new(name, config, secrets)?))
873    }
874}
875
876/// What `onetaskgraph sources fields` reports for a `linear` source: each workflow state its
877/// `status_mapping` names, and whether the configured team has it.
878///
879/// A plan and nothing else. Workflow states are settings of the team that the people who own
880/// it decide, so this source never creates, renames or retypes one; a state reported missing
881/// is added in Linear's own team settings.
882#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
883pub struct WorkflowStatesReport {
884    /// The configured source name.
885    pub source: SourceName,
886    /// The configured team, as `team` names it.
887    team: Team,
888    /// Every workflow state `status_mapping` names, in category order. Empty when the mapping
889    /// names none.
890    pub states: Vec<MappedWorkflowState>,
891}
892
893impl WorkflowStatesReport {
894    /// The configured team, as `team` names it.
895    #[must_use]
896    pub fn team(&self) -> &str {
897        &self.team.0
898    }
899}
900
901/// One workflow state `status_mapping` names, and whether the configured team has it.
902#[derive(Debug, Clone, PartialEq, Eq)]
903pub struct MappedWorkflowState {
904    category: StatusCategory,
905    state: StateName,
906    found: Found,
907}
908
909/// Whether the configured team has a workflow state of the name the mapping gives.
910#[derive(Debug, Clone, PartialEq, Eq)]
911pub enum Found {
912    /// The team has it, of this `WorkflowState.type` — `backlog`, `unstarted`, `started`,
913    /// `completed`, `canceled`, `triage`, or another Linear names, reported verbatim because
914    /// Linear adds types (`duplicate` among them) this report must not refuse.
915    // llmlint: ignore[invalid_states_unrepresentable] Linear's own open `String!` vocabulary, reported verbatim; an enum here would refuse a type Linear adds, which a report must not.
916    Present(String),
917    /// The team has no state of that name.
918    Missing,
919}
920
921impl MappedWorkflowState {
922    /// The category the mapping sends to the state.
923    #[must_use]
924    pub fn category(&self) -> StatusCategory {
925        self.category
926    }
927
928    /// The state's name, as the mapping spells it.
929    #[must_use]
930    pub fn state(&self) -> &str {
931        &self.state.0
932    }
933
934    /// Whether the team has it, and of which type.
935    #[must_use]
936    pub fn found(&self) -> &Found {
937        &self.found
938    }
939}
940
941/// [`MappedWorkflowState`] as it is written: `present`, and the state's `type` where it is.
942///
943/// The wire shape of the report, spelled once for its serialization and its schema, so the
944/// public type can hold only the combinations [`Found`] allows.
945#[derive(serde::Serialize, schemars::JsonSchema)]
946#[schemars(rename = "MappedWorkflowState")]
947struct MappedWorkflowStateWire<'a> {
948    /// The category the mapping sends to the state.
949    category: StatusCategory,
950    /// The state's name, as the mapping spells it.
951    state: &'a StateName,
952    /// Whether the configured team has a workflow state of that name.
953    present: bool,
954    /// The state's `WorkflowState.type` on the team — `backlog`, `unstarted`, `started`,
955    /// `completed`, `canceled`, `triage` or another Linear names — absent when it is missing.
956    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
957    state_type: Option<&'a str>,
958}
959
960impl<'a> From<&'a MappedWorkflowState> for MappedWorkflowStateWire<'a> {
961    fn from(mapped: &'a MappedWorkflowState) -> Self {
962        let state_type = match &mapped.found {
963            Found::Present(kind) => Some(kind.as_str()),
964            Found::Missing => None,
965        };
966        Self {
967            category: mapped.category,
968            state: &mapped.state,
969            present: state_type.is_some(),
970            state_type,
971        }
972    }
973}
974
975impl serde::Serialize for MappedWorkflowState {
976    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
977        MappedWorkflowStateWire::from(self).serialize(serializer)
978    }
979}
980
981impl schemars::JsonSchema for MappedWorkflowState {
982    fn schema_name() -> std::borrow::Cow<'static, str> {
983        MappedWorkflowStateWire::schema_name()
984    }
985
986    fn json_schema(generator: &mut schemars::SchemaGenerator) -> Schema {
987        MappedWorkflowStateWire::json_schema(generator)
988    }
989}
990
991/// Report each workflow state one `linear` source's `status_mapping` names, as present on its
992/// team or missing from it, with its type. It writes nothing.
993///
994/// # Errors
995///
996/// [`SourceError::Config`] or [`SourceError::Auth`] for a source that cannot be built, a
997/// refusal for one with no `team`, and whatever else Linear could not answer.
998pub async fn workflow_states(
999    name: &SourceName,
1000    config: LinearConfig,
1001    secrets: &dyn SecretResolver,
1002) -> Result<WorkflowStatesReport, SourceError> {
1003    LinearSource::new(name, config, secrets)?
1004        .workflow_states()
1005        .await
1006}
1007
1008struct LinearSource {
1009    client: reqwest::Client,
1010    endpoint: Endpoint,
1011    key: SecretString,
1012    team: Option<Team>,
1013    /// This source's configured name, kept for one comparison: a far end recorded as
1014    /// `<this name>:<native>` is a Linear item Linear itself relates, so the reserved key
1015    /// is refused for it exactly as a bare id of the same kind is.
1016    name: SourceName,
1017    /// Where each status category is written and how each workflow state reads.
1018    statuses: StatusMapping,
1019    /// The one Linear project this source is scoped to, when it is.
1020    project: Option<ProjectScope>,
1021}
1022
1023impl LinearSource {
1024    fn new(
1025        name: &SourceName,
1026        config: LinearConfig,
1027        secrets: &dyn SecretResolver,
1028    ) -> Result<Self, SourceError> {
1029        let statuses = StatusMapping::resolve(config.status_mapping, name)?;
1030        let key = secrets
1031            .get(&config.api_key_env.0)
1032            .filter(|v| !v.expose_secret().trim().is_empty())
1033            .ok_or_else(|| SourceError::Auth {
1034                message: format!("set environment variable {}", config.api_key_env.0),
1035            })?;
1036        Ok(Self {
1037            client: reqwest::Client::new(),
1038            endpoint: config.endpoint,
1039            key,
1040            team: config.team,
1041            name: name.clone(),
1042            statuses,
1043            project: config.project,
1044        })
1045    }
1046}
1047#[derive(Clone, Copy)]
1048enum WriteKind {
1049    Task,
1050    Project,
1051}
1052enum Lookup<'a> {
1053    Team(&'a str),
1054    IssueState { name: &'a str, team: &'a NativeId },
1055    ProjectStatus(&'a str),
1056    IssueLabel(&'a str),
1057    ProjectLabel(&'a str),
1058}
1059impl Lookup<'_> {
1060    fn query(&self) -> &'static str {
1061        match self {
1062            Self::Team(_) => graphql::TEAM,
1063            Self::IssueState { .. } => graphql::ISSUE_STATE,
1064            Self::ProjectStatus(_) => graphql::PROJECT_STATUS,
1065            Self::IssueLabel(_) => graphql::ISSUE_LABEL,
1066            Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
1067        }
1068    }
1069    fn connection(&self) -> &'static str {
1070        match self {
1071            Self::Team(_) => "teams",
1072            Self::IssueState { .. } => "workflowStates",
1073            Self::ProjectStatus(_) => "projectStatuses",
1074            Self::IssueLabel(_) => "issueLabels",
1075            Self::ProjectLabel(_) => "projectLabels",
1076        }
1077    }
1078    fn diagnostic(&self) -> String {
1079        match self {
1080            Self::Team(_) => "configured team".into(),
1081            Self::IssueState { name, .. } => format!("workflow state {name:?}"),
1082            Self::ProjectStatus(name) => format!("project status {name:?}"),
1083            Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
1084        }
1085    }
1086    fn variables(&self) -> Value {
1087        match self {
1088            Self::Team(key) => json!({"key":key}),
1089            Self::IssueState { name, team } => json!({"name":name,"team":team.0}),
1090            Self::IssueLabel(name) | Self::ProjectLabel(name) => json!({"name":name}),
1091            // `PROJECT_STATUS` names nothing, for the reason recorded on that document.
1092            Self::ProjectStatus(_) => json!({}),
1093        }
1094    }
1095    /// The display name `one_id` matches locally, for the one lookup whose connection
1096    /// Linear will not narrow server-side.
1097    fn local_name(&self) -> Option<&str> {
1098        match self {
1099            Self::ProjectStatus(name) => Some(name),
1100            _ => None,
1101        }
1102    }
1103}
1104#[derive(Clone, Copy)]
1105enum MutationRoot {
1106    IssueCreate,
1107    IssueUpdate,
1108    ProjectCreate,
1109    ProjectUpdate,
1110    IssueRelationCreate,
1111    ProjectRelationCreate,
1112    IssueRelationDelete,
1113    ProjectRelationDelete,
1114    IssueDelete,
1115    ProjectDelete,
1116    DocumentCreate,
1117    DocumentUpdate,
1118    DocumentDelete,
1119    CommentCreate,
1120    CommentUpdate,
1121    CommentDelete,
1122}
1123impl MutationRoot {
1124    fn as_str(self) -> &'static str {
1125        match self {
1126            Self::IssueCreate => "issueCreate",
1127            Self::IssueUpdate => "issueUpdate",
1128            Self::ProjectCreate => "projectCreate",
1129            Self::ProjectUpdate => "projectUpdate",
1130            Self::IssueRelationCreate => "issueRelationCreate",
1131            Self::ProjectRelationCreate => "projectRelationCreate",
1132            Self::IssueRelationDelete => "issueRelationDelete",
1133            Self::ProjectRelationDelete => "projectRelationDelete",
1134            Self::IssueDelete => "issueDelete",
1135            Self::ProjectDelete => "projectDelete",
1136            Self::DocumentCreate => "documentCreate",
1137            Self::DocumentUpdate => "documentUpdate",
1138            Self::DocumentDelete => "documentDelete",
1139            Self::CommentCreate => "commentCreate",
1140            Self::CommentUpdate => "commentUpdate",
1141            Self::CommentDelete => "commentDelete",
1142        }
1143    }
1144}
1145
1146#[derive(Deserialize)]
1147struct Envelope {
1148    // 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.
1149    data: Option<Value>,
1150    #[serde(default)]
1151    errors: Vec<GqlError>,
1152}
1153#[derive(Deserialize)]
1154struct GqlError {
1155    message: String,
1156    // Held raw rather than typed, for two reasons. Linear puts the whole of *why* it
1157    // refused in here — `message` is a category name like `Argument Validation Error`,
1158    // which named neither the field nor the value when the live project-relation write
1159    // was refused by it — so a refusal carries this verbatim and a reader diagnoses from
1160    // it. And a typed shape with a required `code` fails the whole envelope's
1161    // deserialization when Linear sends extensions without one, turning a refusal this
1162    // source could explain into an unexplained malformed response.
1163    extensions: Option<Value>,
1164}
1165#[derive(Deserialize)]
1166#[serde(rename_all = "camelCase")]
1167struct GqlExtensions {
1168    code: GqlErrorCode,
1169    retry_after: Option<u64>,
1170}
1171impl GqlError {
1172    /// The rate-limit shape of [`Self::extensions`], when it has one.
1173    fn coded(&self) -> Option<GqlExtensions> {
1174        self.extensions
1175            .as_ref()
1176            .and_then(|value| serde_json::from_value(value.clone()).ok())
1177    }
1178    /// Everything Linear said about this refusal, on one line and cut to [`SAID_LIMIT`].
1179    ///
1180    /// Linear's own sentence comes first, then the raw envelope, because only the first
1181    /// of those two is short enough to survive [`SAID_LIMIT`] on its merits. `message` is
1182    /// a category name — `Argument Validation Error` — and the sentence naming the field
1183    /// and the values it would have taken is `extensions.userPresentableMessage`, one of
1184    /// several keys in an envelope whose `validationErrors` echoes the whole rejected
1185    /// input back. Observed against the real API on 2026-09-04, a `projectRelationCreate`
1186    /// refusal rendered past the cut, and the echo is what got cut.
1187    ///
1188    /// That the sentence itself did not was luck: this build of `serde_json` renders an
1189    /// object's keys sorted, and `userPresentableMessage` happens to sort ahead of
1190    /// `validationErrors`. Nobody chose that — Linear sends the echo first — and any key
1191    /// Linear adds sorting between the two would move the sentence behind an echo longer
1192    /// than the whole limit, as would turning `preserve_order` on. Leading with it makes
1193    /// what a reader diagnoses from independent of both.
1194    fn said(&self) -> String {
1195        let Some(extensions) = &self.extensions else {
1196            return elided(&self.message);
1197        };
1198        match extensions
1199            .get("userPresentableMessage")
1200            .and_then(Value::as_str)
1201            .filter(|sentence| !sentence.is_empty())
1202        {
1203            Some(sentence) => elided(&format!("{}: {sentence} {extensions}", self.message)),
1204            None => elided(&format!("{}: {extensions}", self.message)),
1205        }
1206    }
1207}
1208#[derive(Deserialize)]
1209enum GqlErrorCode {
1210    #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
1211    RateLimited,
1212    #[serde(other)]
1213    Other,
1214}
1215
1216/// How much of a failed response's body a refusal carries.
1217///
1218/// Enough for Linear's own error envelope, which is one or two sentences naming the field
1219/// or argument it would not accept, and short enough that a proxy's HTML error page does
1220/// not become the whole message.
1221const SAID_LIMIT: usize = 400;
1222
1223/// `said` made safe to put in a message: one line of printable text, cut to [`SAID_LIMIT`].
1224///
1225/// A failed response's body is whatever answered — Linear's error envelope, or an HTML
1226/// page from a proxy in front of it — and this message is written to a terminal. So every
1227/// control character goes, escape sequences with them, and each run of whitespace becomes
1228/// one space: a body cannot move the cursor, repaint the line or hide the rest of the
1229/// diagnostic behind itself. Cut by characters rather than bytes, because slicing UTF-8
1230/// mid-codepoint would panic inside the path that exists to explain a failure.
1231fn elided(said: &str) -> String {
1232    let mut printable = String::new();
1233    let mut spaced = true;
1234    for character in said.chars() {
1235        if character.is_control() || character.is_whitespace() {
1236            if !spaced {
1237                printable.push(' ');
1238                spaced = true;
1239            }
1240            continue;
1241        }
1242        printable.push(character);
1243        spaced = false;
1244    }
1245    let printable = printable.trim_end();
1246    if printable.chars().count() <= SAID_LIMIT {
1247        return printable.to_owned();
1248    }
1249    let kept: String = printable.chars().take(SAID_LIMIT).collect();
1250    format!("{kept}…")
1251}
1252
1253impl LinearSource {
1254    // 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.
1255    async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
1256        let response = self
1257            .client
1258            .post(&self.endpoint.0)
1259            .header("Authorization", self.key.expose_secret())
1260            .json(&json!({"query": query, "variables": variables}))
1261            .send()
1262            .await
1263            .map_err(|e| SourceError::Unavailable {
1264                message: e.to_string(),
1265            })?;
1266        let status = response.status();
1267        let retry = response
1268            .headers()
1269            .get("retry-after")
1270            .and_then(|v| v.to_str().ok())
1271            .and_then(|v| v.parse().ok());
1272        if status.as_u16() == 429 {
1273            return Err(SourceError::RateLimited {
1274                retry_after_seconds: retry,
1275                // Linear has one rate limiter and the status is the whole of what it said,
1276                // so there is nothing to add beyond the kind — which is what an absent
1277                // message means.
1278                message: None,
1279            });
1280        }
1281        if status.as_u16() == 401 || status.as_u16() == 403 {
1282            return Err(SourceError::Auth {
1283                message: "Linear rejected the configured credential".into(),
1284            });
1285        }
1286        if !status.is_success() {
1287            // Linear puts its GraphQL error envelope in the *body* of a 400, so the status
1288            // alone names the whole call and nothing about what Linear objected to. The
1289            // body is Linear's answer to this request and holds no credential; it is cut
1290            // because a proxy in front of Linear can answer with a page.
1291            let said = elided(&response.text().await.unwrap_or_default());
1292            return Err(SourceError::Unavailable {
1293                message: if said.is_empty() {
1294                    format!("Linear returned HTTP {status}")
1295                } else {
1296                    format!("Linear returned HTTP {status}: {said}")
1297                },
1298            });
1299        }
1300        let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
1301            message: e.to_string(),
1302        })?;
1303        if let Some(error) = body.errors.first() {
1304            if let Some(extensions) = error
1305                .coded()
1306                .filter(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
1307            {
1308                return Err(SourceError::RateLimited {
1309                    retry_after_seconds: extensions.retry_after.or(retry),
1310                    message: None,
1311                });
1312            }
1313            return Err(SourceError::Refused {
1314                message: error.said(),
1315            });
1316        }
1317        body.data.ok_or_else(|| SourceError::Malformed {
1318            message: "GraphQL response has no data".into(),
1319        })
1320    }
1321
1322    // 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.
1323    /// The label predicates, which really are spelled the same at both levels.
1324    ///
1325    /// `IssueFilter.labels` is an `IssueLabelCollectionFilter` and `ProjectFilter.labels`
1326    /// is a `ProjectLabelCollectionFilter` — two types — but `some`, `every` and a `name`
1327    /// of `StringComparator` are members of both, so one spelling satisfies each. That is
1328    /// the whole of what the two filters have in common, and everything else about them is
1329    /// built separately for the reason recorded on the two builders below.
1330    ///
1331    /// "At least one of these" is a disjunction of `eqIgnoreCase` rather than one
1332    /// case-insensitive list operator, because Linear has no such operator. This source
1333    /// sent `labels:{some:{name:{inIgnoreCase:[…]}}}` until Linear refused it outright,
1334    /// HTTP 400, on the first read of the live lane that ever reached a label filter:
1335    ///
1336    /// ```text
1337    /// Variable "$filter" got invalid value { inIgnoreCase: […] } at
1338    /// "filter.and[1].labels.some.name"; Field "inIgnoreCase" is not defined by
1339    /// type "StringComparator". Did you mean "eqIgnoreCase" or "neqIgnoreCase"?
1340    /// ```
1341    ///
1342    /// That refusal is also the evidence for the replacement: Linear named the two members
1343    /// of `StringComparator` closest to what it was sent, and `eqIgnoreCase` is one of
1344    /// them — the same operator `all_of` below has always sent and the live lane has always
1345    /// exercised. `in` exists there too and would need no `or`, but it is case-sensitive,
1346    /// so `any_of` would stop agreeing with `all_of` and `none_of` and with what the table
1347    /// at the top of this file says this source does.
1348    fn label_parts(labels: &onetaskgraph_plugin_api::LabelFilter) -> Vec<Value> {
1349        let mut parts = Vec::new();
1350        if !labels.any_of.is_empty() {
1351            parts.push(json!({"or": labels
1352                .any_of
1353                .iter()
1354                .map(|name| json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}))
1355                .collect::<Vec<_>>()}));
1356        }
1357        for name in &labels.all_of {
1358            parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
1359        }
1360        for name in &labels.none_of {
1361            parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
1362        }
1363        parts
1364    }
1365    fn narrowed(mut parts: Vec<Value>) -> Value {
1366        if parts.len() == 1 {
1367            parts.pop().unwrap()
1368        } else {
1369            json!({"and": parts})
1370        }
1371    }
1372    /// The filter this source sends to `issues(filter:)`.
1373    ///
1374    /// **`IssueFilter` and `ProjectFilter` are different input types, and one builder for
1375    /// both is what put two wrong fields on the wire.** They read as though they were the
1376    /// same filter over different rows — the label member really is spelled alike, and the
1377    /// `and`/`or` are identical — and a single builder producing one object for both
1378    /// connections had shipped `team` and the issue's `state` shape into `projects(filter:)`
1379    /// since long before this branch. Linear refused the first outright:
1380    ///
1381    /// ```text
1382    /// Variable "$filter" got invalid value { team: { key: [Object] } };
1383    /// Field "team" is not defined by type "ProjectFilter". Did you mean "lead"?
1384    /// ```
1385    ///
1386    /// So there are two builders, and each names its own type's members. Adding a predicate
1387    /// means deciding twice, on purpose, rather than once by accident.
1388    fn issue_filter(&self, query: &TaskQuery) -> Result<Value, SourceError> {
1389        let mut parts = self.issue_scope();
1390        parts.extend(Self::label_parts(&query.labels));
1391        if !query.statuses.is_empty() {
1392            parts.push(self.status_narrowing(&query.statuses));
1393        }
1394        match &query.project {
1395            ProjectFilter::Orphans => parts.push(json!({"project": {"null": true}})),
1396            ProjectFilter::Is(id) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
1397            ProjectFilter::Any => {}
1398        }
1399        // The narrowings below are each confirmed in process by `confirms` before a row is
1400        // returned: Linear's comparators are a candidate set, and the contract's predicate is
1401        // what decides. None of them can drop a row the predicate keeps — see the module
1402        // documentation's ruling on the follow-up searches for what each one rests on.
1403        if !query.priorities.is_empty() {
1404            parts.push(json!({"priority": {"in": query
1405                .priorities
1406                .iter()
1407                .map(|priority| linear_priority(*priority))
1408                .collect::<Vec<_>>()}}));
1409        }
1410        if let Some(since) = query.commented_since {
1411            let since = since.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true);
1412            parts.push(json!({"comments": {"some": {"or": [
1413                {"createdAt": {"gte": since}},
1414                {"updatedAt": {"gte": since}},
1415            ]}}}));
1416        }
1417        for wanted in &query.metadata {
1418            parts.extend(slot_phrase(wanted.value()));
1419        }
1420        if let Some(origin) = &query.origin {
1421            parts.extend(slot_phrase(origin));
1422        }
1423        if let Some(text) = &query.text {
1424            let title = json!({"title": {"containsIgnoreCase": text.terms}});
1425            let content = json!({"description": {"containsIgnoreCase": text.terms}});
1426            parts.push(match text.fields {
1427                TextFields::Title => title,
1428                TextFields::Content => content,
1429                TextFields::TitleOrContent => json!({"or": [title, content]}),
1430            });
1431        }
1432        Ok(Self::narrowed(parts))
1433    }
1434
1435    /// What every issue read of this source is narrowed to before any predicate: the
1436    /// configured team, and the project this source is scoped to, when it is.
1437    fn issue_scope(&self) -> Vec<Value> {
1438        let mut parts = Vec::new();
1439        if let Some(team) = &self.team {
1440            parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
1441        }
1442        if let Some(project) = &self.project {
1443            parts.push(json!({"project": {"id": {"eq": project.0}}}));
1444        }
1445        parts
1446    }
1447
1448    /// The issue-side status narrowing for `statuses`, through this instance's mapping.
1449    ///
1450    /// **Without a mapping it is exactly what it always was** — one `state:{type:{in:[…]}}`
1451    /// over the categories' workflow-state types. With one, each category asks for exactly
1452    /// what reads as it: the issues at its named state, by name, when it is mapped; and the
1453    /// issues of its type *at no state the mapping names*, because those read as the category
1454    /// they are mapped to — a `todo` filter returning an issue at `Queued` would be a row that
1455    /// reads back as another category. A category set to `null` is never written, and still
1456    /// asks for the issues of its type, which read as it.
1457    fn status_narrowing(&self, statuses: &[StatusCategory]) -> Value {
1458        if self.statuses.is_empty() {
1459            return json!({"state": {"type": {"in": statuses
1460                .iter()
1461                .flat_map(workflow_state_types)
1462                .collect::<Vec<_>>()}}});
1463        }
1464        let mut alternatives = Vec::new();
1465        for category in statuses {
1466            let target = self.statuses.target(*category);
1467            if let StateTarget::Named(name) = target {
1468                alternatives.push(json!({"state": {"name": {"eqIgnoreCase": name.0}}}));
1469            }
1470            // Every issue that reads as this category by its type — at a state of the type and
1471            // of no name the mapping claims — whether the category is mapped, left out or set
1472            // to `null`: with `backlog` mapped to `Proposed` an issue at `Backlog` still reads
1473            // as `backlog`, and with `cancelled` set to `null` an issue at `Canceled` still
1474            // reads as `cancelled`, which a write can no longer move it to but a read reports.
1475            let types = workflow_state_types(category);
1476            // `unknown` has no type of its own: a state reads as it when its type is none of
1477            // the five a category stands for — `Triage`'s `triage`, a `duplicate` state.
1478            let by_type = if *category == StatusCategory::Unknown {
1479                Some(
1480                    json!({"state": {"type": {"nin": WorkflowType::ALL.map(WorkflowType::as_str)}}}),
1481                )
1482            } else {
1483                (!types.is_empty()).then(|| json!({"state": {"type": {"in": types}}}))
1484            };
1485            if let Some(by_type) = by_type {
1486                let mut parts = vec![by_type];
1487                parts.extend(
1488                    self.statuses
1489                        .named()
1490                        .map(|(_, name)| json!({"state": {"name": {"neqIgnoreCase": name.0}}})),
1491                );
1492                alternatives.push(Self::narrowed(parts));
1493            }
1494        }
1495        match alternatives.len() {
1496            // Every category asked for is disabled: a type no state has, which matches nothing
1497            // and is refused by nothing — what a disabled category always narrowed to.
1498            0 => json!({"state": {"type": {"in": Vec::<&str>::new()}}}),
1499            1 => alternatives.pop().expect("one alternative"),
1500            _ => json!({ "or": alternatives }),
1501        }
1502    }
1503
1504    /// Whether one task this source read satisfies every predicate of `query` that a
1505    /// narrowing above only approximates — the metadata matches, the origin, the text and the
1506    /// priorities — by the contract's own statement of each.
1507    fn confirms(query: &TaskQuery, task: &Task) -> bool {
1508        query.metadata_matches(&task.metadata)
1509            && query.origin_matches(&task.metadata)
1510            && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
1511            && query
1512                .text
1513                .as_ref()
1514                .is_none_or(|text| text_holds(&task.title, task.content.as_deref(), text))
1515    }
1516
1517    /// The filter this source sends to `projects(filter:)`.
1518    ///
1519    /// Two members differ from [`Self::issue_filter`] and both are Linear's doing; see that
1520    /// builder for why they are written out twice rather than shared.
1521    ///
1522    /// **A project has no `team`.** It has the teams it is accessible from, and
1523    /// `ProjectFilter.accessibleTeams` is a `TeamCollectionFilter`, so the same team key
1524    /// reaches it under `some:`. `leadTeam` is the other team-shaped member and is a
1525    /// different set — one designated team rather than every team the project is in — so
1526    /// narrowing by it would drop projects the configured team really does hold.
1527    ///
1528    /// **A project's status is not an issue's state, and they do not even share a
1529    /// vocabulary.** An issue's is `WorkflowState`, reached through `IssueFilter.state`,
1530    /// and its `type` is `backlog`, `unstarted`, `started`, `completed`, `canceled` or
1531    /// `triage`. A project's is `ProjectStatus`, reached through `ProjectFilter.status` —
1532    /// `ProjectFilter.state` exists and is *not* it: that member is a bare
1533    /// `StringComparator` over a different thing — and its `type` is the `ProjectStatusType`
1534    /// enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`. So the
1535    /// nearest thing to an issue's `unstarted` is a project's `planned`, and `paused` has no
1536    /// issue counterpart at all. [`project_status_types`] is that vocabulary and
1537    /// [`workflow_state_types`] is the other; sending either one's words to the other's
1538    /// connection matches nothing while refusing nothing, which is the worst way to be
1539    /// wrong.
1540    fn project_filter(
1541        &self,
1542        labels: &onetaskgraph_plugin_api::LabelFilter,
1543        statuses: &[StatusCategory],
1544    ) -> Value {
1545        let mut parts = self.project_scope();
1546        parts.extend(Self::label_parts(labels));
1547        if !statuses.is_empty() {
1548            parts.push(json!({"status": {"type": {"in": statuses.iter().flat_map(project_status_types).collect::<Vec<_>>()}}}));
1549        }
1550        Self::narrowed(parts)
1551    }
1552
1553    /// What every project read of this source is narrowed to: the projects the configured
1554    /// team can reach, and the one project this source is scoped to, when it is.
1555    fn project_scope(&self) -> Vec<Value> {
1556        let mut parts = Vec::new();
1557        if let Some(team) = &self.team {
1558            parts.push(json!({"accessibleTeams": {"some": {"key": {"eqIgnoreCase": team.0}}}}));
1559        }
1560        if let Some(project) = &self.project {
1561            parts.push(json!({"id": {"eq": project.0}}));
1562        }
1563        parts
1564    }
1565
1566    /// Whether an item filed under `project` is one this source holds: always, unless it is
1567    /// scoped to one project and this is not filed under it.
1568    fn in_scope(&self, project: Option<&NativeId>) -> bool {
1569        self.project
1570            .as_ref()
1571            .is_none_or(|scope| project.is_some_and(|project| project.0 == scope.0))
1572    }
1573
1574    /// The project a task or a document written with `project` is filed under: that one, or
1575    /// the scope when it names none — and a refusal naming both when it names another.
1576    fn filed_in(
1577        &self,
1578        project: Option<&NativeId>,
1579        what: &str,
1580    ) -> Result<Option<String>, SourceError> {
1581        match (&self.project, project) {
1582            (None, project) => Ok(project.map(|id| id.0.clone())),
1583            (Some(scope), None) => Ok(Some(scope.0.clone())),
1584            (Some(scope), Some(project)) if project.0 == scope.0 => Ok(Some(scope.0.clone())),
1585            (Some(scope), Some(project)) => Err(SourceError::Refused {
1586                message: format!(
1587                    "source {} is scoped to the Linear project {} and cannot hold a {what} in                      the project {}; next: write it with no project, or with {}, or to a                      source scoped to {}",
1588                    self.name, scope.0, project.0, scope.0, project.0
1589                ),
1590            }),
1591        }
1592    }
1593    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1594
1595    async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
1596        let data = self.send(lookup.query(), lookup.variables()).await?;
1597        let connection = lookup.connection();
1598        let nodes = data
1599            .get(connection)
1600            .and_then(|v| v.get("nodes"))
1601            .and_then(Value::as_array)
1602            .ok_or_else(|| SourceError::Malformed {
1603                message: format!("missing {connection}.nodes"),
1604            })?;
1605        // A node this comparison cannot read is malformed rather than a nonmatch: dropping
1606        // it would turn Linear having answered nonsense into this source reporting no such
1607        // status, which is a different thing and reads as the caller's mistake.
1608        let matched = match lookup.local_name() {
1609            Some(name) => {
1610                let mut matched = Vec::new();
1611                for node in nodes {
1612                    if str_at(node, "name")?.eq_ignore_ascii_case(name) {
1613                        matched.push(node);
1614                    }
1615                }
1616                matched
1617            }
1618            None => nodes.iter().collect::<Vec<_>>(),
1619        };
1620        match matched.as_slice() {
1621            [] => Err(SourceError::Refused {
1622                message: format!(
1623                    "source {} cannot resolve {}: found 0 matches",
1624                    self.name,
1625                    lookup.diagnostic()
1626                ),
1627            }),
1628            [node] => Ok(NativeId(backend_id(node, "id")?.to_owned())),
1629            nodes => {
1630                let ids = nodes
1631                    .iter()
1632                    .map(|node| backend_id(node, "id"))
1633                    .collect::<Result<Vec<_>, _>>()?;
1634                Err(SourceError::Refused {
1635                    message: format!(
1636                        "source {} cannot resolve {}: found {} matches with ids {ids:?}",
1637                        self.name,
1638                        lookup.diagnostic(),
1639                        nodes.len()
1640                    ),
1641                })
1642            }
1643        }
1644    }
1645    async fn team_id(&self) -> Result<NativeId, SourceError> {
1646        let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
1647            message: format!(
1648                "source {} needs config.team before it can create Linear items",
1649                self.name
1650            ),
1651        })?;
1652        self.one_id(Lookup::Team(&team.0)).await
1653    }
1654    async fn label_ids(
1655        &self,
1656        labels: &[Label],
1657        kind: WriteKind,
1658    ) -> Result<Vec<NativeId>, SourceError> {
1659        let mut ids = Vec::with_capacity(labels.len());
1660        for label in labels {
1661            ids.push(
1662                self.one_id(if matches!(kind, WriteKind::Project) {
1663                    Lookup::ProjectLabel(&label.name)
1664                } else {
1665                    Lookup::IssueLabel(&label.name)
1666                })
1667                .await?,
1668            );
1669        }
1670        Ok(ids)
1671    }
1672    fn write_description(
1673        &self,
1674        content: Option<&str>,
1675        metadata: &std::collections::BTreeMap<String, Value>,
1676        repositories: &[Repository],
1677        edges: &[DependencyEdge],
1678        kind: WriteKind,
1679    ) -> Result<Option<String>, SourceError> {
1680        Self::long_form(
1681            content,
1682            metadata,
1683            repositories,
1684            self.recorded_ends(edges, kind),
1685        )
1686    }
1687
1688    /// The far ends of `edges` no relation of this workspace can name — another level, or
1689    /// another source — as the reserved key records them.
1690    fn recorded_ends(&self, edges: &[DependencyEdge], kind: WriteKind) -> Vec<Value> {
1691        edges
1692            .iter()
1693            .filter(|edge| {
1694                edge.to.kind
1695                    != match kind {
1696                        WriteKind::Task => ItemKind::Task,
1697                        WriteKind::Project => ItemKind::Project,
1698                    }
1699                    || edge
1700                        .to
1701                        .id()
1702                        .split_once(':')
1703                        .is_some_and(|(source, _)| source != self.name.as_str())
1704            })
1705            .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
1706            .collect()
1707    }
1708
1709    /// Every forward edge `id` holds, relations and recorded far ends alike, walked to
1710    /// exhaustion.
1711    async fn forward_edges(&self, id: &NativeId) -> Result<Vec<DependencyEdge>, SourceError> {
1712        let mut edges = Vec::new();
1713        let mut cursor = None;
1714        loop {
1715            let page = self
1716                .dependencies(
1717                    ISSUE_RELATIONS,
1718                    DependencyRoot::Issue,
1719                    id,
1720                    Direction::DependsOn,
1721                    &PageRequest {
1722                        cursor,
1723                        limit: MAX_PAGE_SIZE,
1724                    },
1725                )
1726                .await?;
1727            edges.extend(page.items);
1728            match page.next {
1729                Some(next) => cursor = Some(next),
1730                None => return Ok(edges),
1731            }
1732        }
1733    }
1734
1735    /// Apply one targeted update to one issue; see [`TaskSource::update_task`].
1736    ///
1737    /// One read of the issue, then one `issueUpdate` carrying only the members that differ
1738    /// from it — `title`, `description` (content and metadata slot together), `stateId`,
1739    /// `priority` — and, when the named edges differ from the ones the issue holds, its
1740    /// relations replaced. Nothing is sent for a field already holding the requested value,
1741    /// and nothing at all when nothing differs. A status is written by its category, exactly
1742    /// as `set_task_status` writes one: an issue already in that category keeps the state it
1743    /// is in, and one that is not moves to the state `status_mapping` names for it, or to the
1744    /// team's first state of that type where the mapping names none. `delivers` lands in the
1745    /// metadata slot under its reserved key, as a copy writes it.
1746    ///
1747    /// The task answered is read back after the write, so its status is Linear's.
1748    async fn targeted_update(
1749        &self,
1750        id: &NativeId,
1751        update: &TaskUpdate,
1752    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
1753        update.consistent()?;
1754        if let Some(delivers) = &update.delivers {
1755            TaskRef::listed(
1756                TaskRef::DELIVERS_KEY,
1757                id,
1758                Some(&self.name),
1759                delivers.clone(),
1760            )
1761            .map_err(|message| SourceError::Refused { message })?;
1762        }
1763        if let Some(status) = &update.status {
1764            self.writable(status.category)?;
1765        }
1766        let Some((before, description)) = self.issue_held(id).await? else {
1767            return Ok(None);
1768        };
1769        let (visible, held) = metadata_description(description)?;
1770        let mut slot = held.clone();
1771        for (key, value) in &update.metadata_set {
1772            slot.insert(key.as_str().to_owned(), value.clone());
1773        }
1774        for key in &update.metadata_remove {
1775            slot.remove(key.as_str());
1776        }
1777        if let Some(delivers) = &update.delivers {
1778            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
1779        }
1780        let mut relations = None;
1781        if let Some(wanted) = &update.depends_on {
1782            let current = self.forward_edges(&before.id).await?;
1783            let ends = |edges: &[DependencyEdge]| {
1784                let mut ends: Vec<(String, String)> = edges
1785                    .iter()
1786                    .map(|edge| {
1787                        (
1788                            edge.to.id().to_owned(),
1789                            format!("{:?}{:?}", edge.to.kind, edge.kind),
1790                        )
1791                    })
1792                    .collect();
1793                ends.sort();
1794                ends
1795            };
1796            if ends(&current) != ends(wanted) {
1797                let prepared = self.prepare_edges(wanted, WriteKind::Task).await?;
1798                let recorded = self.recorded_ends(&prepared, WriteKind::Task);
1799                if recorded.is_empty() {
1800                    slot.remove(DependencyEdge::RECORDED_KEY);
1801                } else {
1802                    slot.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
1803                }
1804                relations = Some(prepared);
1805            }
1806        }
1807        let mut input = serde_json::Map::new();
1808        if let Some(title) = update
1809            .title
1810            .as_ref()
1811            .filter(|title| **title != before.title)
1812        {
1813            input.insert("title".into(), json!(title));
1814        }
1815        let content = update.content.as_deref().or(visible.as_deref());
1816        if content != visible.as_deref() || slot != held {
1817            let written = Self::described(content, &slot)?;
1818            // Checked before anything is sent: content ending in what this source reads as
1819            // its own slot would read back as metadata rather than as the content it was.
1820            let (reads, read) = metadata_description(written.clone())?;
1821            if reads.as_deref().unwrap_or_default() != content.unwrap_or_default() || read != slot {
1822                return Err(SourceError::Refused {
1823                    message: format!(
1824                        "this content would read back from source {} as something other than \
1825                         itself, or ends in what it reads as its own metadata slot; next: change \
1826                         how the content ends",
1827                        self.name
1828                    ),
1829                });
1830            }
1831            input.insert("description".into(), json!(written));
1832        }
1833        if let Some(status) = update
1834            .status
1835            .as_ref()
1836            .filter(|status| status.category != before.status.category)
1837        {
1838            let (state, _) = self.resolve_state(status.category, &before.id).await?;
1839            input.insert("stateId".into(), json!(state.0));
1840        }
1841        if let Some(priority) = update
1842            .priority
1843            .filter(|priority| *priority != before.priority)
1844        {
1845            input.insert("priority".into(), json!(linear_priority(priority)));
1846        }
1847        let sent = !input.is_empty();
1848        if sent {
1849            let data = self
1850                .send(
1851                    graphql::ISSUE_UPDATE,
1852                    json!({"id":before.id.0,"input":Value::Object(input)}),
1853                )
1854                .await?;
1855            let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
1856                .get("issue")
1857                .filter(|issue| !issue.is_null())
1858                .ok_or_else(|| SourceError::Malformed {
1859                    message: "missing issueUpdate.issue".into(),
1860                })?;
1861            written_is(issue, &before.id)?;
1862        }
1863        if let Some(prepared) = &relations {
1864            self.write_relations(&before.id, prepared, WriteKind::Task)
1865                .await?;
1866        }
1867        let task = if sent || relations.is_some() {
1868            self.get_task(&before.id)
1869                .await?
1870                .ok_or_else(|| SourceError::Malformed {
1871                    message: format!("task {id} was updated and then could not be read back"),
1872                })?
1873        } else {
1874            before.clone()
1875        };
1876        let mut written = update.changed(&before, &task);
1877        if relations.is_some() {
1878            written.insert(UpdatedField::DependsOn);
1879        }
1880        Ok(Some(TaskUpdateOutcome {
1881            task,
1882            written,
1883            delivers_before: before.delivers,
1884        }))
1885    }
1886
1887    /// The one long-form field a Linear item has, with this source's own slot at the end.
1888    ///
1889    /// Shared by every kind this source writes rather than reimplemented per kind: a
1890    /// document keeps caller metadata in exactly the slot an issue and a project do, which
1891    /// is what lets the same read side take it back out.
1892    fn long_form(
1893        content: Option<&str>,
1894        metadata: &std::collections::BTreeMap<String, Value>,
1895        repositories: &[Repository],
1896        recorded: Vec<Value>,
1897    ) -> Result<Option<String>, SourceError> {
1898        let mut metadata = metadata.clone();
1899        if repositories.is_empty() {
1900            metadata.remove(Repository::METADATA_KEY);
1901        } else {
1902            metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
1903        }
1904        if recorded.is_empty() {
1905            metadata.remove(DependencyEdge::RECORDED_KEY);
1906        } else {
1907            metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
1908        }
1909        Self::described(content, &metadata)
1910    }
1911
1912    /// The long-form field holding `content` and a slot of exactly `metadata`, in the one
1913    /// encoding [`long_form`](Self::long_form) writes: the content alone when there is no
1914    /// metadata, and otherwise the slot after one blank line.
1915    fn described(
1916        content: Option<&str>,
1917        metadata: &std::collections::BTreeMap<String, Value>,
1918    ) -> Result<Option<String>, SourceError> {
1919        let visible = content.unwrap_or_default();
1920        if metadata.is_empty() {
1921            return Ok((!visible.is_empty()).then(|| visible.to_owned()));
1922        }
1923        let slot = slot_text(metadata)?;
1924        Ok(Some(if visible.is_empty() {
1925            slot
1926        } else {
1927            format!("{visible}\n\n{slot}")
1928        }))
1929    }
1930    /// What this source says when asked for a project edge carrying no ordering.
1931    ///
1932    /// Linear's project relations have exactly one type and it is an ordering. Asked on
1933    /// 2026-09-04 to create one typed `related` — and separately `blocks` and `dependsOn`
1934    /// — the real API refused each with `Argument Validation Error` and
1935    /// `constraints: {"isEnum": "type must be one of the following values: dependency"}`.
1936    /// That is Linear's own enumeration of the field, from the validator behind GraphQL
1937    /// where introspection cannot reach it, and it has one member. An issue relation is a
1938    /// different relation with a different set, which does include `related`, so this
1939    /// reaches projects alone.
1940    fn unordered_project_relation(&self, near: &NativeId, far: &str) -> SourceError {
1941        SourceError::Refused {
1942            message: format!(
1943                "source {} cannot carry an unordered dependency between projects, because \
1944                 Linear types every project relation `dependency` and that is an ordering; \
1945                 record {near} to {far} as a dependency, or between tasks",
1946                self.name,
1947                near = near.0,
1948            ),
1949        }
1950    }
1951    /// The one edge [`Self::unordered_project_relation`] refuses, if there is one here.
1952    fn unordered_project_edge(edges: &[DependencyEdge]) -> Option<&DependencyEdge> {
1953        edges
1954            .iter()
1955            .find(|edge| edge.to.kind == ItemKind::Project && edge.kind == DependencyKind::Related)
1956    }
1957    async fn write_relations(
1958        &self,
1959        near: &NativeId,
1960        edges: &[DependencyEdge],
1961        kind: WriteKind,
1962    ) -> Result<(), SourceError> {
1963        let mut cursor: Option<Cursor> = None;
1964        loop {
1965            let data = self
1966                .send(
1967                    if matches!(kind, WriteKind::Project) {
1968                        PROJECT_RELATIONS
1969                    } else {
1970                        ISSUE_RELATIONS
1971                    },
1972                    json!({"id":near.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
1973                )
1974                .await?;
1975            let root = data
1976                .get(if matches!(kind, WriteKind::Project) {
1977                    "project"
1978                } else {
1979                    "issue"
1980                })
1981                .ok_or_else(|| SourceError::Malformed {
1982                    message: "missing relation item".into(),
1983                })?;
1984            let relations = root
1985                .get("relations")
1986                .ok_or_else(|| SourceError::Malformed {
1987                    message: "missing relations".into(),
1988                })?;
1989            for relation in relations
1990                .get("nodes")
1991                .and_then(Value::as_array)
1992                .ok_or_else(|| SourceError::Malformed {
1993                    message: "missing relations.nodes".into(),
1994                })?
1995            {
1996                let id = backend_id(relation, "id")?;
1997                let (query, mutation) = if matches!(kind, WriteKind::Project) {
1998                    (
1999                        graphql::PROJECT_RELATION_DELETE,
2000                        MutationRoot::ProjectRelationDelete,
2001                    )
2002                } else {
2003                    (
2004                        graphql::ISSUE_RELATION_DELETE,
2005                        MutationRoot::IssueRelationDelete,
2006                    )
2007                };
2008                let deleted = self.send(query, json!({"id":id})).await?;
2009                mutation_payload(&deleted, mutation)?;
2010            }
2011            let Some(next) = page_next(relations)? else {
2012                break;
2013            };
2014            cursor = Some(next);
2015        }
2016        // Linear requires an anchor at each end of a project relation and validates both
2017        // against an enum GraphQL cannot see: `ProjectRelationCreateInput` declares them
2018        // `String!` and enumerates nothing, and the field descriptions read as a choice
2019        // between the project and a milestone, which is not what they are. Linear's own
2020        // refusal enumerates them — sent `project` in both, it answered `anchorType must
2021        // be one of the following values: start, end, milestone` — and `milestone` needs
2022        // an id this source never sends, so the two whole-project anchors are the whole of
2023        // what it can send.
2024        //
2025        // **Which of them goes where carries the direction, and the two id slots do not.**
2026        // Linear stores whatever pair it is given and reads a backwards dependency as
2027        // readily as the right one, so acceptance settles nothing; what does is Linear's
2028        // own reading of a stored relation, published as the computed `ProjectFilter`
2029        // members `hasBlockingRelations` ("projects which are blocking") and
2030        // `hasBlockedByRelations` ("projects which are blocked"). Three relations between
2031        // two scratch projects, read back through them on 2026-09-04:
2032        //
2033        // | `projectId` | `anchorType` | `relatedProjectId` | `relatedAnchorType` | blocked | blocking |
2034        // | ----------- | ------------ | ------------------ | ------------------- | ------- | -------- |
2035        // | A           | `start`      | B                  | `end`               | A       | B        |
2036        // | A           | `end`        | B                  | `start`             | B       | A        |
2037        // | B           | `end`        | A                  | `start`             | A       | B        |
2038        //
2039        // Rows one and three exchange the ids and the anchors together and read alike;
2040        // rows one and two exchange only the anchors and the reading flips. So the project
2041        // anchored `start` is the one that waits, whichever slot it sits in, and row one is
2042        // what this source sends — `near`, the item that depends, in `projectId`. Linear's
2043        // own callers put the blocker there instead, so copying their `end`/`start` pair
2044        // across by position would state every dependency backwards in the workspace, and
2045        // nothing would refuse it.
2046        const NEAR_ANCHOR: &str = "start";
2047        const FAR_ANCHOR: &str = "end";
2048        for edge in edges {
2049            if edge.to.kind
2050                != match kind {
2051                    WriteKind::Task => ItemKind::Task,
2052                    WriteKind::Project => ItemKind::Project,
2053                }
2054            {
2055                continue;
2056            }
2057            let far = match edge.to.id().split_once(':') {
2058                Some((source, native)) if source == self.name.as_str() => native,
2059                Some(_) => continue,
2060                None => edge.to.id(),
2061            };
2062            // A project relation is not spelled the way an issue relation is, and this is
2063            // the whole of what a project's `type` may say.
2064            //
2065            // `blocks` there is what the live journey's project write was refused for
2066            // once the two anchors above stopped being missing: Linear answered HTTP 200
2067            // with `Argument Validation Error`, the message class its input validator
2068            // raises for a value outside an accepted set, having already accepted every
2069            // field of the same input by name — which is what tells that refusal apart
2070            // from the missing-field one before it, and what says the anchors were not the
2071            // cause.
2072            //
2073            // Which field, and what it takes, was measured against the real API on
2074            // 2026-09-04 rather than inferred. Each of `blocks`, `dependsOn`, `related`
2075            // and `DEPENDENCY` was refused with `property: "type"` and
2076            // `constraints: {"isEnum": "type must be one of the following values:
2077            // dependency"}`; `dependency` was accepted. That enumeration, like the
2078            // anchors' above, reaches this source through the validator's `extensions`;
2079            // see `GqlError::said`.
2080            //
2081            // A `Related` project edge is refused at the top of this function by that same
2082            // enumeration: it has one member and it is an ordering. An issue relation is a
2083            // different relation with a different set, which does include `related`.
2084            let relation_type = match (kind, edge.kind) {
2085                (WriteKind::Project, DependencyKind::Blocks) => "dependency",
2086                (WriteKind::Task, DependencyKind::Blocks) => "blocks",
2087                (WriteKind::Task, DependencyKind::Related) => "related",
2088                // Unreachable past `write_project`'s guard, and an error rather than a
2089                // skip so it stays that way: an edge dropped here would be a copy
2090                // reporting success for a dependency the destination does not hold.
2091                (WriteKind::Project, DependencyKind::Related) => {
2092                    return Err(self.unordered_project_relation(near, edge.to.id()));
2093                }
2094            };
2095            let (query, input) = if matches!(kind, WriteKind::Project) {
2096                (
2097                    graphql::PROJECT_RELATION_CREATE,
2098                    json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type,"anchorType":NEAR_ANCHOR,"relatedAnchorType":FAR_ANCHOR}),
2099                )
2100            } else {
2101                (
2102                    graphql::ISSUE_RELATION_CREATE,
2103                    json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
2104                )
2105            };
2106            let data = self.send(query, json!({"input":input})).await?;
2107            let mutation = if matches!(kind, WriteKind::Project) {
2108                MutationRoot::ProjectRelationCreate
2109            } else {
2110                MutationRoot::IssueRelationCreate
2111            };
2112            let payload = mutation_payload(&data, mutation)?;
2113            let relation = payload
2114                .get(if matches!(kind, WriteKind::Project) {
2115                    "projectRelation"
2116                } else {
2117                    "issueRelation"
2118                })
2119                .ok_or_else(|| SourceError::Malformed {
2120                    message: format!("missing {} relation", mutation.as_str()),
2121                })?;
2122            backend_id(relation, "id")?;
2123        }
2124        Ok(())
2125    }
2126
2127    async fn prepare_edges(
2128        &self,
2129        edges: &[DependencyEdge],
2130        kind: WriteKind,
2131    ) -> Result<Vec<DependencyEdge>, SourceError> {
2132        let mut prepared = Vec::with_capacity(edges.len());
2133        for edge in edges {
2134            let mut edge = edge.clone();
2135            if edge.to.kind
2136                == match kind {
2137                    WriteKind::Task => ItemKind::Task,
2138                    WriteKind::Project => ItemKind::Project,
2139                }
2140                && edge
2141                    .to
2142                    .id()
2143                    .split_once(':')
2144                    .is_some_and(|(source, _)| source != self.name.as_str())
2145            {
2146                // Narrowed to what this source holds — its team, and its project when it is
2147                // scoped to one — and, for an issue, to the ones whose description carries
2148                // the far end as its origin: a copy of the far end is an item of this source,
2149                // and an item of another team or project carrying the same origin is not one
2150                // this source could relate. The origin is confirmed over the parsed slot below.
2151                let filter = match kind {
2152                    WriteKind::Project => Self::narrowed(self.project_scope()),
2153                    WriteKind::Task => {
2154                        let mut parts = self.issue_scope();
2155                        parts.extend(slot_phrase(edge.to.id()));
2156                        Self::narrowed(parts)
2157                    }
2158                };
2159                let mut cursor: Option<Cursor> = None;
2160                loop {
2161                    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":filter})).await?;
2162                    let (items, next) = if matches!(kind, WriteKind::Project) {
2163                        let page = connection(&data, "projects", map_project)?;
2164                        (
2165                            page.items
2166                                .into_iter()
2167                                .map(|item| (item.id, item.metadata))
2168                                .collect::<Vec<_>>(),
2169                            page.next,
2170                        )
2171                    } else {
2172                        let page = connection(&data, "issues", |v| {
2173                            map_task(v, &self.name, &self.statuses)
2174                        })?;
2175                        (
2176                            page.items
2177                                .into_iter()
2178                                .map(|item| (item.id, item.metadata))
2179                                .collect::<Vec<_>>(),
2180                            page.next,
2181                        )
2182                    };
2183                    if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
2184                        metadata.get("onetaskgraph.origin").and_then(Value::as_str)
2185                            == Some(edge.to.id())
2186                    }) {
2187                        edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
2188                        break;
2189                    }
2190                    let Some(next) = next else { break };
2191                    cursor = Some(next);
2192                }
2193            }
2194            prepared.push(edge);
2195        }
2196        Ok(prepared)
2197    }
2198}
2199
2200#[async_trait::async_trait]
2201impl TaskSource for LinearSource {
2202    fn kind(&self) -> &'static str {
2203        KIND
2204    }
2205    fn capabilities(&self) -> Capabilities {
2206        Capabilities {
2207            projects: Support::Native,
2208            documents: Support::Native,
2209            comments: Support::Native,
2210            priority: Support::Native,
2211            filter_by_priority: Support::Native,
2212            filter_by_comment_activity: Support::Native,
2213            filter_by_metadata: Support::Native,
2214            filter_by_origin: Support::Native,
2215            orphan_tasks: Support::Native,
2216            filter_by_label: Support::Native,
2217            filter_by_status: Support::Native,
2218            search_title: Support::Native,
2219            search_content: Support::Native,
2220            task_dependencies: DependencySupport::BothDirections,
2221            project_dependencies: DependencySupport::BothDirections,
2222            max_page_size: MAX_PAGE_SIZE,
2223        }
2224    }
2225    fn writes(&self) -> WriteSupport {
2226        WriteSupport::Supported
2227    }
2228    async fn health(&self) -> Result<Health, SourceError> {
2229        let data = self.send(VIEWER, json!({})).await?;
2230        str_at(
2231            data.get("viewer").ok_or_else(|| SourceError::Malformed {
2232                message: "missing viewer".into(),
2233            })?,
2234            "id",
2235        )?;
2236        Ok(Health {
2237            reachable: true,
2238            detail: None,
2239        })
2240    }
2241    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
2242        Ok(self.issue_held(id).await?.map(|(task, _)| task))
2243    }
2244    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
2245        Ok(self.project_held(id).await?.map(|(project, _)| project))
2246    }
2247    async fn query_tasks(
2248        &self,
2249        query: &TaskQuery,
2250        page: &PageRequest,
2251    ) -> Result<Page<Task>, SourceError> {
2252        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)?})).await?;
2253        let page = connection(&d, "issues", |v| map_task(v, &self.name, &self.statuses))?;
2254        Ok(Page {
2255            items: page
2256                .items
2257                .into_iter()
2258                .filter(|task| Self::confirms(query, task))
2259                .collect(),
2260            next: page.next,
2261        })
2262    }
2263    async fn query_projects(
2264        &self,
2265        query: &ProjectQuery,
2266        page: &PageRequest,
2267    ) -> Result<Page<Project>, SourceError> {
2268        // 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.
2269        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?;
2270        let page = connection(&d, "projects", map_project)?;
2271        // A project's text is applied here, over the page Linear answered: `search_title` and
2272        // `search_content` are declared for every level, and `ProjectFilter` is not asked for
2273        // a description match — so the rule decides, over every row of the page.
2274        Ok(Page {
2275            items: page
2276                .items
2277                .into_iter()
2278                .filter(|project| {
2279                    query.text.as_ref().is_none_or(|text| {
2280                        text_holds(&project.title, project.content.as_deref(), text)
2281                    })
2282                })
2283                .collect(),
2284            next: page.next,
2285        })
2286    }
2287    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
2288        let d = self
2289            .send(
2290                LABELS,
2291                json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0)}),
2292            )
2293            .await?;
2294        connection(&d, "issueLabels", map_label)
2295    }
2296    async fn task_dependencies(
2297        &self,
2298        id: &NativeId,
2299        direction: Direction,
2300        page: &PageRequest,
2301    ) -> Result<Page<DependencyEdge>, SourceError> {
2302        self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
2303            .await
2304    }
2305    async fn project_dependencies(
2306        &self,
2307        id: &NativeId,
2308        direction: Direction,
2309        page: &PageRequest,
2310    ) -> Result<Page<DependencyEdge>, SourceError> {
2311        self.dependencies(
2312            PROJECT_RELATIONS,
2313            DependencyRoot::Project,
2314            id,
2315            direction,
2316            page,
2317        )
2318        .await
2319    }
2320    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
2321        // Before anything is read or written, because nothing Linear could answer changes
2322        // either: neither list may name the task itself or name one task twice, a category
2323        // this source has disabled is refused in the words a status write is refused with,
2324        // and a project other than the one this source is scoped to is refused naming both.
2325        let near = write.target.as_ref().unwrap_or(&write.item.id);
2326        for (key, entries) in [
2327            (TaskRef::DELIVERS_KEY, &write.item.delivers),
2328            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
2329        ] {
2330            TaskRef::listed(key, near, Some(&self.name), entries.clone())
2331                .map_err(|message| SourceError::Refused { message })?;
2332        }
2333        if self.statuses.target(write.item.status.category) == StateTarget::DisabledByMapping {
2334            return Err(self.disabled(write.item.status.category, true));
2335        }
2336        let project = self.filed_in(write.item.project.as_ref(), "task")?;
2337        let edges = self
2338            .prepare_edges(&write.depends_on, WriteKind::Task)
2339            .await?;
2340        let team = self.team_id().await?;
2341        // A mapped category is written as the state the mapping names, whatever the status
2342        // was called where it came from; one the mapping leaves out keeps the lookup a source
2343        // without the key has always made, by the status's own name.
2344        let state = match self.statuses.target(write.item.status.category) {
2345            StateTarget::Named(name) => {
2346                self.named_state(&name.0, write.item.status.category)
2347                    .await?
2348                    .0
2349            }
2350            StateTarget::FirstOfType(_)
2351            | StateTarget::DisabledByMapping
2352            | StateTarget::NoWorkflowType => {
2353                self.one_id(Lookup::IssueState {
2354                    name: &write.item.status.name,
2355                    team: &team,
2356                })
2357                .await?
2358            }
2359        };
2360        let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
2361        // The typed lists are what land, whatever the caller's own metadata held under their
2362        // keys: a key of either name travelling beside the field would be a second answer to
2363        // the same question, and the field is the one the contract names.
2364        let mut metadata = write.item.metadata.clone();
2365        set_task_list(&mut metadata, TaskRef::DELIVERS_KEY, &write.item.delivers);
2366        set_task_list(
2367            &mut metadata,
2368            TaskRef::DELIVERED_BY_KEY,
2369            &write.item.delivered_by,
2370        );
2371        let description = self.write_description(
2372            write.item.content.as_deref(),
2373            &metadata,
2374            &write.item.repositories,
2375            &edges,
2376            WriteKind::Task,
2377        )?;
2378        // `priority` on a create and on an update alike, `0` included: an update that left
2379        // it out would keep whatever the destination held, so a copy moving an issue back to
2380        // no priority would report success for a priority the destination still carries.
2381        let input = json!({"title":write.item.title,"description":description,"stateId":state,"priority":linear_priority(write.item.priority),"labelIds":labels,"projectId":project});
2382        let (query, variables, root) = match &write.target {
2383            Some(id) => (
2384                graphql::ISSUE_UPDATE,
2385                json!({"id":id.0,"input":input}),
2386                MutationRoot::IssueUpdate,
2387            ),
2388            None => (
2389                graphql::ISSUE_CREATE,
2390                {
2391                    let mut input = input;
2392                    input["teamId"] = Value::String(team.0);
2393                    json!({"input":input})
2394                },
2395                MutationRoot::IssueCreate,
2396            ),
2397        };
2398        let data = self.send(query, variables).await?;
2399        let issue =
2400            mutation_payload(&data, root)?
2401                .get("issue")
2402                .ok_or_else(|| SourceError::Malformed {
2403                    message: format!("missing {}.issue", root.as_str()),
2404                })?;
2405        let id = NativeId(backend_id(issue, "id")?.into());
2406        self.write_relations(&id, &edges, WriteKind::Task).await?;
2407        Ok(id)
2408    }
2409    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
2410        // Before anything is read or written, and before the item's own description
2411        // records these edges: an edge Linear will never accept has to refuse the whole
2412        // write, or a copy would create the project and then fail relating it, leaving the
2413        // undo to clean up a write that could have been refused without a call at all.
2414        if let Some(edge) = Self::unordered_project_edge(&write.depends_on) {
2415            return Err(self.unordered_project_relation(&write.item.id, edge.to.id()));
2416        }
2417        if let Some(key) = delivery_key_in(&write.item.metadata) {
2418            return Err(self.undeliverable(key, "project"));
2419        }
2420        // A source scoped to one project holds that project and no other, so it writes no
2421        // other: a project it created would be one none of its reads could find.
2422        if let Some(scope) = &self.project
2423            && write
2424                .target
2425                .as_ref()
2426                .is_none_or(|target| target.0 != scope.0)
2427        {
2428            return Err(SourceError::Refused {
2429                message: format!(
2430                    "source {} is scoped to the Linear project {} and holds no other project, \
2431                     so it cannot write {}; next: copy the project to a source with no \
2432                     `project`, or copy its tasks here",
2433                    self.name,
2434                    scope.0,
2435                    write.target.as_ref().map_or_else(
2436                        || "a new one".to_owned(),
2437                        |target| format!("the project {}", target.0)
2438                    ),
2439                ),
2440            });
2441        }
2442        let edges = self
2443            .prepare_edges(&write.depends_on, WriteKind::Project)
2444            .await?;
2445        let team = self.team_id().await?;
2446        let status = self
2447            .one_id(Lookup::ProjectStatus(&write.item.status.name))
2448            .await?;
2449        let labels = self
2450            .label_ids(&write.item.labels, WriteKind::Project)
2451            .await?;
2452        let description = self.write_description(
2453            write.item.content.as_deref(),
2454            &write.item.metadata,
2455            &write.item.repositories,
2456            &edges,
2457            WriteKind::Project,
2458        )?;
2459        let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
2460        let (query, variables, root) = match &write.target {
2461            Some(id) => (
2462                graphql::PROJECT_UPDATE,
2463                json!({"id":id.0,"input":input}),
2464                MutationRoot::ProjectUpdate,
2465            ),
2466            None => (
2467                graphql::PROJECT_CREATE,
2468                {
2469                    let mut input = input;
2470                    input["teamIds"] = json!([team]);
2471                    json!({"input":input})
2472                },
2473                MutationRoot::ProjectCreate,
2474            ),
2475        };
2476        let data = self.send(query, variables).await?;
2477        let project = mutation_payload(&data, root)?
2478            .get("project")
2479            .ok_or_else(|| SourceError::Malformed {
2480                message: format!("missing {}.project", root.as_str()),
2481            })?;
2482        let id = NativeId(backend_id(project, "id")?.into());
2483        self.write_relations(&id, &edges, WriteKind::Project)
2484            .await?;
2485        Ok(id)
2486    }
2487    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
2488        // An id naming nothing is the state this asks for, not an error — Linear reports
2489        // an unknown issue as an errored response rather than an unsuccessful payload, and
2490        // `get_task` answering `None` is what says the item is already gone.
2491        if self.get_task(id).await?.is_none() {
2492            return Ok(());
2493        }
2494        let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
2495        mutation_payload(&data, MutationRoot::IssueDelete)?;
2496        Ok(())
2497    }
2498    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
2499        // An id naming nothing is the state this asks for, on exactly the terms
2500        // `delete_task` reads it on.
2501        if self.get_project(id).await?.is_none() {
2502            return Ok(());
2503        }
2504        let data = self
2505            .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
2506            .await?;
2507        mutation_payload(&data, MutationRoot::ProjectDelete)?;
2508        Ok(())
2509    }
2510    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
2511        // Read as an optional although the pinned `document(id:)` returns `Document!`, for
2512        // the reason `delete_task` records: Linear answers an id naming nothing with an
2513        // errored response rather than a null, and reading the null defensively is what
2514        // keeps a responder that does answer one from being a malformed-response failure.
2515        Ok(self.document_held(id).await?.map(|(document, _)| document))
2516    }
2517    async fn query_documents(
2518        &self,
2519        query: &DocumentQuery,
2520        page: &PageRequest,
2521    ) -> Result<Page<Document>, SourceError> {
2522        // A document's text is applied over each fetched page with the labels and the
2523        // orphans, by the contract's own rule: both searches are declared for every level.
2524        let want = page.limit.min(MAX_PAGE_SIZE) as usize;
2525        let mut filter = serde_json::Map::new();
2526        if let ProjectFilter::Is(id) = &query.project {
2527            if !self.in_scope(Some(id)) {
2528                return Ok(Page::last(Vec::new()));
2529            }
2530            filter.insert("project".into(), json!({"id": {"eq": id.0}}));
2531        } else if let Some(scope) = &self.project {
2532            filter.insert("project".into(), json!({"id": {"eq": scope.0}}));
2533        }
2534        let filter = Value::Object(filter);
2535        let mut items = Vec::new();
2536        let mut cursor = page.cursor.clone();
2537        loop {
2538            // Only what is still owed, so the predicates applied here can never make this
2539            // return more than the caller asked for, and never drop what it fetched.
2540            let first = want.saturating_sub(items.len()).max(1);
2541            let d = self
2542                .send(
2543                    DOCUMENTS,
2544                    json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
2545                )
2546                .await?;
2547            let fetched = connection(&d, "documents", map_document)?;
2548            items.extend(fetched.items.into_iter().filter(|document| {
2549                document_matches(document, &query.project, &query.labels)
2550                    && self.in_scope(document.project.as_ref())
2551                    && query.text.as_ref().is_none_or(|text| {
2552                        text_holds(&document.title, document.content.as_deref(), text)
2553                    })
2554            }));
2555            cursor = fetched.next;
2556            if cursor.is_none() || items.len() >= want {
2557                return Ok(Page {
2558                    items,
2559                    next: cursor,
2560                });
2561            }
2562        }
2563    }
2564    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
2565        // Two refusals by name rather than two silent drops. Linear's own document type
2566        // has no labels and a document is not work, so neither a label nor a dependency
2567        // has anywhere here to land — and a copy that dropped one would report success for
2568        // an item the destination does not hold.
2569        if !write.item.labels.is_empty() {
2570            let named = write
2571                .item
2572                .labels
2573                .iter()
2574                .map(|label| label.name.as_str())
2575                .collect::<Vec<_>>()
2576                .join(", ");
2577            return Err(SourceError::Refused {
2578                message: format!(
2579                    "source {} cannot carry a document's labels, because Linear's own \
2580                     document type has none: {named}",
2581                    self.name
2582                ),
2583            });
2584        }
2585        if !write.depends_on.is_empty()
2586            || write
2587                .item
2588                .metadata
2589                .contains_key(DependencyEdge::RECORDED_KEY)
2590        {
2591            return Err(SourceError::Refused {
2592                message: format!(
2593                    "source {} cannot carry {} on a document, because a document is not \
2594                     work and nothing may depend on one",
2595                    self.name,
2596                    DependencyEdge::RECORDED_KEY
2597                ),
2598            });
2599        }
2600        if let Some(key) = delivery_key_in(&write.item.metadata) {
2601            return Err(self.undeliverable(key, "document"));
2602        }
2603        let content = Self::long_form(
2604            write.item.content.as_deref(),
2605            &write.item.metadata,
2606            &write.item.repositories,
2607            Vec::new(),
2608        )?;
2609        let project = self.filed_in(write.item.project.as_ref(), "document")?;
2610        let (query, variables, root) = match &write.target {
2611            Some(id) => {
2612                // A target this workspace does not hold is refused rather than created:
2613                // the engine established that id before asking, so an absent one is a race
2614                // this destination must not paper over by writing a second document.
2615                if self.get_document(id).await?.is_none() {
2616                    return Err(SourceError::Refused {
2617                        message: format!("source {} holds no document {}", self.name, id.0),
2618                    });
2619                }
2620                (
2621                    graphql::DOCUMENT_UPDATE,
2622                    json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
2623                    MutationRoot::DocumentUpdate,
2624                )
2625            }
2626            None => {
2627                let mut input = json!({"title":write.item.title,"content":content});
2628                // A Linear document lives in a project, an initiative, an issue or a team.
2629                // One filed under no project needs the configured team to be its home, and
2630                // one filed under a project already has one — so the team is asked for
2631                // only where it is the answer, rather than made a condition of every write.
2632                //
2633                // **`projectId` is left out rather than sent as null, and that is Linear's
2634                // rule rather than tidiness.** `documentCreate` refuses an input that names
2635                // more than one home — `Exactly one of initiativeId, teamId, issueId,
2636                // releaseId, cycleId or projectId must be defined.` — and it counts a
2637                // *present* key, observed on 2026-09-04: `{projectId: null, teamId: …}` is
2638                // refused where `{teamId: …}` is accepted. So a document filed under no
2639                // project must carry no `projectId` at all. `documentUpdate` is the
2640                // opposite and keeps its explicit null, because there the null is the
2641                // instruction — it is how a document is moved out of a project, and
2642                // omitting the key would leave it where it was.
2643                match &project {
2644                    Some(project) => input["projectId"] = Value::String(project.clone()),
2645                    None => input["teamId"] = Value::String(self.team_id().await?.0),
2646                }
2647                (
2648                    graphql::DOCUMENT_CREATE,
2649                    json!({ "input": input }),
2650                    MutationRoot::DocumentCreate,
2651                )
2652            }
2653        };
2654        let data = self.send(query, variables).await?;
2655        let document = mutation_payload(&data, root)?
2656            .get("document")
2657            .ok_or_else(|| SourceError::Malformed {
2658                message: format!("missing {}.document", root.as_str()),
2659            })?;
2660        Ok(NativeId(backend_id(document, "id")?.into()))
2661    }
2662    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
2663        // An id naming nothing is the state this asks for, on exactly the terms
2664        // `delete_task` reads it on.
2665        if self.get_document(id).await?.is_none() {
2666            return Ok(());
2667        }
2668        let data = self
2669            .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
2670            .await?;
2671        mutation_payload(&data, MutationRoot::DocumentDelete)?;
2672        Ok(())
2673    }
2674    async fn task_comments(
2675        &self,
2676        task: &NativeId,
2677        page: &PageRequest,
2678    ) -> Result<Option<Page<Comment>>, SourceError> {
2679        // A page of no rows is not a page: refused here rather than sent as `last: 0`, which
2680        // would answer an empty page that reads as a task with no comments.
2681        if page.limit == 0 {
2682            return Err(SourceError::Config {
2683                message: "a page limit of 0 is not a page; ask for at least 1 comment".to_owned(),
2684            });
2685        }
2686        // One request rather than a task lookup and then a read: the issue the comments
2687        // hang off answers "no such task" by itself, on exactly the terms `get_task` reads
2688        // it — null, or trashed.
2689        let d = self
2690            .send(
2691                graphql::ISSUE_COMMENTS,
2692                json!({"id":task.0,"last":page.limit.min(MAX_PAGE_SIZE),"before":page.cursor.as_ref().map(|c|&c.0)}),
2693            )
2694            .await?;
2695        optional(&d, "issue", comment_page)
2696    }
2697    async fn add_comment(
2698        &self,
2699        task: &NativeId,
2700        comment: &NewComment,
2701    ) -> Result<Option<Comment>, SourceError> {
2702        // Before anything is sent, because nothing Linear could answer changes it: see the
2703        // ruling on the author in this crate's module documentation.
2704        if let Some(author) = &comment.author {
2705            return Err(SourceError::Refused {
2706                message: format!(
2707                    "source {} cannot post a comment as {author:?}, because Linear records the \
2708                     user whose API key makes the request as the author of every comment; \
2709                     leave --author out to post as that user",
2710                    self.name
2711                ),
2712            });
2713        }
2714        let Some(issue) = self.commented_issue(task).await? else {
2715            return Ok(None);
2716        };
2717        let data = self
2718            .send(
2719                graphql::COMMENT_CREATE,
2720                json!({"input":{"issueId":issue.0,"body":comment.body.as_str()}}),
2721            )
2722            .await?;
2723        written_comment(&data, MutationRoot::CommentCreate).map(Some)
2724    }
2725    async fn edit_comment(
2726        &self,
2727        task: &NativeId,
2728        comment: &NativeId,
2729        body: &CommentBody,
2730    ) -> Result<Option<Comment>, SourceError> {
2731        if !self.comment_is_on(task, comment).await? {
2732            return Ok(None);
2733        }
2734        // `body` alone: the id, the author and the time it was written are the comment's
2735        // own, so nothing else is sent that Linear could move.
2736        let data = self
2737            .send(
2738                graphql::COMMENT_UPDATE,
2739                json!({"id":comment.0,"input":{"body":body.as_str()}}),
2740            )
2741            .await?;
2742        written_comment(&data, MutationRoot::CommentUpdate).map(Some)
2743    }
2744    async fn delete_comment(
2745        &self,
2746        task: &NativeId,
2747        comment: &NativeId,
2748    ) -> Result<Option<NativeId>, SourceError> {
2749        if !self.comment_is_on(task, comment).await? {
2750            return Ok(None);
2751        }
2752        let data = self
2753            .send(graphql::COMMENT_DELETE, json!({"id":comment.0}))
2754            .await?;
2755        mutation_payload(&data, MutationRoot::CommentDelete)?;
2756        Ok(Some(comment.clone()))
2757    }
2758    async fn set_task_status(
2759        &self,
2760        id: &NativeId,
2761        category: StatusCategory,
2762    ) -> Result<Option<Status>, SourceError> {
2763        // Before any request: a category no workflow state has is not one Linear could
2764        // answer differently for another issue.
2765        self.writable(category)?;
2766        let Some(task) = self.get_task(id).await? else {
2767            return Ok(None);
2768        };
2769        // Already in the category asked for: its own state is left where it is. A team can
2770        // hold several states of one type — `In Progress` and `In Review` are both `started`
2771        // — and moving an issue from one to the other is a change nobody asked for.
2772        if task.status.category == category {
2773            return Ok(Some(task.status));
2774        }
2775        let (state_id, name) = self.resolve_state(category, id).await?;
2776        // `stateId` alone, so nothing else about the issue can move: Linear's
2777        // `IssueUpdateInput` makes every member optional and leaves an absent one as it was.
2778        let data = self
2779            .send(
2780                graphql::ISSUE_UPDATE,
2781                json!({"id":task.id.0,"input":{"stateId":state_id.0}}),
2782            )
2783            .await?;
2784        let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
2785            .get("issue")
2786            .ok_or_else(|| SourceError::Malformed {
2787                message: "missing issueUpdate.issue".into(),
2788            })?;
2789        backend_id(issue, "id")?;
2790        Ok(Some(Status { category, name }))
2791    }
2792    async fn set_task_priority(
2793        &self,
2794        id: &NativeId,
2795        priority: Priority,
2796    ) -> Result<Option<Priority>, SourceError> {
2797        // Read first, on exactly the terms `set_task_status` reads: Linear answers an
2798        // `issueUpdate` naming no issue with an errored response rather than a null, so the
2799        // read is what tells "no such task" from a refusal — and a trashed issue is not one
2800        // this source holds, so it is never written to.
2801        let Some(task) = self.get_task(id).await? else {
2802            return Ok(None);
2803        };
2804        // `priority` alone: every member of `IssueUpdateInput` is optional and Linear leaves
2805        // an absent one as the issue holds it, so nothing else about the issue can move.
2806        let data = self
2807            .send(
2808                graphql::ISSUE_PRIORITY_UPDATE,
2809                json!({"id":task.id.0,"input":{"priority":linear_priority(priority)}}),
2810            )
2811            .await?;
2812        let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
2813            .get("issue")
2814            .filter(|issue| !issue.is_null())
2815            .ok_or_else(|| SourceError::Malformed {
2816                message: "missing issueUpdate.issue".into(),
2817            })?;
2818        written_is(issue, &task.id)?;
2819        issue_priority(issue).map(Some)
2820    }
2821    async fn set_task_content(
2822        &self,
2823        id: &NativeId,
2824        content: &str,
2825    ) -> Result<Option<()>, SourceError> {
2826        // The raw description, because the metadata slot lives in that same field and has to
2827        // go back byte for byte: re-encoding it would be a metadata write nobody asked for.
2828        // The whole issue is read as `get_task` reads it first, so an issue this source could
2829        // not read is refused before anything is written rather than overwritten blind.
2830        let Some((task, description)) = self.issue_held(id).await? else {
2831            return Ok(None);
2832        };
2833        let issue = task.id;
2834        let slot = match description.as_deref() {
2835            Some(description) => metadata_slot(description)?.map(str::to_owned),
2836            None => None,
2837        };
2838        let description = match &slot {
2839            None => content.to_owned(),
2840            Some(slot) if content.is_empty() => slot.clone(),
2841            // The separator `long_form` writes, so a content write and a copy leave one shape.
2842            Some(slot) => format!("{content}\n\n{slot}"),
2843        };
2844        // Checked before anything is sent: content ending in what this source reads as its own
2845        // metadata slot would read back as metadata rather than as the content it was.
2846        if metadata_slot(&description)? != slot.as_deref() {
2847            return Err(SourceError::Refused {
2848                message: format!(
2849                    "this content ends in what source {} reads as its own metadata slot, so part \
2850                     of it would read back as metadata rather than as content; next: remove that \
2851                     trailing block from the content",
2852                    self.name
2853                ),
2854            });
2855        }
2856        // And what a read will report is exactly what was asked for, or nothing is sent.
2857        let (reads, _) = metadata_description(Some(description.clone()))?;
2858        if reads.as_deref().unwrap_or_default() != content {
2859            return Err(SourceError::Refused {
2860                message: format!(
2861                    "this content would read back from source {} as {:?} rather than as itself; \
2862                     next: change how the content ends",
2863                    self.name,
2864                    reads.as_deref().unwrap_or_default()
2865                ),
2866            });
2867        }
2868        // `description` alone, for the reason `set_task_priority` sends `priority` alone.
2869        let data = self
2870            .send(
2871                graphql::ISSUE_UPDATE,
2872                json!({"id":issue.0,"input":{"description":description}}),
2873            )
2874            .await?;
2875        let written = mutation_payload(&data, MutationRoot::IssueUpdate)?
2876            .get("issue")
2877            .filter(|issue| !issue.is_null())
2878            .ok_or_else(|| SourceError::Malformed {
2879                message: "missing issueUpdate.issue".into(),
2880            })?;
2881        written_is(written, &issue)?;
2882        Ok(Some(()))
2883    }
2884    async fn set_delivered_by(
2885        &self,
2886        id: &NativeId,
2887        delivered_by: &[TaskRef],
2888    ) -> Result<Option<()>, SourceError> {
2889        let entries = TaskRef::listed(
2890            TaskRef::DELIVERED_BY_KEY,
2891            id,
2892            Some(&self.name),
2893            delivered_by.to_vec(),
2894        )
2895        .map_err(|message| SourceError::Refused { message })?;
2896        let Some((task, description)) = self.issue_held(id).await? else {
2897            return Ok(None);
2898        };
2899        let (_, held) = metadata_description(description.clone())?;
2900        let mut slot = held.clone();
2901        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
2902        // Compared as JSON rather than as the field's bytes, so a slot already holding the
2903        // list is not rewritten for its spelling alone.
2904        if slot != held {
2905            let rewritten = reslotted(description.as_deref(), &slot)?;
2906            self.write_description_alone(&task.id, rewritten.as_deref())
2907                .await?;
2908        }
2909        Ok(Some(()))
2910    }
2911    /// One read of the issue and, unless the key already holds the value, one `issueUpdate`
2912    /// carrying the description alone, whose metadata slot is the only part that moved.
2913    async fn set_task_metadata(
2914        &self,
2915        id: &NativeId,
2916        key: &MetadataKey,
2917        value: &Value,
2918    ) -> Result<Option<Task>, SourceError> {
2919        let Some((task, description)) = self.issue_held(id).await? else {
2920            return Ok(None);
2921        };
2922        let (_, mut slot) = metadata_description(description.clone())?;
2923        if slot.get(key.as_str()) == Some(value) {
2924            return Ok(Some(task));
2925        }
2926        slot.insert(key.as_str().to_owned(), value.clone());
2927        let rewritten = reslotted(description.as_deref(), &slot)?;
2928        self.write_description_alone(&task.id, rewritten.as_deref())
2929            .await?;
2930        self.get_task(&task.id)
2931            .await?
2932            .map(Some)
2933            .ok_or_else(|| SourceError::Malformed {
2934                message: format!(
2935                    "task {} was written and then could not be read back",
2936                    task.id
2937                ),
2938            })
2939    }
2940    /// One metadata key of a project, on the terms of `set_task_metadata`: one read, and one
2941    /// `projectUpdate` carrying the description alone.
2942    async fn set_project_metadata(
2943        &self,
2944        id: &NativeId,
2945        key: &MetadataKey,
2946        value: &Value,
2947    ) -> Result<Option<Project>, SourceError> {
2948        let Some((project, description)) = self.project_held(id).await? else {
2949            return Ok(None);
2950        };
2951        let (_, mut slot) = metadata_description(description.clone())?;
2952        if slot.get(key.as_str()) == Some(value) {
2953            return Ok(Some(project));
2954        }
2955        slot.insert(key.as_str().to_owned(), value.clone());
2956        let rewritten = reslotted(description.as_deref(), &slot)?;
2957        self.write_project_description(&project.id, rewritten.as_deref())
2958            .await?;
2959        self.get_project(&project.id)
2960            .await?
2961            .map(Some)
2962            .ok_or_else(|| SourceError::Malformed {
2963                message: format!(
2964                    "project {} was written and then could not be read back",
2965                    project.id
2966                ),
2967            })
2968    }
2969    /// One metadata key of a document, on the terms of `set_task_metadata`: one read, and one
2970    /// `documentUpdate` carrying the content alone.
2971    async fn set_document_metadata(
2972        &self,
2973        id: &NativeId,
2974        key: &MetadataKey,
2975        value: &Value,
2976    ) -> Result<Option<Document>, SourceError> {
2977        let Some((document, content)) = self.document_held(id).await? else {
2978            return Ok(None);
2979        };
2980        let (_, mut slot) = metadata_description(content.clone())?;
2981        if slot.get(key.as_str()) == Some(value) {
2982            return Ok(Some(document));
2983        }
2984        slot.insert(key.as_str().to_owned(), value.clone());
2985        let rewritten = reslotted(content.as_deref(), &slot)?;
2986        self.write_document_content(&document.id, rewritten.as_deref())
2987            .await?;
2988        self.get_document(&document.id)
2989            .await?
2990            .map(Some)
2991            .ok_or_else(|| SourceError::Malformed {
2992                message: format!(
2993                    "document {} was written and then could not be read back",
2994                    document.id
2995                ),
2996            })
2997    }
2998    /// One read of the issue and one `issueUpdate` carrying the description alone: the new
2999    /// content, and a slot whose `onetaskgraph.template` entry is `provenance` and whose every
3000    /// other entry is as it was. This source keeps no template answers — an issue has no room
3001    /// beside its description that is not the description, and answers written there would
3002    /// repeat what the content already says — so `answers` reaches nothing here.
3003    async fn set_task_rendering(
3004        &self,
3005        id: &NativeId,
3006        content: &str,
3007        provenance: &Value,
3008        _answers: &std::collections::BTreeMap<String, Value>,
3009    ) -> Result<Option<()>, SourceError> {
3010        let Some((task, description)) = self.issue_held(id).await? else {
3011            return Ok(None);
3012        };
3013        let (_, mut slot) = metadata_description(description.clone())?;
3014        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
3015        // No read-back check, unlike a content write: this slot is never empty, it is written
3016        // after the content, and a read takes the last slot off first, so the content reads
3017        // back as itself whatever it ends in.
3018        let rewritten = Self::described(Some(content), &slot)?;
3019        if rewritten != description {
3020            self.write_description_alone(&task.id, rewritten.as_deref())
3021                .await?;
3022        }
3023        Ok(Some(()))
3024    }
3025    /// One document's rendering, on the terms of `set_task_rendering`, through one
3026    /// `documentUpdate` carrying the content alone.
3027    async fn set_document_rendering(
3028        &self,
3029        id: &NativeId,
3030        content: &str,
3031        provenance: &Value,
3032        _answers: &std::collections::BTreeMap<String, Value>,
3033    ) -> Result<Option<()>, SourceError> {
3034        let Some((document, held)) = self.document_held(id).await? else {
3035            return Ok(None);
3036        };
3037        let (_, mut slot) = metadata_description(held.clone())?;
3038        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
3039        let rewritten = Self::described(Some(content), &slot)?;
3040        if rewritten != held {
3041            self.write_document_content(&document.id, rewritten.as_deref())
3042                .await?;
3043        }
3044        Ok(Some(()))
3045    }
3046    /// One read of the issue and one `issueUpdate` carrying only what differs; see
3047    /// `targeted_update`.
3048    async fn update_task(
3049        &self,
3050        id: &NativeId,
3051        update: &TaskUpdate,
3052    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
3053        self.targeted_update(id, update).await
3054    }
3055}
3056
3057/// Refuse a narrow project or document write whose payload names another item than the one it
3058/// was sent for, on the terms [`written_is`] refuses an issue's.
3059fn acknowledged(item: &Value, asked: &NativeId, root: MutationRoot) -> Result<(), SourceError> {
3060    let written = backend_id(item, "id")?;
3061    if written == asked.0 {
3062        return Ok(());
3063    }
3064    Err(SourceError::Malformed {
3065        message: format!(
3066            "{} for {asked} answered with the item {written}",
3067            root.as_str()
3068        ),
3069    })
3070}
3071
3072/// Refuse a narrow write's payload naming an issue other than the one it was sent for.
3073///
3074/// An `issueUpdate` answering with another issue is not this write landing, so it is reported
3075/// as the malformed answer it is rather than as the task having been written.
3076fn written_is(issue: &Value, asked: &NativeId) -> Result<(), SourceError> {
3077    let written = backend_id(issue, "id")?;
3078    if written == asked.0 {
3079        return Ok(());
3080    }
3081    Err(SourceError::Malformed {
3082        message: format!("issueUpdate for {asked} answered with the issue {written}"),
3083    })
3084}
3085
3086/// Why a project and a document carry neither [`Task::delivers`] nor [`Task::delivered_by`].
3087///
3088/// Only a task delivers or is delivered, so the two reserved keys name nothing a project or a
3089/// document has. A task keeps both in its description's metadata slot; anything else naming
3090/// one is refused by name rather than written.
3091const NO_DELIVERY: &str = "only a task delivers or is delivered, so neither list has a place \
3092                           on anything else";
3093
3094/// The reserved delivery key `metadata` carries, if it carries one.
3095fn delivery_key_in(metadata: &std::collections::BTreeMap<String, Value>) -> Option<&'static str> {
3096    [TaskRef::DELIVERS_KEY, TaskRef::DELIVERED_BY_KEY]
3097        .into_iter()
3098        .find(|key| metadata.contains_key(*key))
3099}
3100
3101/// A category as the wire spells it — `in-progress`, `queued` — for a message.
3102fn category_word(category: StatusCategory) -> String {
3103    serde_json::to_value(category)
3104        .ok()
3105        .and_then(|value| value.as_str().map(str::to_owned))
3106        .unwrap_or_else(|| format!("{category:?}"))
3107}
3108
3109impl LinearSource {
3110    /// The refusal of a category this source writes no workflow state for — before any request,
3111    /// because nothing Linear could answer changes it.
3112    fn writable(&self, category: StatusCategory) -> Result<(), SourceError> {
3113        match self.statuses.target(category) {
3114            StateTarget::DisabledByMapping => Err(self.disabled(category, true)),
3115            StateTarget::NoWorkflowType => Err(self.disabled(category, false)),
3116            StateTarget::Named(_) | StateTarget::FirstOfType(_) => Ok(()),
3117        }
3118    }
3119
3120    /// The refusal of a disabled category, saying which of the two it is.
3121    fn disabled(&self, category: StatusCategory, configured: bool) -> SourceError {
3122        let word = category_word(category);
3123        SourceError::Refused {
3124            message: if configured {
3125                format!(
3126                    "source {} cannot set a task's status to {word}: that category is disabled \
3127                     for this source, because its status_mapping sets {word} to null; map \
3128                     status_mapping.{word} to one of the team's workflow states to write it",
3129                    self.name
3130                )
3131            } else {
3132                format!(
3133                    "source {} cannot set a task's status to {word}: that category is disabled \
3134                     for this source, because Linear has no workflow state of that kind — its \
3135                     workflow states are triage, backlog, unstarted, started, completed and \
3136                     canceled; choose backlog, todo, in-progress, done or cancelled, or map \
3137                     status_mapping.{word} to one of the team's workflow states",
3138                    self.name
3139                )
3140            },
3141        }
3142    }
3143
3144    /// The team's workflow state a status of `category` is written as — the first Linear lists
3145    /// of that type, and deliberately no choice beyond that: every state of this type reads
3146    /// back as the category asked for, which is the whole of what a status write owes, and
3147    /// nothing a category carries says which of several the caller meant — and its name.
3148    async fn state_of(
3149        &self,
3150        category: StatusCategory,
3151        state_type: &str,
3152        task: &NativeId,
3153    ) -> Result<(NativeId, String), SourceError> {
3154        let team = self.team_id().await?;
3155        let data = self
3156            .send(
3157                graphql::ISSUE_STATE_OF_TYPE,
3158                json!({"type":state_type,"team":team.0}),
3159            )
3160            .await?;
3161        let nodes = data
3162            .get("workflowStates")
3163            .and_then(|v| v.get("nodes"))
3164            .and_then(Value::as_array)
3165            .ok_or_else(|| SourceError::Malformed {
3166                message: "missing workflowStates.nodes".into(),
3167            })?;
3168        let Some(state) = nodes.first() else {
3169            return Err(SourceError::Refused {
3170                message: format!(
3171                    "source {} cannot set task {} to {}: its configured team has no workflow \
3172                     state of type {state_type}; add one to the team in Linear",
3173                    self.name,
3174                    task.0,
3175                    category_word(category)
3176                ),
3177            });
3178        };
3179        Ok((
3180            NativeId(backend_id(state, "id")?.into()),
3181            str_at(state, "name")?.to_owned(),
3182        ))
3183    }
3184
3185    /// The refusal a write naming `named` — a field or a reserved key — on a `what` gets.
3186    fn undeliverable(&self, named: &str, what: &str) -> SourceError {
3187        SourceError::Refused {
3188            message: format!(
3189                "source {} cannot carry {named} on a {what}: {NO_DELIVERY}; write the {what} \
3190                 without it",
3191                self.name
3192            ),
3193        }
3194    }
3195
3196    /// The backend id of the issue `task` names, or `None` when this source holds no such
3197    /// task — resolved by `get_task` itself, so a comment call and a task read cannot
3198    /// disagree about whether a task is there.
3199    ///
3200    /// The id Linear answers with rather than the one asked for, because `issue(id:)` also
3201    /// takes an identifier such as `ENG-1`, and the comment's own `issue{id}` is compared
3202    /// against — and a comment is created on — the backend id.
3203    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
3204        Ok(self.get_task(task).await?.map(|task| task.id))
3205    }
3206
3207    /// Whether `comment` is a comment on the issue `task` names.
3208    ///
3209    /// Asked before any edit or removal, so an id belonging to another issue — or to no
3210    /// issue, or to nothing — is answered as no such comment without a mutation reaching
3211    /// Linear. `commentUpdate` and `commentDelete` address a comment by its id alone, so
3212    /// without this a task named in error would edit or remove somebody else's comment.
3213    async fn comment_is_on(
3214        &self,
3215        task: &NativeId,
3216        comment: &NativeId,
3217    ) -> Result<bool, SourceError> {
3218        let Some(issue) = self.commented_issue(task).await? else {
3219            return Ok(false);
3220        };
3221        let data = self.send(graphql::COMMENT, json!({"id":comment.0})).await?;
3222        Ok(optional(&data, "comment", comment_issue)?.flatten() == Some(issue))
3223    }
3224}
3225
3226impl LinearSource {
3227    /// One issue as this source reads it, beside its raw `description` — or `None` for an
3228    /// issue Linear does not hold, has trashed, or that is outside the project this source is
3229    /// scoped to.
3230    ///
3231    /// Every read of one issue goes through here, so a status, a content, a metadata and a
3232    /// rendering write all answer "no such task" on exactly the terms `get_task` does.
3233    async fn issue_held(
3234        &self,
3235        id: &NativeId,
3236    ) -> Result<Option<(Task, Option<String>)>, SourceError> {
3237        let data = self.send(ISSUE, json!({"id":id.0})).await?;
3238        Ok(optional(&data, "issue", |v| {
3239            Ok((
3240                map_task(v, &self.name, &self.statuses)?,
3241                optional_string(v, "description")?,
3242            ))
3243        })?
3244        .filter(|(task, _)| self.in_scope(task.project.as_ref())))
3245    }
3246
3247    /// One project and its raw `description`, on the terms of [`Self::issue_held`]: a source
3248    /// scoped to one project holds that one alone.
3249    async fn project_held(
3250        &self,
3251        id: &NativeId,
3252    ) -> Result<Option<(Project, Option<String>)>, SourceError> {
3253        let data = self.send(PROJECT, json!({"id":id.0})).await?;
3254        Ok(optional(&data, "project", |v| {
3255            Ok((map_project(v)?, optional_string(v, "description")?))
3256        })?
3257        .filter(|(project, _)| self.in_scope(Some(&project.id))))
3258    }
3259
3260    /// One document and its raw `content`, on the terms of [`Self::issue_held`].
3261    async fn document_held(
3262        &self,
3263        id: &NativeId,
3264    ) -> Result<Option<(Document, Option<String>)>, SourceError> {
3265        // Read as an optional although the pinned `document(id:)` returns `Document!`, for
3266        // the reason `delete_task` records: Linear answers an id naming nothing with an
3267        // errored response rather than a null, and reading the null defensively is what
3268        // keeps a responder that does answer one from being a malformed-response failure.
3269        let data = self.send(DOCUMENT, json!({"id":id.0})).await?;
3270        Ok(optional(&data, "document", |v| {
3271            Ok((map_document(v)?, optional_string(v, "content")?))
3272        })?
3273        .filter(|(document, _)| self.in_scope(document.project.as_ref())))
3274    }
3275
3276    /// Send one issue's new `description` alone, and nothing else about it.
3277    async fn write_description_alone(
3278        &self,
3279        id: &NativeId,
3280        description: Option<&str>,
3281    ) -> Result<(), SourceError> {
3282        let data = self
3283            .send(
3284                graphql::ISSUE_UPDATE,
3285                json!({"id":id.0,"input":{"description":description}}),
3286            )
3287            .await?;
3288        let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
3289            .get("issue")
3290            .filter(|issue| !issue.is_null())
3291            .ok_or_else(|| SourceError::Malformed {
3292                message: "missing issueUpdate.issue".into(),
3293            })?;
3294        written_is(issue, id)
3295    }
3296
3297    /// Send one project's new `description` alone.
3298    async fn write_project_description(
3299        &self,
3300        id: &NativeId,
3301        description: Option<&str>,
3302    ) -> Result<(), SourceError> {
3303        let data = self
3304            .send(
3305                graphql::PROJECT_UPDATE,
3306                json!({"id":id.0,"input":{"description":description}}),
3307            )
3308            .await?;
3309        let project = mutation_payload(&data, MutationRoot::ProjectUpdate)?
3310            .get("project")
3311            .filter(|project| !project.is_null())
3312            .ok_or_else(|| SourceError::Malformed {
3313                message: "missing projectUpdate.project".into(),
3314            })?;
3315        acknowledged(project, id, MutationRoot::ProjectUpdate)
3316    }
3317
3318    /// Send one document's new `content` alone.
3319    async fn write_document_content(
3320        &self,
3321        id: &NativeId,
3322        content: Option<&str>,
3323    ) -> Result<(), SourceError> {
3324        let data = self
3325            .send(
3326                graphql::DOCUMENT_UPDATE,
3327                json!({"id":id.0,"input":{"content":content}}),
3328            )
3329            .await?;
3330        let document = mutation_payload(&data, MutationRoot::DocumentUpdate)?
3331            .get("document")
3332            .filter(|document| !document.is_null())
3333            .ok_or_else(|| SourceError::Malformed {
3334                message: "missing documentUpdate.document".into(),
3335            })?;
3336        acknowledged(document, id, MutationRoot::DocumentUpdate)
3337    }
3338
3339    /// The configured team's workflow states, every one with its type, in Linear's order.
3340    async fn team_states(&self) -> Result<Vec<TeamState>, SourceError> {
3341        let team = self.team_id().await?;
3342        let data = self
3343            .send(graphql::TEAM_WORKFLOW_STATES, json!({"team":team.0}))
3344            .await?;
3345        data.get("workflowStates")
3346            .and_then(|v| v.get("nodes"))
3347            .and_then(Value::as_array)
3348            .ok_or_else(|| SourceError::Malformed {
3349                message: "missing workflowStates.nodes".into(),
3350            })?
3351            .iter()
3352            .map(|node| {
3353                Ok(TeamState {
3354                    id: NativeId(backend_id(node, "id")?.into()),
3355                    name: StateName::try_from(str_at(node, "name")?.to_owned())
3356                        .map_err(|message| SourceError::Malformed { message })?,
3357                    kind: str_at(node, "type")?.to_owned(),
3358                })
3359            })
3360            .collect()
3361    }
3362
3363    /// The team's workflow state `status_mapping` sends `category` to, by its name — or the
3364    /// refusal naming the state, the team and the category when the team has none so named.
3365    async fn named_state(
3366        &self,
3367        name: &str,
3368        category: StatusCategory,
3369    ) -> Result<(NativeId, String), SourceError> {
3370        self.team_states()
3371            .await?
3372            .into_iter()
3373            .find(|state| state.name.0.eq_ignore_ascii_case(name))
3374            .map(|state| (state.id, state.name.0))
3375            .ok_or_else(|| SourceError::Refused {
3376                message: format!(
3377                    "source {} maps status {} to the workflow state {name:?}, which team {} \
3378                     does not have; next: add {name:?} to that team in Linear's team settings, \
3379                     or point status_mapping.{} of this source at a state the team has",
3380                    self.name,
3381                    category_word(category),
3382                    self.team.as_ref().map_or("(none)", |team| team.0.as_str()),
3383                    category_word(category)
3384                ),
3385            })
3386    }
3387
3388    /// The workflow state a status of `category` is written as — the state the mapping names,
3389    /// or the team's first of the category's type — and its name.
3390    async fn resolve_state(
3391        &self,
3392        category: StatusCategory,
3393        task: &NativeId,
3394    ) -> Result<(NativeId, String), SourceError> {
3395        match self.statuses.target(category) {
3396            StateTarget::Named(name) => self.named_state(&name.0, category).await,
3397            StateTarget::FirstOfType(kind) => self.state_of(category, kind.as_str(), task).await,
3398            StateTarget::DisabledByMapping => Err(self.disabled(category, true)),
3399            StateTarget::NoWorkflowType => Err(self.disabled(category, false)),
3400        }
3401    }
3402
3403    /// What `sources fields` reports: each state the mapping names, present or missing on
3404    /// the team, with its type.
3405    async fn workflow_states(&self) -> Result<WorkflowStatesReport, SourceError> {
3406        let held = self.team_states().await?;
3407        let states = self
3408            .statuses
3409            .named()
3410            .map(|(category, name)| {
3411                let found = held
3412                    .iter()
3413                    .find(|state| state.name.0.eq_ignore_ascii_case(&name.0));
3414                MappedWorkflowState {
3415                    category,
3416                    state: name.clone(),
3417                    found: found.map_or(Found::Missing, |state| Found::Present(state.kind.clone())),
3418                }
3419            })
3420            .collect();
3421        // `team_states` has just resolved the configured team, so there is one.
3422        let team = self.team.clone().ok_or_else(|| SourceError::Refused {
3423            message: format!(
3424                "source {} needs config.team to report its states",
3425                self.name
3426            ),
3427        })?;
3428        Ok(WorkflowStatesReport {
3429            source: self.name.clone(),
3430            team,
3431            states,
3432        })
3433    }
3434}
3435
3436/// One workflow state of the configured team.
3437struct TeamState {
3438    id: NativeId,
3439    name: StateName,
3440    kind: String,
3441}
3442
3443/// `held` with its trailing metadata slot replaced by one holding exactly `slot`, and every
3444/// byte above the slot exactly as it was — or with a slot appended after one blank line where
3445/// it had none, and the slot taken off, with the one blank line that set it off, where `slot`
3446/// is empty.
3447///
3448/// The one place a narrow metadata write composes a long-form field, so a metadata set, a
3449/// `delivered_by` write and a copy-link record move nothing a person wrote.
3450fn reslotted(
3451    held: Option<&str>,
3452    slot: &std::collections::BTreeMap<String, Value>,
3453) -> Result<Option<String>, SourceError> {
3454    let held = held.unwrap_or_default();
3455    let Some((start, _, _)) = slot_bounds(held)? else {
3456        return LinearSource::described((!held.is_empty()).then_some(held), slot);
3457    };
3458    let above = &held[..start];
3459    if slot.is_empty() {
3460        let visible = above
3461            .strip_suffix("\n\n")
3462            .or_else(|| above.strip_suffix('\n'))
3463            .unwrap_or(above);
3464        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
3465    }
3466    Ok(Some(format!("{above}{}", slot_text(slot)?)))
3467}
3468
3469/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
3470fn set_task_list(
3471    metadata: &mut std::collections::BTreeMap<String, Value>,
3472    key: &str,
3473    entries: &[TaskRef],
3474) {
3475    if entries.is_empty() {
3476        metadata.remove(key);
3477    } else {
3478        metadata.insert(
3479            key.to_owned(),
3480            Value::Array(
3481                entries
3482                    .iter()
3483                    .map(|entry| Value::String(entry.as_str().to_owned()))
3484                    .collect(),
3485            ),
3486        );
3487    }
3488}
3489
3490/// One page of an issue's comments, oldest first.
3491///
3492/// Linear answered newest first, walking backwards from `before`, so the page is reversed
3493/// and the next cursor is the one *behind* it; see the ruling on comments in this crate's
3494/// module documentation for why the walk runs that way.
3495fn comment_page(v: &Value) -> Result<Page<Comment>, SourceError> {
3496    let c = v.get("comments").ok_or_else(|| SourceError::Malformed {
3497        message: "missing comments connection".into(),
3498    })?;
3499    let mut items = c
3500        .get("nodes")
3501        .and_then(Value::as_array)
3502        .ok_or_else(|| SourceError::Malformed {
3503            message: "missing comment nodes".into(),
3504        })?
3505        .iter()
3506        .map(map_comment)
3507        .collect::<Result<Vec<_>, _>>()?;
3508    items.reverse();
3509    let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
3510        message: "missing pageInfo".into(),
3511    })?;
3512    let older = info
3513        .get("hasPreviousPage")
3514        .and_then(Value::as_bool)
3515        .ok_or_else(|| SourceError::Malformed {
3516            message: "missing boolean pageInfo.hasPreviousPage".into(),
3517        })?;
3518    let next = if older {
3519        Some(Cursor(str_at(info, "startCursor")?.into()))
3520    } else {
3521        None
3522    };
3523    Ok(Page { items, next })
3524}
3525
3526fn map_comment(v: &Value) -> Result<Comment, SourceError> {
3527    let author = match v.get("user") {
3528        None => {
3529            return Err(SourceError::Malformed {
3530                message: "missing comment user field".into(),
3531            });
3532        }
3533        // An integration or a bot: Linear names no user, and this source invents none.
3534        Some(Value::Null) => None,
3535        Some(user) => Some(str_at(user, "displayName")?.to_owned()),
3536    };
3537    Ok(Comment {
3538        id: NativeId(backend_id(v, "id")?.into()),
3539        author,
3540        created_at: time(v, "createdAt")?,
3541        updated_at: time(v, "updatedAt")?,
3542        body: str_at(v, "body")?.into(),
3543        url: optional_string(v, "url")?,
3544    })
3545}
3546
3547/// The comment a `commentCreate` or `commentUpdate` answered with, as Linear now holds it.
3548fn written_comment(data: &Value, root: MutationRoot) -> Result<Comment, SourceError> {
3549    let comment = mutation_payload(data, root)?
3550        .get("comment")
3551        .ok_or_else(|| SourceError::Malformed {
3552            message: format!("missing {}.comment", root.as_str()),
3553        })?;
3554    map_comment(comment)
3555}
3556
3557/// The issue a comment is on, or `None` for a comment on something else — a project, a
3558/// document, an update — which is a comment no task of this source has.
3559fn comment_issue(v: &Value) -> Result<Option<NativeId>, SourceError> {
3560    match v.get("issue") {
3561        None => Err(SourceError::Malformed {
3562            message: "missing comment issue field".into(),
3563        }),
3564        Some(Value::Null) => Ok(None),
3565        Some(issue) => Ok(Some(NativeId(backend_id(issue, "id")?.into()))),
3566    }
3567}
3568
3569/// Linear relates one Linear item to another and nothing else, so an edge whose far end
3570/// is in a different source is the one edge no `relations` entry can hold. Those edges
3571/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
3572/// are served *after* the native relations are spent: a page under this cursor is the
3573/// recorded tail of the same walk, which keeps the native pages exactly what they were.
3574const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
3575
3576impl LinearSource {
3577    async fn dependencies(
3578        &self,
3579        query: &str,
3580        root: DependencyRoot,
3581        id: &NativeId,
3582        direction: Direction,
3583        page: &PageRequest,
3584    ) -> Result<Page<DependencyEdge>, SourceError> {
3585        let limit = page.limit.min(MAX_PAGE_SIZE);
3586        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
3587        if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
3588            // This cursor resumes the *forward* tail and only a forward walk ever issues
3589            // one, so a reverse read carrying it is resuming a walk it did not come from.
3590            // Serving it would answer a reverse read with forward edges, which is the one
3591            // thing a recorded edge must never do — its reverse is derived from the far
3592            // end and is never written down here.
3593            if direction != Direction::DependsOn {
3594                return Err(SourceError::Malformed {
3595                    message: format!(
3596                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a                          reverse dependency read never issues; resume it in the direction                          that reported it"
3597                    ),
3598                });
3599            }
3600            let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
3601                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
3602            })?;
3603            let d = self
3604                .send(query, json!({"id":id.0,"first":1,"after":null}))
3605                .await?;
3606            return Ok(recorded_page(
3607                recorded(&d, root, id, &self.name)?,
3608                offset,
3609                limit as usize,
3610            ));
3611        }
3612        let d = self
3613            .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
3614            .await?;
3615        let mut answered = relation_page(&d, root, id, direction)?;
3616        // Only forwards: the reverse of a recorded edge is derived from the far end, never
3617        // written down on the near item.
3618        if answered.next.is_none()
3619            && direction == Direction::DependsOn
3620            && !recorded(&d, root, id, &self.name)?.is_empty()
3621        {
3622            answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
3623        }
3624        Ok(answered)
3625    }
3626}
3627
3628fn recorded(
3629    d: &Value,
3630    root: DependencyRoot,
3631    id: &NativeId,
3632    name: &SourceName,
3633) -> Result<Vec<DependencyEdge>, SourceError> {
3634    let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
3635        message: format!("missing {}", root.as_str()),
3636    })?;
3637    let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
3638    // `relations` on an issue holds issues and on a project holds projects, both of this
3639    // workspace — so a same-kind far end in this same source is one Linear itself was
3640    // supposed to hold, and the key is refused rather than quietly read, whether the entry
3641    // left the source out or spelled this one.
3642    DependencyEdge::recorded(
3643        &metadata,
3644        id,
3645        root.item_kind(),
3646        name,
3647        Some(root.item_kind()),
3648    )
3649    .map_err(|message| SourceError::Malformed { message })
3650}
3651
3652fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
3653    let total = edges.len();
3654    let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
3655    let end = offset.saturating_add(items.len());
3656    Page {
3657        items,
3658        next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
3659    }
3660}
3661
3662// 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.
3663/// A category as `WorkflowState.type` spells it — the vocabulary an **issue**'s state has.
3664///
3665/// Linear's workflow states are triage, backlog, unstarted, started, completed and
3666/// canceled. None of them is a draft, so `Draft` narrows to nothing exactly as `Unknown`
3667/// does rather than filtering on a state Linear does not have.
3668///
3669/// Linear has no type for work that is claimed and not yet started: `unstarted` is `todo` and
3670/// `started` is `in-progress`, and a Linear issue reads back as one of those. So `queued`
3671/// narrows to nothing by type, exactly as `draft` does — mapping it onto either neighbour would
3672/// have a `queued` filter return an item that reads back as `todo` or `in-progress`, which is
3673/// capability rule 1 broken. A state `status_mapping` names is what reads as either.
3674fn workflow_state_types(s: &StatusCategory) -> Vec<&'static str> {
3675    WorkflowType::of(*s)
3676        .map(WorkflowType::as_str)
3677        .into_iter()
3678        .collect()
3679}
3680/// A category as `ProjectStatus.type` spells it — a **different** vocabulary, and a
3681/// different enum: Linear declares that field `ProjectStatusType!`, whose members are
3682/// backlog, planned, started, paused, completed and canceled.
3683///
3684/// Two of them have no issue counterpart and are why this cannot be the function above.
3685/// `planned` is where `unstarted` would be, so it is what `Todo` narrows to; a project
3686/// filtered with `unstarted` matches nothing and is refused by nothing, which is how this
3687/// went unnoticed. And `paused` is a project that has started and is neither finished nor
3688/// cancelled, so it reads as in progress — the same reading [`status`] gives it, which is
3689/// what keeps this narrowing and that mapping the same claim rather than two.
3690fn project_status_types(s: &StatusCategory) -> Vec<&'static str> {
3691    match s {
3692        StatusCategory::Draft => vec![],
3693        StatusCategory::Backlog => vec!["backlog"],
3694        StatusCategory::Todo => vec!["planned"],
3695        // No `ProjectStatusType` is claimed-and-not-started either, so `queued` narrows to
3696        // nothing here for the reason it does for an issue above.
3697        StatusCategory::Queued => vec![],
3698        StatusCategory::InProgress => vec!["started", "paused"],
3699        StatusCategory::Done => vec!["completed"],
3700        StatusCategory::Cancelled => vec!["canceled"],
3701        StatusCategory::Unknown => vec![],
3702    }
3703}
3704/// The category a Linear status name and type normalise to, at either level.
3705///
3706/// One mapper for both vocabularies, because the two are disjoint where they differ: no
3707/// issue is ever `planned` or `paused`, and no project is ever `unstarted` or `triage`. It
3708/// is the inverse of [`workflow_state_types`] and [`project_status_types`] together, and
3709/// has to stay so: a category this reports and that filter cannot ask for is capability
3710/// rule 1 broken, and the row would go missing rather than be refused.
3711///
3712/// **It never answers `Queued` or `Draft`**, and that is the other half of the same claim:
3713/// both filters narrow those two to nothing, because no Linear state or project status means
3714/// either, so a row this reported as one would be a row no filter for it could return. A type
3715/// Linear does not document — even one spelled `queued` — is `Unknown`, never a guess. An
3716/// issue reads as `queued` or `draft` only through [`issue_status`], at a state
3717/// `status_mapping` names, and the status filter then narrows that category by the same name.
3718fn status(v: &Value) -> Result<Status, SourceError> {
3719    let name = str_at(v, "name")?.into();
3720    let category = match str_at(v, "type")? {
3721        "backlog" => StatusCategory::Backlog,
3722        "unstarted" | "planned" => StatusCategory::Todo,
3723        "started" | "paused" => StatusCategory::InProgress,
3724        "completed" => StatusCategory::Done,
3725        "canceled" => StatusCategory::Cancelled,
3726        _ => StatusCategory::Unknown,
3727    };
3728    Ok(Status { category, name })
3729}
3730// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
3731/// The status an issue's workflow state reads as, through this instance's `status_mapping`.
3732///
3733/// A state whose name the mapping names reads as that category, under the state's own name —
3734/// which is what lets two states of one type, `Todo` and `Queued`, read as two categories. Every
3735/// other state reads by its type exactly as [`status`] reads it, so a team's review states, its
3736/// triage state and every state the mapping leaves out read as they did without one.
3737fn issue_status(v: &Value, statuses: &StatusMapping) -> Result<Status, SourceError> {
3738    let name = str_at(v, "name")?;
3739    match statuses.category_of(name) {
3740        Some(category) => Ok(Status {
3741            category,
3742            name: name.to_owned(),
3743        }),
3744        None => status(v),
3745    }
3746}
3747fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
3748    v.get(k)
3749        .and_then(Value::as_str)
3750        .ok_or_else(|| SourceError::Malformed {
3751            message: format!("missing string field {k}"),
3752        })
3753}
3754fn map_label(v: &Value) -> Result<Label, SourceError> {
3755    Ok(Label {
3756        id: NativeId(str_at(v, "id")?.into()),
3757        name: str_at(v, "name")?.into(),
3758        color: optional_string(v, "color")?,
3759    })
3760}
3761fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
3762    v.get("nodes")
3763        .and_then(Value::as_array)
3764        .ok_or_else(|| SourceError::Malformed {
3765            message: "missing label nodes".into(),
3766        })?
3767        .iter()
3768        .map(map_label)
3769        .collect()
3770}
3771fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
3772    optional_str(v, k)?
3773        .map(|s| {
3774            s.parse().map_err(|e| SourceError::Malformed {
3775                message: format!("invalid {k}: {e}"),
3776            })
3777        })
3778        .transpose()
3779}
3780/// One issue as a task, `source` being this source's configured name.
3781///
3782/// The name is what lets [`TaskRef::listed`] tell `work:I-1` on the issue `I-1` of the
3783/// source `work` apart as that issue itself, rather than recognising only the bare spelling.
3784fn map_task(v: &Value, source: &SourceName, statuses: &StatusMapping) -> Result<Task, SourceError> {
3785    let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
3786    let repositories = Repository::from_metadata(&metadata)
3787        .map_err(|message| SourceError::Malformed { message })?;
3788    let url = optional_string(v, "url")?;
3789    let id = NativeId(str_at(v, "id")?.into());
3790    // Taken out of the caller's metadata as they are read: a reserved key is this product's,
3791    // and reporting it there as well would hand a consumer two spellings of one list.
3792    let delivers = delivery_list(&mut metadata, TaskRef::DELIVERS_KEY, &id, source)?;
3793    let delivered_by = delivery_list(&mut metadata, TaskRef::DELIVERED_BY_KEY, &id, source)?;
3794    Ok(Task {
3795        id,
3796        // `Issue.identifier` is `String!` and every read of an issue selects it, so a
3797        // response without one is a response this source cannot read rather than an issue
3798        // with no handle — Linear gives every issue one.
3799        key: Some(str_at(v, "identifier")?.into()),
3800        title: str_at(v, "title")?.into(),
3801        content,
3802        status: issue_status(
3803            v.get("state").ok_or_else(|| SourceError::Malformed {
3804                message: "missing state".into(),
3805            })?,
3806            statuses,
3807        )?,
3808        priority: issue_priority(v)?,
3809        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
3810            message: "missing labels".into(),
3811        })?)?,
3812        project: filed_under(v)?,
3813        location: web_address(url.as_deref()),
3814        url,
3815        created_at: time(v, "createdAt")?,
3816        updated_at: time(v, "updatedAt")?,
3817        metadata,
3818        repositories,
3819        delivers,
3820        delivered_by,
3821    })
3822}
3823/// A priority as Linear's `Issue.priority` and its two input members spell it.
3824///
3825/// Linear's own scale, as its published schema describes the field: `0` is no priority,
3826/// `1` urgent, `2` high, `3` normal and `4` low. Normal is this contract's `medium`.
3827const fn linear_priority(priority: Priority) -> u8 {
3828    match priority {
3829        Priority::None => 0,
3830        Priority::Urgent => 1,
3831        Priority::High => 2,
3832        Priority::Medium => 3,
3833        Priority::Low => 4,
3834    }
3835}
3836
3837/// The priority an issue carries, read from `Issue.priority`.
3838///
3839/// Linear declares that field `Float!` while its inputs take an `Int`, so `2` and `2.0` are
3840/// the same answer. Anything else — absent, null, fractional, or outside `0` to `4` — is a
3841/// response this source cannot read, never a guess at the nearest level: a priority reported
3842/// that a filter for it could not find is capability rule 1 broken.
3843fn issue_priority(v: &Value) -> Result<Priority, SourceError> {
3844    let raw = v.get("priority").ok_or_else(|| SourceError::Malformed {
3845        message: "missing number field priority".into(),
3846    })?;
3847    let level = raw.as_f64().filter(|level| level.fract() == 0.0);
3848    Priority::ALL
3849        .into_iter()
3850        .find(|priority| level == Some(f64::from(linear_priority(*priority))))
3851        .ok_or_else(|| SourceError::Malformed {
3852            message: format!(
3853                "field priority is {raw}, which is none of Linear's priorities 0 (none), \
3854                 1 (urgent), 2 (high), 3 (normal) and 4 (low)"
3855            ),
3856        })
3857}
3858
3859/// One delivery list read out of an issue's metadata slot, and removed from it.
3860///
3861/// An entry that is not a task id, that names the issue itself, or that repeats is a
3862/// malformed response naming the task and the entry, never a list quietly shortened.
3863fn delivery_list(
3864    metadata: &mut std::collections::BTreeMap<String, Value>,
3865    key: &str,
3866    task: &NativeId,
3867    source: &SourceName,
3868) -> Result<Vec<TaskRef>, SourceError> {
3869    let held = metadata.remove(key);
3870    TaskRef::from_value(key, task, Some(source), held.as_ref())
3871        .map_err(|message| SourceError::Malformed { message })
3872}
3873/// Remove the two delivery keys from a project's or a document's metadata.
3874///
3875/// Neither is work that delivers anything, so a key there names nothing this contract has,
3876/// and it is not the caller's free metadata either: it is this product's reserved spelling.
3877fn strip_delivery_keys(metadata: &mut std::collections::BTreeMap<String, Value>) {
3878    metadata.remove(TaskRef::DELIVERS_KEY);
3879    metadata.remove(TaskRef::DELIVERED_BY_KEY);
3880}
3881fn map_project(v: &Value) -> Result<Project, SourceError> {
3882    let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
3883    strip_delivery_keys(&mut metadata);
3884    let repositories = Repository::from_metadata(&metadata)
3885        .map_err(|message| SourceError::Malformed { message })?;
3886    let url = optional_string(v, "url")?;
3887    Ok(Project {
3888        id: NativeId(str_at(v, "id")?.into()),
3889        title: str_at(v, "name")?.into(),
3890        content,
3891        status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
3892            message: "missing status".into(),
3893        })?)?,
3894        labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
3895            message: "missing project labels".into(),
3896        })?)?,
3897        location: web_address(url.as_deref()),
3898        url,
3899        created_at: time(v, "createdAt")?,
3900        updated_at: time(v, "updatedAt")?,
3901        metadata,
3902        repositories,
3903    })
3904}
3905
3906/// Where a Linear entity is: the web address Linear itself reports for it, as a link.
3907///
3908/// Every issue, project and document of a Linear workspace has a page a person can open,
3909/// so this source says so for all three — the counterpart of a folder of Markdown
3910/// reporting the path of the file behind an item. A source that reported nothing here is
3911/// what leaves a reader holding an opaque id, and `None` is reserved for the case Linear
3912/// really did not say, which is not the same as saying the entity is nowhere.
3913fn web_address(url: Option<&str>) -> Option<Location> {
3914    url.map(|url| Location::Url(url.to_owned()))
3915}
3916
3917/// The project a Linear item is filed under, or `None` for one filed under nothing.
3918///
3919/// One reader for issues and documents alike, because the field is the same field: an
3920/// absent `project` key is a malformed response, a null one is an orphan.
3921fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
3922    match v.get("project") {
3923        None => Err(SourceError::Malformed {
3924            message: "missing project field".into(),
3925        }),
3926        Some(Value::Null) => Ok(None),
3927        Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
3928    }
3929}
3930
3931fn map_document(v: &Value) -> Result<Document, SourceError> {
3932    let (content, mut metadata) = metadata_description(optional_string(v, "content")?)?;
3933    strip_delivery_keys(&mut metadata);
3934    let repositories = Repository::from_metadata(&metadata)
3935        .map_err(|message| SourceError::Malformed { message })?;
3936    let url = optional_string(v, "url")?;
3937    Ok(Document {
3938        id: NativeId(str_at(v, "id")?.into()),
3939        title: str_at(v, "title")?.into(),
3940        content,
3941        project: filed_under(v)?,
3942        // Linear's `Document` carries no labels, and that is the published schema rather
3943        // than a gap here: the types of it that carry `labels` are `Issue`, `Project`,
3944        // `Team`, `Initiative` and `Organization`. Reporting none is what a source with no
3945        // native slot owes; standing one up beside a first-class type is what this source
3946        // exists not to do, and `write_document` refuses a label by name for the same
3947        // reason rather than dropping it.
3948        labels: Vec::new(),
3949        location: web_address(url.as_deref()),
3950        url,
3951        created_at: time(v, "createdAt")?,
3952        updated_at: time(v, "updatedAt")?,
3953        metadata,
3954        repositories,
3955    })
3956}
3957
3958/// Whether this document satisfies the predicates this source applies to a fetched page.
3959///
3960/// Two of them reach a page rather than the `documents(filter:)` variables, and each for a
3961/// reason of Linear's own. `DocumentFilter.project` is a `ProjectFilter` where
3962/// `IssueFilter.project` is a `NullableProjectFilter`, so only the issue side can be asked
3963/// for the items belonging to no project. And a Linear document carries no label at all,
3964/// so a query demanding one keeps nothing and a query excluding one keeps everything —
3965/// which is this source *applying* the predicate it declares native, over the labels the
3966/// document really has, rather than ignoring it.
3967fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
3968    let carries = |name: &String| {
3969        document
3970            .labels
3971            .iter()
3972            .any(|label| label.name.eq_ignore_ascii_case(name))
3973    };
3974    let filed = match project {
3975        ProjectFilter::Any => true,
3976        ProjectFilter::Orphans => document.project.is_none(),
3977        ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
3978    };
3979    filed
3980        && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
3981        && labels.all_of.iter().all(&carries)
3982        && !labels.none_of.iter().any(&carries)
3983}
3984
3985fn optional<T>(
3986    d: &Value,
3987    k: &str,
3988    f: impl Fn(&Value) -> Result<T, SourceError>,
3989) -> Result<Option<T>, SourceError> {
3990    match d.get(k) {
3991        None => Err(SourceError::Malformed {
3992            message: format!("missing {k}"),
3993        }),
3994        Some(Value::Null) => Ok(None),
3995        // An item Linear no longer shows is not an item this source holds, and Linear says
3996        // so with `archivedAt` rather than by answering null.
3997        //
3998        // **None of Linear's three `delete` verbs removes anything.** `issueDelete`,
3999        // `projectDelete` and `documentDelete` move the item to the trash: observed on
4000        // 2026-09-04, each answered `success: true` and the item still read back by id,
4001        // carrying `archivedAt` and `trashed: true`. Its separate *archive* verb is a third
4002        // state — `archivedAt` set, `trashed` null — and Linear excludes both from every
4003        // connection, so `issues`, `projects` and `documents` had already stopped returning
4004        // them while a read by id still did.
4005        //
4006        // `archivedAt` rather than `trashed` for exactly that reason: it is the marker both
4007        // states share, so a read by id answers what a listing answers, and a delete means
4008        // what a copy's undo needs it to mean — the item this run created is gone.
4009        Some(value) if !matches!(value.get("archivedAt"), None | Some(Value::Null)) => Ok(None),
4010        Some(value) => f(value).map(Some),
4011    }
4012}
4013fn connection<T>(
4014    d: &Value,
4015    k: &str,
4016    f: impl Fn(&Value) -> Result<T, SourceError>,
4017) -> Result<Page<T>, SourceError> {
4018    let c = d.get(k).ok_or_else(|| SourceError::Malformed {
4019        message: format!("missing {k} connection"),
4020    })?;
4021    let items = c
4022        .get("nodes")
4023        .and_then(Value::as_array)
4024        .ok_or_else(|| SourceError::Malformed {
4025            message: "missing nodes".into(),
4026        })?
4027        .iter()
4028        .map(f)
4029        .collect::<Result<_, _>>()?;
4030    let next = page_next(c)?;
4031    Ok(Page { items, next })
4032}
4033#[derive(Clone, Copy)]
4034enum DependencyRoot {
4035    Issue,
4036    Project,
4037}
4038impl DependencyRoot {
4039    const fn item_kind(self) -> ItemKind {
4040        match self {
4041            Self::Issue => ItemKind::Task,
4042            Self::Project => ItemKind::Project,
4043        }
4044    }
4045    const fn as_str(self) -> &'static str {
4046        match self {
4047            Self::Issue => "issue",
4048            Self::Project => "project",
4049        }
4050    }
4051}
4052fn relation_page(
4053    d: &Value,
4054    root: DependencyRoot,
4055    id: &NativeId,
4056    direction: Direction,
4057) -> Result<Page<DependencyEdge>, SourceError> {
4058    let key = if direction == Direction::DependsOn {
4059        "relations"
4060    } else {
4061        "inverseRelations"
4062    };
4063    let c = d
4064        .get(root.as_str())
4065        .and_then(|v| v.get(key))
4066        .ok_or_else(|| SourceError::Malformed {
4067            message: format!("missing {key}"),
4068        })?;
4069    let nodes = c
4070        .get("nodes")
4071        .and_then(Value::as_array)
4072        .ok_or_else(|| SourceError::Malformed {
4073            message: "missing relation nodes".into(),
4074        })?;
4075    let mut items = Vec::new();
4076    for n in nodes {
4077        let other = n
4078            .get(if direction == Direction::DependsOn {
4079                "relatedIssue"
4080            } else {
4081                "issue"
4082            })
4083            .or_else(|| {
4084                n.get(if direction == Direction::DependsOn {
4085                    "relatedProject"
4086                } else {
4087                    "project"
4088                })
4089            })
4090            .and_then(|v| v.get("id"))
4091            .and_then(Value::as_str)
4092            .ok_or_else(|| SourceError::Malformed {
4093                message: "missing related id".into(),
4094            })?;
4095        let (from, to) = if direction == Direction::DependsOn {
4096            (id.clone(), NativeId(other.into()))
4097        } else {
4098            (NativeId(other.into()), id.clone())
4099        };
4100        // 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.
4101        let relation_type =
4102            n.get("type")
4103                .and_then(Value::as_str)
4104                .ok_or_else(|| SourceError::Malformed {
4105                    message: "missing relation type".into(),
4106                })?;
4107        // An issue relation and a project relation do not share a vocabulary. Linear
4108        // spells a project dependency `dependency`, where an issue's is `blocks`; the
4109        // write side sends exactly that pair and says why. So each root reads only its
4110        // own, and a value the other root would have accepted is refused here rather than
4111        // read as an edge this source could not have written.
4112        //
4113        // `related` is one of those values, and only an issue relation has it. Linear's
4114        // validator enumerates a project relation's `type` as `dependency` alone — see
4115        // the write side, which had `related` refused by the real API on 2026-09-04 — so
4116        // a project relation typed `related` is not a relation this workspace can hold.
4117        let kind = match (root, relation_type) {
4118            (DependencyRoot::Issue, "blocks") | (DependencyRoot::Project, "dependency") => {
4119                DependencyKind::Blocks
4120            }
4121            (DependencyRoot::Issue, "related") => DependencyKind::Related,
4122            _ => {
4123                return Err(SourceError::Malformed {
4124                    message: format!(
4125                        "invalid relation type: {relation_type} on a {} relation",
4126                        root.as_str()
4127                    ),
4128                });
4129            }
4130        };
4131        // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
4132        let item_kind = root.item_kind();
4133        items.push(DependencyEdge {
4134            from: DependencyEndpoint::from_native(from, item_kind),
4135            to: DependencyEndpoint::from_native(to, item_kind),
4136            kind,
4137        });
4138    }
4139    let next = page_next(c)?;
4140    Ok(Page { items, next })
4141}
4142
4143fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
4144    match v.get(k) {
4145        None => Err(SourceError::Malformed {
4146            message: format!("missing field {k}"),
4147        }),
4148        Some(Value::Null) => Ok(None),
4149        Some(value) => value
4150            .as_str()
4151            .map(Some)
4152            .ok_or_else(|| SourceError::Malformed {
4153                message: format!("field {k} is not a string"),
4154            }),
4155    }
4156}
4157
4158/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment at the
4159/// end of the long-form field, and writes it on one line with the canonical JSON inside a code
4160/// span: ``<!-- onetaskgraph.metadata `{…}` -->``. The code span is the one spelling Linear keeps
4161/// byte for byte in a description or a document — see the module documentation's ruling on what
4162/// Linear does to an HTML comment — and [`slot_json`] escapes the three characters that could
4163/// end it early.
4164const METADATA_PREFIX: &str = "<!-- onetaskgraph.metadata";
4165/// The opening of the slot this source writes.
4166const METADATA_OPEN_SPAN: &str = "<!-- onetaskgraph.metadata `";
4167/// The close of the slot this source writes.
4168const METADATA_CLOSE_SPAN: &str = "` -->";
4169/// The opening of the multi-line slot this source wrote before the code span, still read: an
4170/// item written then keeps its metadata. Linear normalized its JSON, so a key or a value
4171/// Linear rewrote reads back as Linear left it — or, for an array Linear escaped, as a
4172/// malformed slot naming itself — and the next write of that item writes the code span.
4173const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
4174const METADATA_CLOSE: &str = "\n-->";
4175/// The same close as Linear hands that multi-line slot back: it escapes a line opening
4176/// `-->`, so the slot reads back with a backslash before its close (observed from the real API
4177/// on 2026-09-14 for a document and on 2026-10-02 for an issue's description too).
4178const METADATA_CLOSE_ESCAPED: &str = "\n\\-->";
4179
4180/// One JSON value in the encoding the slot holds it in: compact canonical JSON with `<`, `>`
4181/// and `` ` `` escaped as `\u003c`, `\u003e` and `\u0060`.
4182///
4183/// All three only ever occur inside a JSON string, where the escape means the same character,
4184/// so the value parses back exactly; and with them escaped no value can close the code span or
4185/// the HTML comment around it. A search phrase is not built with this: `slot_phrase` sends only
4186/// a value none of whose characters any encoder escapes, which this leaves as written.
4187fn slot_json(value: &impl serde::Serialize) -> Result<String, SourceError> {
4188    let encoded = serde_json::to_string(value).map_err(|error| SourceError::Malformed {
4189        message: error.to_string(),
4190    })?;
4191    Ok(encoded
4192        .replace('<', "\\u003c")
4193        .replace('>', "\\u003e")
4194        .replace('`', "\\u0060"))
4195}
4196
4197/// The slot holding exactly `metadata`, as this source writes it.
4198fn slot_text(metadata: &std::collections::BTreeMap<String, Value>) -> Result<String, SourceError> {
4199    Ok(format!(
4200        "{METADATA_OPEN_SPAN}{}{METADATA_CLOSE_SPAN}",
4201        slot_json(metadata)?
4202    ))
4203}
4204
4205/// Where the trailing metadata slot of `description` is: the byte its opening marker starts
4206/// at, and the span of the encoded JSON inside it — or `None` when it ends in no slot.
4207///
4208/// The one place the slot is recognised, in either spelling, so what [`metadata_description`]
4209/// reads out and what [`metadata_slot`] keeps for a content write are the same bytes.
4210fn slot_bounds(description: &str) -> Result<Option<(usize, usize, usize)>, SourceError> {
4211    let Some(start) = description.rfind(METADATA_PREFIX) else {
4212        return Ok(None);
4213    };
4214    let rest = &description[start..];
4215    let (encoded_start, close) = if rest.starts_with(METADATA_OPEN_SPAN) {
4216        let encoded_start = start + METADATA_OPEN_SPAN.len();
4217        (
4218            encoded_start,
4219            description[encoded_start..]
4220                .rfind(METADATA_CLOSE_SPAN)
4221                .map(|at| (at, METADATA_CLOSE_SPAN.len())),
4222        )
4223    } else if rest.starts_with(METADATA_OPEN) {
4224        let encoded_start = start + METADATA_OPEN.len();
4225        (
4226            encoded_start,
4227            [METADATA_CLOSE, METADATA_CLOSE_ESCAPED]
4228                .into_iter()
4229                .filter_map(|close| {
4230                    description[encoded_start..]
4231                        .find(close)
4232                        .map(|at| (at, close.len()))
4233                })
4234                .min(),
4235        )
4236    } else {
4237        return Ok(None);
4238    };
4239    let Some((relative_end, close_len)) = close else {
4240        return Err(SourceError::Malformed {
4241            message: "unterminated onetaskgraph metadata slot in Linear description".into(),
4242        });
4243    };
4244    let encoded_end = encoded_start + relative_end;
4245    if !description[encoded_end + close_len..].trim().is_empty() {
4246        return Ok(None);
4247    }
4248    Ok(Some((start, encoded_start, encoded_end)))
4249}
4250
4251/// The metadata slot `description` ends in, exactly as it is stored, or `None`.
4252fn metadata_slot(description: &str) -> Result<Option<&str>, SourceError> {
4253    Ok(slot_bounds(description)?.map(|(start, _, _)| &description[start..]))
4254}
4255
4256fn metadata_description(
4257    description: Option<String>,
4258) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
4259    let Some(description) = description else {
4260        return Ok((None, Default::default()));
4261    };
4262    let Some((start, encoded_start, encoded_end)) = slot_bounds(&description)? else {
4263        return Ok((Some(description), Default::default()));
4264    };
4265    let metadata =
4266        serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
4267            SourceError::Malformed {
4268                message: format!(
4269                    "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
4270                ),
4271            }
4272        })?;
4273    // Exactly the text above the slot less the one blank line `long_form` sets it off by, so
4274    // content whose own end is whitespace reads back as itself. A description edited in Linear
4275    // down to a single line break before the slot loses just that one.
4276    let above = &description[..start];
4277    let visible = above
4278        .strip_suffix("\n\n")
4279        .or_else(|| above.strip_suffix('\n'))
4280        .unwrap_or(above);
4281    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
4282}
4283
4284/// The narrowing that asks Linear for the issues whose description holds `"<value>"` — a
4285/// string `value` as any JSON encoder writes it, quotes included — or `None` when `value` holds
4286/// a character an encoder may write another way, and only the confirmation decides.
4287///
4288/// A candidate set rather than the answer: the phrase can sit in the visible prose, or under
4289/// another key, and both are kept out by the confirmation over the parsed slot that follows
4290/// every read. What it cannot do is miss an issue whose slot holds the value — in the code span
4291/// this source writes, in the multi-line slot it wrote before, or in one a person spaced or
4292/// re-encoded by hand — which is what makes sending it sound. So it names the value alone, never
4293/// the key beside it, whose spacing a slot is free to vary; and only a value of printable ASCII
4294/// none of whose characters any encoder escapes — not `"`, `\`, `/`, `<`, `>`, `&`, `'` or a
4295/// backtick — because one that is escaped would be spelled in a stored slot otherwise than here.
4296fn slot_phrase(value: &str) -> Option<Value> {
4297    let verbatim = value.chars().all(|character| {
4298        (character.is_ascii_graphic() && !"\"\\/<>&'`".contains(character)) || character == ' '
4299    });
4300    verbatim.then(|| json!({"description": {"contains": format!("\"{value}\"")}}))
4301}
4302
4303/// Whether `title`/`content` satisfies `query`, case-insensitively — the contract's own rule,
4304/// which the engine applies for a source that does not search, so the two cannot answer one
4305/// workspace differently.
4306fn text_holds(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
4307    let terms = query.terms.to_lowercase();
4308    let in_title = title.to_lowercase().contains(&terms);
4309    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
4310    match query.fields {
4311        TextFields::Title => in_title,
4312        TextFields::Content => in_content,
4313        TextFields::TitleOrContent => in_title || in_content,
4314    }
4315}
4316
4317fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
4318    Ok(optional_str(v, k)?.map(Into::into))
4319}
4320fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
4321    let id = str_at(value, field)?;
4322    (!id.is_empty())
4323        .then_some(id)
4324        .ok_or_else(|| SourceError::Malformed {
4325            message: format!("field {field} is an empty backend id"),
4326        })
4327}
4328fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
4329    let root = root.as_str();
4330    let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
4331        message: format!("missing {root}"),
4332    })?;
4333    match payload.get("success").and_then(Value::as_bool) {
4334        Some(true) => Ok(payload),
4335        Some(false) => Err(SourceError::Refused {
4336            message: format!("Linear reported {root} was unsuccessful"),
4337        }),
4338        None => Err(SourceError::Malformed {
4339            message: format!("missing boolean {root}.success"),
4340        }),
4341    }
4342}
4343fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
4344    let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
4345        message: "missing pageInfo".into(),
4346    })?;
4347    let more = info
4348        .get("hasNextPage")
4349        .and_then(Value::as_bool)
4350        .ok_or_else(|| SourceError::Malformed {
4351            message: "missing boolean pageInfo.hasNextPage".into(),
4352        })?;
4353    if !more {
4354        return Ok(None);
4355    }
4356    let cursor = str_at(info, "endCursor")?;
4357    Ok(Some(Cursor(cursor.into())))
4358}