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