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