Skip to main content

onetaskgraph_linear/
lib.rs

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