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