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