Skip to main content

onetaskgraph_linear/
lib.rs

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