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