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