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