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