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