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