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