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