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