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