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