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