Skip to main content

onetaskgraph_linear/
lib.rs

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