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