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