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