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