Skip to main content

onetaskgraph_linear/
lib.rs

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