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