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