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` | **Unsupported, and unimplemented** rather than a limit of the API: Linear's `IssueFilter` has a `priority` comparator this source does not send yet, so the engine narrows the wider page it returns. |
33//! | `orphan_tasks` | **Supported and proven.** `issues(filter:{project:{null:true}})`. |
34//! | `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`. |
35//! | `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. |
36//! | `search_title` | **Unsupported, and unimplemented** rather than a limit of the API. See the ruling below. |
37//! | `search_content` | **Unsupported, and unimplemented** rather than a limit of the API. See the ruling below. |
38//! | `task_dependencies` | **Supported and proven,** in both directions: `relations` and `inverseRelations`. |
39//! | `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. |
40//! | `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`]. |
41//!
42//! ## Ruling: the two searches are unimplemented, not unsupportable
43//!
44//! Linear's published API *does* offer issue search — `searchIssues` is a documented
45//! operation of it — so there is no property of the remote service that makes a title-only
46//! or a body-only match impossible here. What is true today is narrower and is recorded as
47//! such: no production operation in this crate sends one, so declaring either predicate
48//! `Native` would break capability rule 1, and `Unsupported` is the only honest
49//! declaration for the code that exists.
50//!
51//! The engine compensates correctly for both — it over-fetches and narrows, and the shared
52//! journeys assert that this row returns the same rows every native row does with the plan
53//! naming the engine — so the declaration is sound as well as honest. It is still a gap
54//! rather than a limit, and reading it as a limit is what would leave it here forever.
55//! Implementing it is tracked in `docs/follow-ups.md`.
56//!
57//! ## Ruling: a Linear document carries no label, and that is Linear's
58//!
59//! Unlike the two searches above, this one *is* a property of the remote service. The
60//! types of Linear's published schema carrying a `labels` field are `Issue`, `Project`,
61//! `Team`, `Initiative` and `Organization`; `Document` is not among them, re-observed
62//! 2026-09-01 and pinned in `tests/fixtures/schema.graphql`. So this source reports a
63//! document's labels as none and **refuses by name** a document write carrying one, rather
64//! than dropping it or standing a slot up beside a first-class type. The shared journey
65//! table's row says so, and the shared document journeys drive that claim.
66//!
67//! Two predicates therefore reach a fetched page rather than the `documents(filter:)`
68//! variables, and both are still *applied* — which is what `Native` means here, and why
69//! the declaration stays honest. Labels, for the reason above. And orphans, because
70//! `DocumentFilter.project` is a `ProjectFilter` where `IssueFilter.project` is a
71//! `NullableProjectFilter`: only the nullable one carries `null:`, so Linear cannot be
72//! asked for the documents belonging to no project. The page-by-page walk asks for only
73//! what is still owed, so neither predicate can make a read return more than the caller
74//! asked for, and neither can drop a document the walk already fetched.
75//!
76//! ## Ruling: a comment is read backwards, and its author is Linear's to record
77//!
78//! **The order.** The contract owes a task's comments oldest first, across pages, and Linear's
79//! `Issue.comments` takes no sort direction — only `orderBy`, whose members are `createdAt`
80//! (the default) and `updatedAt`. Linear's pagination documentation says results are "ordered
81//! by `createdAt`" and that "to get most recently updated resources, you can alternatively
82//! order by `updatedAt`", which reads that ordering as newest first. So this source walks the
83//! connection from its far end: `last` with `before`, each page reversed, the next page's
84//! cursor being `startCursor` while `hasPreviousPage` holds. Reversing within a page and
85//! walking backwards across them is what makes the whole walk oldest first rather than each
86//! page alone. **That direction is inferred from the documentation's wording rather than
87//! observed against the real API,** which is the one reading here a live run has not yet
88//! confirmed; if Linear is found to list oldest first, the correction is this walk's
89//! direction and nothing else.
90//!
91//! **The author.** Linear records the user whose credential made the request as a comment's
92//! author, and this source authenticates with an API key. `CommentCreateInput.createAsUser`
93//! exists but is, in Linear's own words, "only available to OAuth applications creating
94//! comments in `actor=app` mode", which a key is not. So a comment carrying an author is
95//! **refused before any request is sent**, naming why and what to do instead, rather than
96//! posted under a name other than the one it was given. An author read back is the user's
97//! `displayName`, which Linear keeps unique within a workspace, and is absent when Linear
98//! names no user — a comment an integration or a bot wrote.
99//!
100//! **What "no such comment" means.** An edit or a removal first asks `comment(id:)` which
101//! issue the comment is on, and answers "no such comment" — no mutation sent — unless it is
102//! the task's own issue: a comment on another issue, on no issue at all, or trashed, is not a
103//! comment this task has. The body is Linear's `body`, which its schema describes as markdown
104//! derived from a rich-text document, so what an add or an edit answers with is what Linear
105//! now holds rather than an echo of what was sent.
106//!
107//! ## Ruling: a project's filter is not an issue's, and neither is its status
108//!
109//! Linear's `IssueFilter` and `ProjectFilter` read as one filter over two kinds of row.
110//! They are two input types, and this source built one object for both until 2026-09-04,
111//! which put two members into `projects(filter:)` that Linear does not have there. It
112//! refused the first outright — `Field "team" is not defined by type "ProjectFilter". Did
113//! you mean "lead"?` — and would have refused the second next.
114//!
115//! A project has no team; it has the teams it is accessible from, so the configured team
116//! reaches `accessibleTeams:{some:{key:{eqIgnoreCase:…}}}`. And a project's status is not
117//! an issue's state: the counterpart of `IssueFilter.state` is `ProjectFilter.status`,
118//! while `ProjectFilter.state` exists and is a bare `StringComparator` over something else.
119//! The two do not even share a vocabulary — `ProjectStatus.type` is the `ProjectStatusType`
120//! enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`, where a
121//! workflow state is `backlog`, `unstarted`, `started`, `completed`, `canceled`, `triage`.
122//! So `planned` is where `unstarted` would be, `paused` reads as in progress and has no
123//! issue counterpart, and a filter spelled in the other level's words matches nothing while
124//! being refused by nothing.
125//!
126//! **Neither of those could be caught by reading a document, and that is the general
127//! lesson.** A filter is built at runtime and handed over as `$filter`, so it appears in no
128//! operation this crate declares, and the two pinned-schema checks that parse those
129//! operations could not see it — Linear was the only reader, one refusal per round trip.
130//! `every_variables_object_this_source_sends_conforms_to_the_pinned_schema` closes that:
131//! it drives this source's whole surface, records what really went out, and walks every
132//! variables object against the pinned type of the argument it stands at.
133//!
134//! ## Ruling: a Linear project relation is always an ordering
135//!
136//! This one is Linear's too, and the validator says so in as many words. Asked on
137//! 2026-09-04 for a project relation typed `related` — and separately `blocks` and
138//! `dependsOn` — the real API refused each with `Argument Validation Error` and
139//! `constraints: {"isEnum": "type must be one of the following values: dependency"}`. That
140//! enumeration has one member and it is a timeline dependency, which is why the input
141//! carries an anchor at each end at all.
142//!
143//! So a project edge carrying no ordering has nowhere here to land, and this source
144//! **refuses it by name** before the write rather than sending a value Linear will reject
145//! or quietly promoting it to a dependency it does not mean. `DependencyKind::Related`
146//! keeps its issue-level spelling, `related`, because `IssueRelationCreateInput` really
147//! does take it: the two relations are different relations with different vocabularies,
148//! and each level's read accepts only its own.
149//!
150//! Which end of a project relation waits is carried by the two anchors and not by the two
151//! id slots — measured, not reasoned, from Linear's own `ProjectFilter.hasBlockedByRelations`
152//! against relations written both ways round. `tests/fixtures/README.md` records the whole
153//! probe, and `write_relations` records why the pair this source sends is the oriented one.
154//!
155//! Caller metadata is canonical JSON in a trailing
156//! `<!-- onetaskgraph.metadata ... -->` Markdown comment in the item's description. The
157//! visible description is returned unchanged without that slot. Writes put the same
158//! canonical encoding back beside the visible description, and use Linear issue/project
159//! relations for same-source dependencies. Only cross-source far ends use the reserved
160//! `onetaskgraph.depends_on` metadata key.
161//!
162//! ## Ruling: a task's status is set by category, and delivery is not carried
163//!
164//! `set_task_status` refuses `draft`, `queued` and `unknown` before any request, because no
165//! Linear workflow state is any of them, and an issue already in the category asked for is
166//! answered with its own state and nothing written. Otherwise it resolves the configured
167//! team's first workflow state of that category's type and sends `issueUpdate` with that
168//! `stateId` alone.
169//!
170//! `delivers` and `delivered_by` are read out of the metadata slot when something put them
171//! there, and taken out of the caller's metadata as they are. They are never written:
172//! Linear has no field for either, so a write carrying either list or either reserved key,
173//! and every `set_delivered_by`, is refused by name before any request.
174//!
175//! ## Ruling: a priority is Linear's own, and content shares a field with the slot
176//!
177//! A task's priority is `Issue.priority`, on Linear's scale: `0` none, `1` urgent, `2` high,
178//! `3` normal — this contract's `medium` — and `4` low. Linear declares the field `Float!`
179//! while `IssueCreateInput.priority` and `IssueUpdateInput.priority` are `Int`, so a read
180//! accepts `2` and `2.0` alike and refuses anything that is not one of the five as a
181//! malformed response naming the field. A copy sends it on a create and on an update, `0`
182//! included, so a task moved back to no priority is not left holding its old one.
183//! `set_task_priority` reads the issue first — no such issue, or a trashed one, is `None`
184//! with nothing written — then sends `issueUpdate` with `priority` alone and answers with
185//! the priority the mutation's own payload reports.
186//!
187//! `set_task_content` sends `issueUpdate` with `description` alone, and that description is
188//! the given content followed by the issue's metadata slot exactly as it was stored, so the
189//! slot, and every key in it, is untouched. What a later read reports as the content is the
190//! given bytes, trailing whitespace included: a read of an issue carrying a slot takes off only
191//! the one blank line that sets the slot off, and a write whose content would not read back as
192//! itself is refused before it is sent.
193//!
194//! Fixture provenance is recorded in `tests/fixtures/README.md`. The live journey in
195//! `tests/live.rs` drives every field of the table above against Linear itself: it builds its own fixture
196//! on the scratch team `LINEAR_WRITE_TEAM` names — two projects, one issue filed under
197//! each, one filed under neither, two labels and two workflow states — because that shape
198//! is what tells an honoured predicate from an ignored one, and a workspace where every
199//! issue carries the label answers a filter the same way either way. The two searches are
200//! asserted as what they are declared: the wider set, unnarrowed. Everything the lane
201//! creates it deletes whether its assertions passed or failed, and it clears residue named
202//! the way it names its own before it starts. A failed live cleanup is reported as a test
203//! failure and may require manual deletion from that scratch team.
204#![deny(missing_docs)]
205
206use chrono::{DateTime, Utc};
207use onetaskgraph_plugin_api::{
208 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
209 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
210 LabelFilter, Location, NativeId, NewComment, Page, PageRequest, Priority, Project,
211 ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin,
212 Status, StatusCategory, Support, Task, TaskQuery, TaskRef, TaskSource, WriteSupport,
213};
214use schemars::{Schema, schema_for};
215use secrecy::{ExposeSecret, SecretString};
216use serde::Deserialize;
217use serde_json::{Value, json};
218
219/// The plugin kind a `linear` source's `plugin:` field names.
220pub const KIND: &str = "linear";
221
222/// The largest page this source will ask Linear for, and the capability it declares.
223///
224/// **Not Linear's connection maximum, which is 250, because a connection maximum is not
225/// the only thing bounding a page.** Linear also scores each document for complexity and
226/// refuses one over 10000 with HTTP 400 and `The query is too complex.` — and the
227/// `projects` document this source sends scores 17475 at `first: 250`, because its nested
228/// `labels` connection, which names no `first` of its own, is charged Linear's default of
229/// 50 per node. Measured against the real API on 2026-09-04: the largest `first` that
230/// document is accepted at is **143**, exactly, and the filter it carries adds nothing.
231/// The `issues` document is accepted at 250, so this is the tighter of the two and a
232/// single declared maximum has to be the tighter one.
233///
234/// 100 rather than 143 because 143 is the cliff. A field added to either selection moves
235/// it, and a page size chosen at the edge of a budget nobody here controls fails in the
236/// live lane rather than in a check. This leaves 30% of the budget spare.
237///
238/// Nothing offline can hold this: complexity is scored by Linear's own runtime and appears
239/// in no schema, so `every_variables_object_this_source_sends_conforms_to_the_pinned_schema`
240/// cannot see it. What guards it is the live journey, which walks a real `projects` page at
241/// exactly this size.
242pub const MAX_PAGE_SIZE: u32 = 100;
243const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
244
245/// Exact GraphQL query documents issued by this plugin.
246///
247/// Fixture servers consume these constants so their recognized contract cannot drift
248/// from the production requests.
249pub mod graphql {
250 /// Check the authenticated viewer.
251 pub const VIEWER: &str = "query { viewer { id } }";
252 /// Fetch one issue.
253 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} } }";
254 /// Fetch one project.
255 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}} } }";
256 /// List issues.
257 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} } }";
258 /// List projects.
259 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} } }";
260 /// List issue labels.
261 pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
262 /// Fetch issue dependency relations.
263 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}} } }";
264 /// Fetch project dependency relations.
265 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}} } }";
266 /// Resolve the configured team key to Linear's backend id.
267 pub const TEAM: &str =
268 "query($key:String!){ teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id}} }";
269 /// Resolve an issue workflow-state display name.
270 ///
271 /// `$team` is an `ID!` and `$name` a `String!` because that is what each one's
272 /// *location* declares, not because of what this source passes: both carry a Linear
273 /// identifier string. `WorkflowStateFilter.team` is a `NullableTeamFilter`, whose `id`
274 /// is an `IDComparator`, whose `eq` is an `ID`; the sibling `name` reaches a
275 /// `StringComparator.eqIgnoreCase`, which is a `String`.
276 ///
277 /// That distinction is what the live lane was refused for on 2026-09-04, with HTTP 400
278 /// and `Variable "$team" of type "String!" used in position expecting type "ID".`
279 /// GraphQL admits a variable at a location only when the variable's type is the
280 /// location's type or that type's non-null form, and `String` is not `ID` however the
281 /// value is spelled — so `String!` there fails validation before any field is read,
282 /// while `ID!` is the non-null form of the location's own type and is accepted.
283 ///
284 /// It reached Linear because a variable inside an inline filter literal is not a root
285 /// argument, and the pinned-schema checks only compared root arguments. They now walk
286 /// into these literals too, so this class of drift fails here rather than in the live
287 /// lane.
288 pub const ISSUE_STATE: &str = "query($name:String!,$team:ID!){ workflowStates(filter:{name:{eqIgnoreCase:$name},team:{id:{eq:$team}}}){nodes{id}} }";
289 /// Find the configured team's workflow states of one `WorkflowState.type`, so a task's
290 /// status can be set by category alone.
291 ///
292 /// `name` is selected beside `id` because the status a narrow status write answers with
293 /// is the one Linear now holds, and a category alone does not say which of the team's
294 /// states of that type it is. `$type` is a `String!` at `StringComparator.eq`, which is a
295 /// `String`, and `$team` an `ID!` for the reason recorded on [`ISSUE_STATE`].
296 pub const ISSUE_STATE_OF_TYPE: &str = "query($type:String!,$team:ID!){ workflowStates(filter:{type:{eq:$type},team:{id:{eq:$team}}}){nodes{id name}} }";
297 /// List the workspace's project statuses, so one can be resolved by display name.
298 ///
299 /// Unlike `teams`, `workflowStates` and the two label connections, Linear's
300 /// `projectStatuses` accepts no `filter` argument: asking for one is refused outright
301 /// with `Unknown argument "filter" on field "Query.projectStatuses"`. The display name
302 /// is therefore matched locally over the whole connection, which a workspace holds few
303 /// enough of to answer in one page.
304 // llmlint: ignore[changed_behavior_has_e2e] The uncovered case the rule names — a status
305 // on a later page — is not a test that is missing but a document this repository has no
306 // evidence Linear would accept: `tests/fixtures/schema.graphql` pins `after` alone,
307 // because Linear's own refusal is where that correction came from, and its
308 // `ProjectStatusConnection` declares `nodes` and no `pageInfo`. Selecting a cursor field
309 // to page on would fail `pinned_schema_checks_selected_fields_arguments_and_fixture_keys`
310 // here and risk, against Linear, the same `GRAPHQL_VALIDATION_FAILED` this document was
311 // changed to stop sending. Reading one page is not what changed either: `teams`,
312 // `workflowStates` and `projectLabels` resolve a display name through the same `one_id`
313 // over the same unpaged connections, and did before this change. What did change is
314 // driven end to end — the CLI journey
315 // `linear_project_and_task_copies_write_native_relations_and_record_only_cross_source_edges`
316 // copies a project whose status is resolved this way, and
317 // `a_project_status_is_matched_locally_because_linear_narrows_that_connection_for_nobody`
318 // holds the match, the ambiguity and the absence against a real HTTP server.
319 pub const PROJECT_STATUS: &str = "query{ projectStatuses{nodes{id name}} }";
320 /// Resolve an issue-label display name.
321 pub const ISSUE_LABEL: &str =
322 "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
323 /// Resolve a project-label display name.
324 pub const PROJECT_LABEL: &str =
325 "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
326 /// Create an issue.
327 pub const ISSUE_CREATE: &str =
328 "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id}} }";
329 /// Update an issue.
330 pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id}} }";
331 /// Set an issue's priority on its own, and read back the priority Linear now holds.
332 ///
333 /// The same `issueUpdate` as [`ISSUE_UPDATE`], selecting `priority` in the payload
334 /// because a narrow priority write answers with what the source reads back rather than
335 /// an echo of what it sent. A document of its own rather than a wider [`ISSUE_UPDATE`],
336 /// so every other issue write keeps asking for exactly what it reads.
337 pub const ISSUE_PRIORITY_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id priority}} }";
338 /// Create a project.
339 pub const PROJECT_CREATE: &str =
340 "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id}} }";
341 /// Update a project.
342 pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id}} }";
343 /// Create a native issue dependency.
344 pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
345 /// Create a native project dependency.
346 pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
347 /// Delete a native issue dependency before replacing its full edge set.
348 pub const ISSUE_RELATION_DELETE: &str =
349 "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
350 /// Delete a native project dependency before replacing its full edge set.
351 pub const PROJECT_RELATION_DELETE: &str =
352 "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
353 /// Delete an issue, so a copy that could not finish can take back what it created.
354 pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
355 /// Delete a project, for the same reason and on the same terms.
356 pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
357 /// Fetch one document.
358 pub const DOCUMENT: &str = "query($id:String!){ document(id:$id){ id title content url createdAt updatedAt archivedAt project{id} } }";
359 /// List documents.
360 ///
361 /// `first` is an `Int` rather than an `Int!` because that is what Linear's `documents`
362 /// connection declares, unlike its `issues` one.
363 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} } }";
364 /// Create a document.
365 pub const DOCUMENT_CREATE: &str = "mutation($input:DocumentCreateInput!){ documentCreate(input:$input){success document{id}} }";
366 /// Update a document.
367 pub const DOCUMENT_UPDATE: &str = "mutation($id:String!,$input:DocumentUpdateInput!){ documentUpdate(id:$id,input:$input){success document{id}} }";
368 /// Delete a document, so a copy that could not finish can take back what it created.
369 pub const DOCUMENT_DELETE: &str = "mutation($id:String!){ documentDelete(id:$id){success} }";
370 /// One page of an issue's comments, walked backwards.
371 ///
372 /// `last`/`before` rather than `first`/`after`, and `pageInfo{hasPreviousPage
373 /// startCursor}` rather than its forward pair, because Linear lists a connection newest
374 /// first and the contract owes the oldest first — see the ruling on comments in this
375 /// crate's module documentation. `archivedAt` is selected for the reason every by-id read
376 /// here selects it: a trashed issue is not an issue this source holds.
377 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} } } }";
378 /// Place one comment: which issue it is on, if any.
379 ///
380 /// `$id` is a nullable `String` because that is what `Query.comment` declares — it also
381 /// takes a `hash` instead — and a variable has to be exactly its argument's type.
382 pub const COMMENT: &str = "query($id:String){ comment(id:$id){ id archivedAt issue{id} } }";
383 /// Add a comment to an issue.
384 pub const COMMENT_CREATE: &str = "mutation($input:CommentCreateInput!){ commentCreate(input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
385 /// Replace a comment's body.
386 pub const COMMENT_UPDATE: &str = "mutation($id:String!,$input:CommentUpdateInput!){ commentUpdate(id:$id,input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
387 /// Remove a comment.
388 pub const COMMENT_DELETE: &str = "mutation($id:String!){ commentDelete(id:$id){success} }";
389}
390
391use graphql::{
392 DOCUMENT, DOCUMENTS, ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS,
393 PROJECTS, VIEWER,
394};
395
396/// Configuration contains only the credential variable's name, never its value.
397#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
398#[serde(default, deny_unknown_fields)]
399pub struct LinearConfig {
400 /// Environment variable resolved by the host.
401 #[schemars(with = "String")]
402 api_key_env: EnvName,
403 /// Linear team key/id used to narrow reads and required for item writes.
404 #[schemars(with = "Option<String>")]
405 team: Option<Team>,
406 /// GraphQL endpoint override, primarily for fixture servers.
407 #[schemars(with = "String")]
408 endpoint: Endpoint,
409}
410
411#[derive(Debug, Clone, Deserialize)]
412#[serde(try_from = "String")]
413struct EnvName(String);
414impl TryFrom<String> for EnvName {
415 type Error = String;
416 fn try_from(value: String) -> Result<Self, Self::Error> {
417 let mut bytes = value.bytes();
418 if bytes
419 .next()
420 .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
421 && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
422 {
423 Ok(Self(value))
424 } else {
425 Err("must be an uppercase environment-variable name".into())
426 }
427 }
428}
429#[derive(Debug, Clone, Deserialize)]
430#[serde(try_from = "String")]
431struct Team(String);
432impl TryFrom<String> for Team {
433 type Error = String;
434 fn try_from(value: String) -> Result<Self, Self::Error> {
435 if value.trim().is_empty() {
436 Err("must not be empty".into())
437 } else {
438 Ok(Self(value))
439 }
440 }
441}
442#[derive(Debug, Clone, Deserialize)]
443#[serde(try_from = "String")]
444struct Endpoint(String);
445impl TryFrom<String> for Endpoint {
446 type Error = String;
447 fn try_from(value: String) -> Result<Self, Self::Error> {
448 let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
449 if matches!(url.scheme(), "http" | "https") {
450 Ok(Self(value))
451 } else {
452 Err("must use http or https".into())
453 }
454 }
455}
456
457impl Default for LinearConfig {
458 fn default() -> Self {
459 Self {
460 api_key_env: EnvName("LINEAR_API_KEY".into()),
461 team: None,
462 endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
463 }
464 }
465}
466
467/// The Linear plugin factory.
468#[derive(Debug, Clone, Copy, Default)]
469pub struct Plugin;
470
471impl SourcePlugin for Plugin {
472 fn kind(&self) -> &'static str {
473 KIND
474 }
475 fn config_schema(&self) -> Schema {
476 schema_for!(LinearConfig)
477 }
478 fn build(
479 &self,
480 name: &SourceName,
481 config: &Value,
482 secrets: &dyn SecretResolver,
483 ) -> Result<Box<dyn TaskSource>, SourceError> {
484 let config: LinearConfig =
485 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
486 message: format!("source {name}: {e}"),
487 })?;
488 let key = secrets
489 .get(&config.api_key_env.0)
490 .filter(|v| !v.expose_secret().trim().is_empty())
491 .ok_or_else(|| SourceError::Auth {
492 message: format!("set environment variable {}", config.api_key_env.0),
493 })?;
494 Ok(Box::new(LinearSource {
495 client: reqwest::Client::new(),
496 endpoint: config.endpoint,
497 key,
498 team: config.team,
499 name: name.clone(),
500 }))
501 }
502}
503
504struct LinearSource {
505 client: reqwest::Client,
506 endpoint: Endpoint,
507 key: SecretString,
508 team: Option<Team>,
509 /// This source's configured name, kept for one comparison: a far end recorded as
510 /// `<this name>:<native>` is a Linear item Linear itself relates, so the reserved key
511 /// is refused for it exactly as a bare id of the same kind is.
512 name: SourceName,
513}
514#[derive(Clone, Copy)]
515enum WriteKind {
516 Task,
517 Project,
518}
519enum Lookup<'a> {
520 Team(&'a str),
521 IssueState { name: &'a str, team: &'a NativeId },
522 ProjectStatus(&'a str),
523 IssueLabel(&'a str),
524 ProjectLabel(&'a str),
525}
526impl Lookup<'_> {
527 fn query(&self) -> &'static str {
528 match self {
529 Self::Team(_) => graphql::TEAM,
530 Self::IssueState { .. } => graphql::ISSUE_STATE,
531 Self::ProjectStatus(_) => graphql::PROJECT_STATUS,
532 Self::IssueLabel(_) => graphql::ISSUE_LABEL,
533 Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
534 }
535 }
536 fn connection(&self) -> &'static str {
537 match self {
538 Self::Team(_) => "teams",
539 Self::IssueState { .. } => "workflowStates",
540 Self::ProjectStatus(_) => "projectStatuses",
541 Self::IssueLabel(_) => "issueLabels",
542 Self::ProjectLabel(_) => "projectLabels",
543 }
544 }
545 fn diagnostic(&self) -> String {
546 match self {
547 Self::Team(_) => "configured team".into(),
548 Self::IssueState { name, .. } => format!("workflow state {name:?}"),
549 Self::ProjectStatus(name) => format!("project status {name:?}"),
550 Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
551 }
552 }
553 fn variables(&self) -> Value {
554 match self {
555 Self::Team(key) => json!({"key":key}),
556 Self::IssueState { name, team } => json!({"name":name,"team":team.0}),
557 Self::IssueLabel(name) | Self::ProjectLabel(name) => json!({"name":name}),
558 // `PROJECT_STATUS` names nothing, for the reason recorded on that document.
559 Self::ProjectStatus(_) => json!({}),
560 }
561 }
562 /// The display name `one_id` matches locally, for the one lookup whose connection
563 /// Linear will not narrow server-side.
564 fn local_name(&self) -> Option<&str> {
565 match self {
566 Self::ProjectStatus(name) => Some(name),
567 _ => None,
568 }
569 }
570}
571#[derive(Clone, Copy)]
572enum MutationRoot {
573 IssueCreate,
574 IssueUpdate,
575 ProjectCreate,
576 ProjectUpdate,
577 IssueRelationCreate,
578 ProjectRelationCreate,
579 IssueRelationDelete,
580 ProjectRelationDelete,
581 IssueDelete,
582 ProjectDelete,
583 DocumentCreate,
584 DocumentUpdate,
585 DocumentDelete,
586 CommentCreate,
587 CommentUpdate,
588 CommentDelete,
589}
590impl MutationRoot {
591 fn as_str(self) -> &'static str {
592 match self {
593 Self::IssueCreate => "issueCreate",
594 Self::IssueUpdate => "issueUpdate",
595 Self::ProjectCreate => "projectCreate",
596 Self::ProjectUpdate => "projectUpdate",
597 Self::IssueRelationCreate => "issueRelationCreate",
598 Self::ProjectRelationCreate => "projectRelationCreate",
599 Self::IssueRelationDelete => "issueRelationDelete",
600 Self::ProjectRelationDelete => "projectRelationDelete",
601 Self::IssueDelete => "issueDelete",
602 Self::ProjectDelete => "projectDelete",
603 Self::DocumentCreate => "documentCreate",
604 Self::DocumentUpdate => "documentUpdate",
605 Self::DocumentDelete => "documentDelete",
606 Self::CommentCreate => "commentCreate",
607 Self::CommentUpdate => "commentUpdate",
608 Self::CommentDelete => "commentDelete",
609 }
610 }
611}
612
613#[derive(Deserialize)]
614struct Envelope {
615 // 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.
616 data: Option<Value>,
617 #[serde(default)]
618 errors: Vec<GqlError>,
619}
620#[derive(Deserialize)]
621struct GqlError {
622 message: String,
623 // Held raw rather than typed, for two reasons. Linear puts the whole of *why* it
624 // refused in here — `message` is a category name like `Argument Validation Error`,
625 // which named neither the field nor the value when the live project-relation write
626 // was refused by it — so a refusal carries this verbatim and a reader diagnoses from
627 // it. And a typed shape with a required `code` fails the whole envelope's
628 // deserialization when Linear sends extensions without one, turning a refusal this
629 // source could explain into an unexplained malformed response.
630 extensions: Option<Value>,
631}
632#[derive(Deserialize)]
633#[serde(rename_all = "camelCase")]
634struct GqlExtensions {
635 code: GqlErrorCode,
636 retry_after: Option<u64>,
637}
638impl GqlError {
639 /// The rate-limit shape of [`Self::extensions`], when it has one.
640 fn coded(&self) -> Option<GqlExtensions> {
641 self.extensions
642 .as_ref()
643 .and_then(|value| serde_json::from_value(value.clone()).ok())
644 }
645 /// Everything Linear said about this refusal, on one line and cut to [`SAID_LIMIT`].
646 ///
647 /// Linear's own sentence comes first, then the raw envelope, because only the first
648 /// of those two is short enough to survive [`SAID_LIMIT`] on its merits. `message` is
649 /// a category name — `Argument Validation Error` — and the sentence naming the field
650 /// and the values it would have taken is `extensions.userPresentableMessage`, one of
651 /// several keys in an envelope whose `validationErrors` echoes the whole rejected
652 /// input back. Observed against the real API on 2026-09-04, a `projectRelationCreate`
653 /// refusal rendered past the cut, and the echo is what got cut.
654 ///
655 /// That the sentence itself did not was luck: this build of `serde_json` renders an
656 /// object's keys sorted, and `userPresentableMessage` happens to sort ahead of
657 /// `validationErrors`. Nobody chose that — Linear sends the echo first — and any key
658 /// Linear adds sorting between the two would move the sentence behind an echo longer
659 /// than the whole limit, as would turning `preserve_order` on. Leading with it makes
660 /// what a reader diagnoses from independent of both.
661 fn said(&self) -> String {
662 let Some(extensions) = &self.extensions else {
663 return elided(&self.message);
664 };
665 match extensions
666 .get("userPresentableMessage")
667 .and_then(Value::as_str)
668 .filter(|sentence| !sentence.is_empty())
669 {
670 Some(sentence) => elided(&format!("{}: {sentence} {extensions}", self.message)),
671 None => elided(&format!("{}: {extensions}", self.message)),
672 }
673 }
674}
675#[derive(Deserialize)]
676enum GqlErrorCode {
677 #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
678 RateLimited,
679 #[serde(other)]
680 Other,
681}
682
683/// How much of a failed response's body a refusal carries.
684///
685/// Enough for Linear's own error envelope, which is one or two sentences naming the field
686/// or argument it would not accept, and short enough that a proxy's HTML error page does
687/// not become the whole message.
688const SAID_LIMIT: usize = 400;
689
690/// `said` made safe to put in a message: one line of printable text, cut to [`SAID_LIMIT`].
691///
692/// A failed response's body is whatever answered — Linear's error envelope, or an HTML
693/// page from a proxy in front of it — and this message is written to a terminal. So every
694/// control character goes, escape sequences with them, and each run of whitespace becomes
695/// one space: a body cannot move the cursor, repaint the line or hide the rest of the
696/// diagnostic behind itself. Cut by characters rather than bytes, because slicing UTF-8
697/// mid-codepoint would panic inside the path that exists to explain a failure.
698fn elided(said: &str) -> String {
699 let mut printable = String::new();
700 let mut spaced = true;
701 for character in said.chars() {
702 if character.is_control() || character.is_whitespace() {
703 if !spaced {
704 printable.push(' ');
705 spaced = true;
706 }
707 continue;
708 }
709 printable.push(character);
710 spaced = false;
711 }
712 let printable = printable.trim_end();
713 if printable.chars().count() <= SAID_LIMIT {
714 return printable.to_owned();
715 }
716 let kept: String = printable.chars().take(SAID_LIMIT).collect();
717 format!("{kept}…")
718}
719
720impl LinearSource {
721 // 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.
722 async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
723 let response = self
724 .client
725 .post(&self.endpoint.0)
726 .header("Authorization", self.key.expose_secret())
727 .json(&json!({"query": query, "variables": variables}))
728 .send()
729 .await
730 .map_err(|e| SourceError::Unavailable {
731 message: e.to_string(),
732 })?;
733 let status = response.status();
734 let retry = response
735 .headers()
736 .get("retry-after")
737 .and_then(|v| v.to_str().ok())
738 .and_then(|v| v.parse().ok());
739 if status.as_u16() == 429 {
740 return Err(SourceError::RateLimited {
741 retry_after_seconds: retry,
742 // Linear has one rate limiter and the status is the whole of what it said,
743 // so there is nothing to add beyond the kind — which is what an absent
744 // message means.
745 message: None,
746 });
747 }
748 if status.as_u16() == 401 || status.as_u16() == 403 {
749 return Err(SourceError::Auth {
750 message: "Linear rejected the configured credential".into(),
751 });
752 }
753 if !status.is_success() {
754 // Linear puts its GraphQL error envelope in the *body* of a 400, so the status
755 // alone names the whole call and nothing about what Linear objected to. The
756 // body is Linear's answer to this request and holds no credential; it is cut
757 // because a proxy in front of Linear can answer with a page.
758 let said = elided(&response.text().await.unwrap_or_default());
759 return Err(SourceError::Unavailable {
760 message: if said.is_empty() {
761 format!("Linear returned HTTP {status}")
762 } else {
763 format!("Linear returned HTTP {status}: {said}")
764 },
765 });
766 }
767 let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
768 message: e.to_string(),
769 })?;
770 if let Some(error) = body.errors.first() {
771 if let Some(extensions) = error
772 .coded()
773 .filter(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))
774 {
775 return Err(SourceError::RateLimited {
776 retry_after_seconds: extensions.retry_after.or(retry),
777 message: None,
778 });
779 }
780 return Err(SourceError::Refused {
781 message: error.said(),
782 });
783 }
784 body.data.ok_or_else(|| SourceError::Malformed {
785 message: "GraphQL response has no data".into(),
786 })
787 }
788
789 // 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.
790 /// The label predicates, which really are spelled the same at both levels.
791 ///
792 /// `IssueFilter.labels` is an `IssueLabelCollectionFilter` and `ProjectFilter.labels`
793 /// is a `ProjectLabelCollectionFilter` — two types — but `some`, `every` and a `name`
794 /// of `StringComparator` are members of both, so one spelling satisfies each. That is
795 /// the whole of what the two filters have in common, and everything else about them is
796 /// built separately for the reason recorded on the two builders below.
797 ///
798 /// "At least one of these" is a disjunction of `eqIgnoreCase` rather than one
799 /// case-insensitive list operator, because Linear has no such operator. This source
800 /// sent `labels:{some:{name:{inIgnoreCase:[…]}}}` until Linear refused it outright,
801 /// HTTP 400, on the first read of the live lane that ever reached a label filter:
802 ///
803 /// ```text
804 /// Variable "$filter" got invalid value { inIgnoreCase: […] } at
805 /// "filter.and[1].labels.some.name"; Field "inIgnoreCase" is not defined by
806 /// type "StringComparator". Did you mean "eqIgnoreCase" or "neqIgnoreCase"?
807 /// ```
808 ///
809 /// That refusal is also the evidence for the replacement: Linear named the two members
810 /// of `StringComparator` closest to what it was sent, and `eqIgnoreCase` is one of
811 /// them — the same operator `all_of` below has always sent and the live lane has always
812 /// exercised. `in` exists there too and would need no `or`, but it is case-sensitive,
813 /// so `any_of` would stop agreeing with `all_of` and `none_of` and with what the table
814 /// at the top of this file says this source does.
815 fn label_parts(labels: &onetaskgraph_plugin_api::LabelFilter) -> Vec<Value> {
816 let mut parts = Vec::new();
817 if !labels.any_of.is_empty() {
818 parts.push(json!({"or": labels
819 .any_of
820 .iter()
821 .map(|name| json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}))
822 .collect::<Vec<_>>()}));
823 }
824 for name in &labels.all_of {
825 parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
826 }
827 for name in &labels.none_of {
828 parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
829 }
830 parts
831 }
832 fn narrowed(mut parts: Vec<Value>) -> Value {
833 if parts.len() == 1 {
834 parts.pop().unwrap()
835 } else {
836 json!({"and": parts})
837 }
838 }
839 /// The filter this source sends to `issues(filter:)`.
840 ///
841 /// **`IssueFilter` and `ProjectFilter` are different input types, and one builder for
842 /// both is what put two wrong fields on the wire.** They read as though they were the
843 /// same filter over different rows — the label member really is spelled alike, and the
844 /// `and`/`or` are identical — and a single builder producing one object for both
845 /// connections had shipped `team` and the issue's `state` shape into `projects(filter:)`
846 /// since long before this branch. Linear refused the first outright:
847 ///
848 /// ```text
849 /// Variable "$filter" got invalid value { team: { key: [Object] } };
850 /// Field "team" is not defined by type "ProjectFilter". Did you mean "lead"?
851 /// ```
852 ///
853 /// So there are two builders, and each names its own type's members. Adding a predicate
854 /// means deciding twice, on purpose, rather than once by accident.
855 fn issue_filter(
856 &self,
857 labels: &onetaskgraph_plugin_api::LabelFilter,
858 statuses: &[StatusCategory],
859 project: &ProjectFilter,
860 ) -> Value {
861 let mut parts = Vec::new();
862 if let Some(team) = &self.team {
863 parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
864 }
865 parts.extend(Self::label_parts(labels));
866 if !statuses.is_empty() {
867 parts.push(json!({"state": {"type": {"in": statuses.iter().flat_map(workflow_state_types).collect::<Vec<_>>()}}}));
868 }
869 match project {
870 ProjectFilter::Orphans => parts.push(json!({"project": {"null": true}})),
871 ProjectFilter::Is(id) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
872 _ => {}
873 }
874 Self::narrowed(parts)
875 }
876 /// The filter this source sends to `projects(filter:)`.
877 ///
878 /// Two members differ from [`Self::issue_filter`] and both are Linear's doing; see that
879 /// builder for why they are written out twice rather than shared.
880 ///
881 /// **A project has no `team`.** It has the teams it is accessible from, and
882 /// `ProjectFilter.accessibleTeams` is a `TeamCollectionFilter`, so the same team key
883 /// reaches it under `some:`. `leadTeam` is the other team-shaped member and is a
884 /// different set — one designated team rather than every team the project is in — so
885 /// narrowing by it would drop projects the configured team really does hold.
886 ///
887 /// **A project's status is not an issue's state, and they do not even share a
888 /// vocabulary.** An issue's is `WorkflowState`, reached through `IssueFilter.state`,
889 /// and its `type` is `backlog`, `unstarted`, `started`, `completed`, `canceled` or
890 /// `triage`. A project's is `ProjectStatus`, reached through `ProjectFilter.status` —
891 /// `ProjectFilter.state` exists and is *not* it: that member is a bare
892 /// `StringComparator` over a different thing — and its `type` is the `ProjectStatusType`
893 /// enum, `backlog`, `planned`, `started`, `paused`, `completed`, `canceled`. So the
894 /// nearest thing to an issue's `unstarted` is a project's `planned`, and `paused` has no
895 /// issue counterpart at all. [`project_status_types`] is that vocabulary and
896 /// [`workflow_state_types`] is the other; sending either one's words to the other's
897 /// connection matches nothing while refusing nothing, which is the worst way to be
898 /// wrong.
899 fn project_filter(
900 &self,
901 labels: &onetaskgraph_plugin_api::LabelFilter,
902 statuses: &[StatusCategory],
903 ) -> Value {
904 let mut parts = Vec::new();
905 if let Some(team) = &self.team {
906 parts.push(json!({"accessibleTeams": {"some": {"key": {"eqIgnoreCase": team.0}}}}));
907 }
908 parts.extend(Self::label_parts(labels));
909 if !statuses.is_empty() {
910 parts.push(json!({"status": {"type": {"in": statuses.iter().flat_map(project_status_types).collect::<Vec<_>>()}}}));
911 }
912 Self::narrowed(parts)
913 }
914 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
915
916 async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
917 let data = self.send(lookup.query(), lookup.variables()).await?;
918 let connection = lookup.connection();
919 let nodes = data
920 .get(connection)
921 .and_then(|v| v.get("nodes"))
922 .and_then(Value::as_array)
923 .ok_or_else(|| SourceError::Malformed {
924 message: format!("missing {connection}.nodes"),
925 })?;
926 // A node this comparison cannot read is malformed rather than a nonmatch: dropping
927 // it would turn Linear having answered nonsense into this source reporting no such
928 // status, which is a different thing and reads as the caller's mistake.
929 let matched = match lookup.local_name() {
930 Some(name) => {
931 let mut matched = Vec::new();
932 for node in nodes {
933 if str_at(node, "name")?.eq_ignore_ascii_case(name) {
934 matched.push(node);
935 }
936 }
937 matched
938 }
939 None => nodes.iter().collect::<Vec<_>>(),
940 };
941 match matched.as_slice() {
942 [] => Err(SourceError::Refused {
943 message: format!(
944 "source {} cannot resolve {}: found 0 matches",
945 self.name,
946 lookup.diagnostic()
947 ),
948 }),
949 [node] => Ok(NativeId(backend_id(node, "id")?.to_owned())),
950 nodes => {
951 let ids = nodes
952 .iter()
953 .map(|node| backend_id(node, "id"))
954 .collect::<Result<Vec<_>, _>>()?;
955 Err(SourceError::Refused {
956 message: format!(
957 "source {} cannot resolve {}: found {} matches with ids {ids:?}",
958 self.name,
959 lookup.diagnostic(),
960 nodes.len()
961 ),
962 })
963 }
964 }
965 }
966 async fn team_id(&self) -> Result<NativeId, SourceError> {
967 let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
968 message: format!(
969 "source {} needs config.team before it can create Linear items",
970 self.name
971 ),
972 })?;
973 self.one_id(Lookup::Team(&team.0)).await
974 }
975 async fn label_ids(
976 &self,
977 labels: &[Label],
978 kind: WriteKind,
979 ) -> Result<Vec<NativeId>, SourceError> {
980 let mut ids = Vec::with_capacity(labels.len());
981 for label in labels {
982 ids.push(
983 self.one_id(if matches!(kind, WriteKind::Project) {
984 Lookup::ProjectLabel(&label.name)
985 } else {
986 Lookup::IssueLabel(&label.name)
987 })
988 .await?,
989 );
990 }
991 Ok(ids)
992 }
993 fn write_description(
994 &self,
995 content: Option<&str>,
996 metadata: &std::collections::BTreeMap<String, Value>,
997 repositories: &[Repository],
998 edges: &[DependencyEdge],
999 kind: WriteKind,
1000 ) -> Result<Option<String>, SourceError> {
1001 let recorded = edges
1002 .iter()
1003 .filter(|edge| {
1004 edge.to.kind
1005 != match kind {
1006 WriteKind::Task => ItemKind::Task,
1007 WriteKind::Project => ItemKind::Project,
1008 }
1009 || edge
1010 .to
1011 .id()
1012 .split_once(':')
1013 .is_some_and(|(source, _)| source != self.name.as_str())
1014 })
1015 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
1016 .collect::<Vec<_>>();
1017 Self::long_form(content, metadata, repositories, recorded)
1018 }
1019
1020 /// The one long-form field a Linear item has, with this source's own slot at the end.
1021 ///
1022 /// Shared by every kind this source writes rather than reimplemented per kind: a
1023 /// document keeps caller metadata in exactly the slot an issue and a project do, which
1024 /// is what lets the same read side take it back out.
1025 fn long_form(
1026 content: Option<&str>,
1027 metadata: &std::collections::BTreeMap<String, Value>,
1028 repositories: &[Repository],
1029 recorded: Vec<Value>,
1030 ) -> Result<Option<String>, SourceError> {
1031 let mut metadata = metadata.clone();
1032 if repositories.is_empty() {
1033 metadata.remove(Repository::METADATA_KEY);
1034 } else {
1035 metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
1036 }
1037 if recorded.is_empty() {
1038 metadata.remove(DependencyEdge::RECORDED_KEY);
1039 } else {
1040 metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
1041 }
1042 let visible = content.unwrap_or_default();
1043 if metadata.is_empty() {
1044 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
1045 }
1046 let encoded = serde_json::to_string(&metadata).map_err(|error| SourceError::Malformed {
1047 message: error.to_string(),
1048 })?;
1049 Ok(Some(if visible.is_empty() {
1050 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
1051 } else {
1052 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
1053 }))
1054 }
1055 /// What this source says when asked for a project edge carrying no ordering.
1056 ///
1057 /// Linear's project relations have exactly one type and it is an ordering. Asked on
1058 /// 2026-09-04 to create one typed `related` — and separately `blocks` and `dependsOn`
1059 /// — the real API refused each with `Argument Validation Error` and
1060 /// `constraints: {"isEnum": "type must be one of the following values: dependency"}`.
1061 /// That is Linear's own enumeration of the field, from the validator behind GraphQL
1062 /// where introspection cannot reach it, and it has one member. An issue relation is a
1063 /// different relation with a different set, which does include `related`, so this
1064 /// reaches projects alone.
1065 fn unordered_project_relation(&self, near: &NativeId, far: &str) -> SourceError {
1066 SourceError::Refused {
1067 message: format!(
1068 "source {} cannot carry an unordered dependency between projects, because \
1069 Linear types every project relation `dependency` and that is an ordering; \
1070 record {near} to {far} as a dependency, or between tasks",
1071 self.name,
1072 near = near.0,
1073 ),
1074 }
1075 }
1076 /// The one edge [`Self::unordered_project_relation`] refuses, if there is one here.
1077 fn unordered_project_edge(edges: &[DependencyEdge]) -> Option<&DependencyEdge> {
1078 edges
1079 .iter()
1080 .find(|edge| edge.to.kind == ItemKind::Project && edge.kind == DependencyKind::Related)
1081 }
1082 async fn write_relations(
1083 &self,
1084 near: &NativeId,
1085 edges: &[DependencyEdge],
1086 kind: WriteKind,
1087 ) -> Result<(), SourceError> {
1088 let mut cursor: Option<Cursor> = None;
1089 loop {
1090 let data = self
1091 .send(
1092 if matches!(kind, WriteKind::Project) {
1093 PROJECT_RELATIONS
1094 } else {
1095 ISSUE_RELATIONS
1096 },
1097 json!({"id":near.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
1098 )
1099 .await?;
1100 let root = data
1101 .get(if matches!(kind, WriteKind::Project) {
1102 "project"
1103 } else {
1104 "issue"
1105 })
1106 .ok_or_else(|| SourceError::Malformed {
1107 message: "missing relation item".into(),
1108 })?;
1109 let relations = root
1110 .get("relations")
1111 .ok_or_else(|| SourceError::Malformed {
1112 message: "missing relations".into(),
1113 })?;
1114 for relation in relations
1115 .get("nodes")
1116 .and_then(Value::as_array)
1117 .ok_or_else(|| SourceError::Malformed {
1118 message: "missing relations.nodes".into(),
1119 })?
1120 {
1121 let id = backend_id(relation, "id")?;
1122 let (query, mutation) = if matches!(kind, WriteKind::Project) {
1123 (
1124 graphql::PROJECT_RELATION_DELETE,
1125 MutationRoot::ProjectRelationDelete,
1126 )
1127 } else {
1128 (
1129 graphql::ISSUE_RELATION_DELETE,
1130 MutationRoot::IssueRelationDelete,
1131 )
1132 };
1133 let deleted = self.send(query, json!({"id":id})).await?;
1134 mutation_payload(&deleted, mutation)?;
1135 }
1136 let Some(next) = page_next(relations)? else {
1137 break;
1138 };
1139 cursor = Some(next);
1140 }
1141 // Linear requires an anchor at each end of a project relation and validates both
1142 // against an enum GraphQL cannot see: `ProjectRelationCreateInput` declares them
1143 // `String!` and enumerates nothing, and the field descriptions read as a choice
1144 // between the project and a milestone, which is not what they are. Linear's own
1145 // refusal enumerates them — sent `project` in both, it answered `anchorType must
1146 // be one of the following values: start, end, milestone` — and `milestone` needs
1147 // an id this source never sends, so the two whole-project anchors are the whole of
1148 // what it can send.
1149 //
1150 // **Which of them goes where carries the direction, and the two id slots do not.**
1151 // Linear stores whatever pair it is given and reads a backwards dependency as
1152 // readily as the right one, so acceptance settles nothing; what does is Linear's
1153 // own reading of a stored relation, published as the computed `ProjectFilter`
1154 // members `hasBlockingRelations` ("projects which are blocking") and
1155 // `hasBlockedByRelations` ("projects which are blocked"). Three relations between
1156 // two scratch projects, read back through them on 2026-09-04:
1157 //
1158 // | `projectId` | `anchorType` | `relatedProjectId` | `relatedAnchorType` | blocked | blocking |
1159 // | ----------- | ------------ | ------------------ | ------------------- | ------- | -------- |
1160 // | A | `start` | B | `end` | A | B |
1161 // | A | `end` | B | `start` | B | A |
1162 // | B | `end` | A | `start` | A | B |
1163 //
1164 // Rows one and three exchange the ids and the anchors together and read alike;
1165 // rows one and two exchange only the anchors and the reading flips. So the project
1166 // anchored `start` is the one that waits, whichever slot it sits in, and row one is
1167 // what this source sends — `near`, the item that depends, in `projectId`. Linear's
1168 // own callers put the blocker there instead, so copying their `end`/`start` pair
1169 // across by position would state every dependency backwards in the workspace, and
1170 // nothing would refuse it.
1171 const NEAR_ANCHOR: &str = "start";
1172 const FAR_ANCHOR: &str = "end";
1173 for edge in edges {
1174 if edge.to.kind
1175 != match kind {
1176 WriteKind::Task => ItemKind::Task,
1177 WriteKind::Project => ItemKind::Project,
1178 }
1179 {
1180 continue;
1181 }
1182 let far = match edge.to.id().split_once(':') {
1183 Some((source, native)) if source == self.name.as_str() => native,
1184 Some(_) => continue,
1185 None => edge.to.id(),
1186 };
1187 // A project relation is not spelled the way an issue relation is, and this is
1188 // the whole of what a project's `type` may say.
1189 //
1190 // `blocks` there is what the live journey's project write was refused for
1191 // once the two anchors above stopped being missing: Linear answered HTTP 200
1192 // with `Argument Validation Error`, the message class its input validator
1193 // raises for a value outside an accepted set, having already accepted every
1194 // field of the same input by name — which is what tells that refusal apart
1195 // from the missing-field one before it, and what says the anchors were not the
1196 // cause.
1197 //
1198 // Which field, and what it takes, was measured against the real API on
1199 // 2026-09-04 rather than inferred. Each of `blocks`, `dependsOn`, `related`
1200 // and `DEPENDENCY` was refused with `property: "type"` and
1201 // `constraints: {"isEnum": "type must be one of the following values:
1202 // dependency"}`; `dependency` was accepted. That enumeration, like the
1203 // anchors' above, reaches this source through the validator's `extensions`;
1204 // see `GqlError::said`.
1205 //
1206 // A `Related` project edge is refused at the top of this function by that same
1207 // enumeration: it has one member and it is an ordering. An issue relation is a
1208 // different relation with a different set, which does include `related`.
1209 let relation_type = match (kind, edge.kind) {
1210 (WriteKind::Project, DependencyKind::Blocks) => "dependency",
1211 (WriteKind::Task, DependencyKind::Blocks) => "blocks",
1212 (WriteKind::Task, DependencyKind::Related) => "related",
1213 // Unreachable past `write_project`'s guard, and an error rather than a
1214 // skip so it stays that way: an edge dropped here would be a copy
1215 // reporting success for a dependency the destination does not hold.
1216 (WriteKind::Project, DependencyKind::Related) => {
1217 return Err(self.unordered_project_relation(near, edge.to.id()));
1218 }
1219 };
1220 let (query, input) = if matches!(kind, WriteKind::Project) {
1221 (
1222 graphql::PROJECT_RELATION_CREATE,
1223 json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type,"anchorType":NEAR_ANCHOR,"relatedAnchorType":FAR_ANCHOR}),
1224 )
1225 } else {
1226 (
1227 graphql::ISSUE_RELATION_CREATE,
1228 json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
1229 )
1230 };
1231 let data = self.send(query, json!({"input":input})).await?;
1232 let mutation = if matches!(kind, WriteKind::Project) {
1233 MutationRoot::ProjectRelationCreate
1234 } else {
1235 MutationRoot::IssueRelationCreate
1236 };
1237 let payload = mutation_payload(&data, mutation)?;
1238 let relation = payload
1239 .get(if matches!(kind, WriteKind::Project) {
1240 "projectRelation"
1241 } else {
1242 "issueRelation"
1243 })
1244 .ok_or_else(|| SourceError::Malformed {
1245 message: format!("missing {} relation", mutation.as_str()),
1246 })?;
1247 backend_id(relation, "id")?;
1248 }
1249 Ok(())
1250 }
1251
1252 async fn prepare_edges(
1253 &self,
1254 edges: &[DependencyEdge],
1255 kind: WriteKind,
1256 ) -> Result<Vec<DependencyEdge>, SourceError> {
1257 let mut prepared = Vec::with_capacity(edges.len());
1258 for edge in edges {
1259 let mut edge = edge.clone();
1260 if edge.to.kind
1261 == match kind {
1262 WriteKind::Task => ItemKind::Task,
1263 WriteKind::Project => ItemKind::Project,
1264 }
1265 && edge
1266 .to
1267 .id()
1268 .split_once(':')
1269 .is_some_and(|(source, _)| source != self.name.as_str())
1270 {
1271 let mut cursor: Option<Cursor> = None;
1272 loop {
1273 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":{}})).await?;
1274 let (items, next) = if matches!(kind, WriteKind::Project) {
1275 let page = connection(&data, "projects", map_project)?;
1276 (
1277 page.items
1278 .into_iter()
1279 .map(|item| (item.id, item.metadata))
1280 .collect::<Vec<_>>(),
1281 page.next,
1282 )
1283 } else {
1284 let page = connection(&data, "issues", |v| map_task(v, &self.name))?;
1285 (
1286 page.items
1287 .into_iter()
1288 .map(|item| (item.id, item.metadata))
1289 .collect::<Vec<_>>(),
1290 page.next,
1291 )
1292 };
1293 if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
1294 metadata.get("onetaskgraph.origin").and_then(Value::as_str)
1295 == Some(edge.to.id())
1296 }) {
1297 edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
1298 break;
1299 }
1300 let Some(next) = next else { break };
1301 cursor = Some(next);
1302 }
1303 }
1304 prepared.push(edge);
1305 }
1306 Ok(prepared)
1307 }
1308}
1309
1310#[async_trait::async_trait]
1311impl TaskSource for LinearSource {
1312 fn kind(&self) -> &'static str {
1313 KIND
1314 }
1315 fn capabilities(&self) -> Capabilities {
1316 Capabilities {
1317 projects: Support::Native,
1318 documents: Support::Native,
1319 comments: Support::Native,
1320 priority: Support::Native,
1321 filter_by_priority: Support::Unsupported,
1322 orphan_tasks: Support::Native,
1323 filter_by_label: Support::Native,
1324 filter_by_status: Support::Native,
1325 search_title: Support::Unsupported,
1326 search_content: Support::Unsupported,
1327 task_dependencies: DependencySupport::BothDirections,
1328 project_dependencies: DependencySupport::BothDirections,
1329 max_page_size: MAX_PAGE_SIZE,
1330 }
1331 }
1332 fn writes(&self) -> WriteSupport {
1333 WriteSupport::Supported
1334 }
1335 async fn health(&self) -> Result<Health, SourceError> {
1336 let data = self.send(VIEWER, json!({})).await?;
1337 str_at(
1338 data.get("viewer").ok_or_else(|| SourceError::Malformed {
1339 message: "missing viewer".into(),
1340 })?,
1341 "id",
1342 )?;
1343 Ok(Health {
1344 reachable: true,
1345 detail: None,
1346 })
1347 }
1348 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1349 let d = self.send(ISSUE, json!({"id":id.0})).await?;
1350 optional(&d, "issue", |v| map_task(v, &self.name))
1351 }
1352 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1353 let d = self.send(PROJECT, json!({"id":id.0})).await?;
1354 optional(&d, "project", map_project)
1355 }
1356 async fn query_tasks(
1357 &self,
1358 query: &TaskQuery,
1359 page: &PageRequest,
1360 ) -> Result<Page<Task>, SourceError> {
1361 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.labels,&query.statuses,&query.project)})).await?;
1362 connection(&d, "issues", |v| map_task(v, &self.name))
1363 }
1364 async fn query_projects(
1365 &self,
1366 query: &ProjectQuery,
1367 page: &PageRequest,
1368 ) -> Result<Page<Project>, SourceError> {
1369 // 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.
1370 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?;
1371 connection(&d, "projects", map_project)
1372 }
1373 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1374 let d = self
1375 .send(
1376 LABELS,
1377 json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0)}),
1378 )
1379 .await?;
1380 connection(&d, "issueLabels", map_label)
1381 }
1382 async fn task_dependencies(
1383 &self,
1384 id: &NativeId,
1385 direction: Direction,
1386 page: &PageRequest,
1387 ) -> Result<Page<DependencyEdge>, SourceError> {
1388 self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
1389 .await
1390 }
1391 async fn project_dependencies(
1392 &self,
1393 id: &NativeId,
1394 direction: Direction,
1395 page: &PageRequest,
1396 ) -> Result<Page<DependencyEdge>, SourceError> {
1397 self.dependencies(
1398 PROJECT_RELATIONS,
1399 DependencyRoot::Project,
1400 id,
1401 direction,
1402 page,
1403 )
1404 .await
1405 }
1406 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1407 // Before anything is read or written, because nothing Linear could answer changes
1408 // it: see `NO_DELIVERY`. A write that dropped either list would report success for a
1409 // task the destination does not hold.
1410 let named = if !write.item.delivers.is_empty() {
1411 Some("delivers")
1412 } else if !write.item.delivered_by.is_empty() {
1413 Some("delivered_by")
1414 } else {
1415 delivery_key_in(&write.item.metadata)
1416 };
1417 if let Some(named) = named {
1418 return Err(self.undeliverable(named, "task"));
1419 }
1420 let edges = self
1421 .prepare_edges(&write.depends_on, WriteKind::Task)
1422 .await?;
1423 let team = self.team_id().await?;
1424 let state = self
1425 .one_id(Lookup::IssueState {
1426 name: &write.item.status.name,
1427 team: &team,
1428 })
1429 .await?;
1430 let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
1431 let description = self.write_description(
1432 write.item.content.as_deref(),
1433 &write.item.metadata,
1434 &write.item.repositories,
1435 &edges,
1436 WriteKind::Task,
1437 )?;
1438 // `priority` on a create and on an update alike, `0` included: an update that left
1439 // it out would keep whatever the destination held, so a copy moving an issue back to
1440 // no priority would report success for a priority the destination still carries.
1441 let input = json!({"title":write.item.title,"description":description,"stateId":state,"priority":linear_priority(write.item.priority),"labelIds":labels,"projectId":write.item.project.as_ref().map(|id| id.0.clone())});
1442 let (query, variables, root) = match &write.target {
1443 Some(id) => (
1444 graphql::ISSUE_UPDATE,
1445 json!({"id":id.0,"input":input}),
1446 MutationRoot::IssueUpdate,
1447 ),
1448 None => (
1449 graphql::ISSUE_CREATE,
1450 {
1451 let mut input = input;
1452 input["teamId"] = Value::String(team.0);
1453 json!({"input":input})
1454 },
1455 MutationRoot::IssueCreate,
1456 ),
1457 };
1458 let data = self.send(query, variables).await?;
1459 let issue =
1460 mutation_payload(&data, root)?
1461 .get("issue")
1462 .ok_or_else(|| SourceError::Malformed {
1463 message: format!("missing {}.issue", root.as_str()),
1464 })?;
1465 let id = NativeId(backend_id(issue, "id")?.into());
1466 self.write_relations(&id, &edges, WriteKind::Task).await?;
1467 Ok(id)
1468 }
1469 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1470 // Before anything is read or written, and before the item's own description
1471 // records these edges: an edge Linear will never accept has to refuse the whole
1472 // write, or a copy would create the project and then fail relating it, leaving the
1473 // undo to clean up a write that could have been refused without a call at all.
1474 if let Some(edge) = Self::unordered_project_edge(&write.depends_on) {
1475 return Err(self.unordered_project_relation(&write.item.id, edge.to.id()));
1476 }
1477 if let Some(key) = delivery_key_in(&write.item.metadata) {
1478 return Err(self.undeliverable(key, "project"));
1479 }
1480 let edges = self
1481 .prepare_edges(&write.depends_on, WriteKind::Project)
1482 .await?;
1483 let team = self.team_id().await?;
1484 let status = self
1485 .one_id(Lookup::ProjectStatus(&write.item.status.name))
1486 .await?;
1487 let labels = self
1488 .label_ids(&write.item.labels, WriteKind::Project)
1489 .await?;
1490 let description = self.write_description(
1491 write.item.content.as_deref(),
1492 &write.item.metadata,
1493 &write.item.repositories,
1494 &edges,
1495 WriteKind::Project,
1496 )?;
1497 let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
1498 let (query, variables, root) = match &write.target {
1499 Some(id) => (
1500 graphql::PROJECT_UPDATE,
1501 json!({"id":id.0,"input":input}),
1502 MutationRoot::ProjectUpdate,
1503 ),
1504 None => (
1505 graphql::PROJECT_CREATE,
1506 {
1507 let mut input = input;
1508 input["teamIds"] = json!([team]);
1509 json!({"input":input})
1510 },
1511 MutationRoot::ProjectCreate,
1512 ),
1513 };
1514 let data = self.send(query, variables).await?;
1515 let project = mutation_payload(&data, root)?
1516 .get("project")
1517 .ok_or_else(|| SourceError::Malformed {
1518 message: format!("missing {}.project", root.as_str()),
1519 })?;
1520 let id = NativeId(backend_id(project, "id")?.into());
1521 self.write_relations(&id, &edges, WriteKind::Project)
1522 .await?;
1523 Ok(id)
1524 }
1525 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
1526 // An id naming nothing is the state this asks for, not an error — Linear reports
1527 // an unknown issue as an errored response rather than an unsuccessful payload, and
1528 // `get_task` answering `None` is what says the item is already gone.
1529 if self.get_task(id).await?.is_none() {
1530 return Ok(());
1531 }
1532 let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
1533 mutation_payload(&data, MutationRoot::IssueDelete)?;
1534 Ok(())
1535 }
1536 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
1537 // An id naming nothing is the state this asks for, on exactly the terms
1538 // `delete_task` reads it on.
1539 if self.get_project(id).await?.is_none() {
1540 return Ok(());
1541 }
1542 let data = self
1543 .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
1544 .await?;
1545 mutation_payload(&data, MutationRoot::ProjectDelete)?;
1546 Ok(())
1547 }
1548 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
1549 // Read as an optional although the pinned `document(id:)` returns `Document!`, for
1550 // the reason `delete_task` records: Linear answers an id naming nothing with an
1551 // errored response rather than a null, and reading the null defensively is what
1552 // keeps a responder that does answer one from being a malformed-response failure.
1553 let d = self.send(DOCUMENT, json!({"id":id.0})).await?;
1554 optional(&d, "document", map_document)
1555 }
1556 async fn query_documents(
1557 &self,
1558 query: &DocumentQuery,
1559 page: &PageRequest,
1560 ) -> Result<Page<Document>, SourceError> {
1561 // `query.text` is read by nothing here on purpose. Both searches are declared
1562 // `Unsupported`, and capability rule 2 says an ignored predicate returns the
1563 // *wider* set for the engine to narrow — half-applying one is what would drop rows.
1564 let want = page.limit.min(MAX_PAGE_SIZE) as usize;
1565 let mut filter = serde_json::Map::new();
1566 if let ProjectFilter::Is(id) = &query.project {
1567 filter.insert("project".into(), json!({"id": {"eq": id.0}}));
1568 }
1569 let filter = Value::Object(filter);
1570 let mut items = Vec::new();
1571 let mut cursor = page.cursor.clone();
1572 loop {
1573 // Only what is still owed, so the predicates applied here can never make this
1574 // return more than the caller asked for, and never drop what it fetched.
1575 let first = want.saturating_sub(items.len()).max(1);
1576 let d = self
1577 .send(
1578 DOCUMENTS,
1579 json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
1580 )
1581 .await?;
1582 let fetched = connection(&d, "documents", map_document)?;
1583 items.extend(
1584 fetched
1585 .items
1586 .into_iter()
1587 .filter(|document| document_matches(document, &query.project, &query.labels)),
1588 );
1589 cursor = fetched.next;
1590 if cursor.is_none() || items.len() >= want {
1591 return Ok(Page {
1592 items,
1593 next: cursor,
1594 });
1595 }
1596 }
1597 }
1598 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
1599 // Two refusals by name rather than two silent drops. Linear's own document type
1600 // has no labels and a document is not work, so neither a label nor a dependency
1601 // has anywhere here to land — and a copy that dropped one would report success for
1602 // an item the destination does not hold.
1603 if !write.item.labels.is_empty() {
1604 let named = write
1605 .item
1606 .labels
1607 .iter()
1608 .map(|label| label.name.as_str())
1609 .collect::<Vec<_>>()
1610 .join(", ");
1611 return Err(SourceError::Refused {
1612 message: format!(
1613 "source {} cannot carry a document's labels, because Linear's own \
1614 document type has none: {named}",
1615 self.name
1616 ),
1617 });
1618 }
1619 if !write.depends_on.is_empty()
1620 || write
1621 .item
1622 .metadata
1623 .contains_key(DependencyEdge::RECORDED_KEY)
1624 {
1625 return Err(SourceError::Refused {
1626 message: format!(
1627 "source {} cannot carry {} on a document, because a document is not \
1628 work and nothing may depend on one",
1629 self.name,
1630 DependencyEdge::RECORDED_KEY
1631 ),
1632 });
1633 }
1634 if let Some(key) = delivery_key_in(&write.item.metadata) {
1635 return Err(self.undeliverable(key, "document"));
1636 }
1637 let content = Self::long_form(
1638 write.item.content.as_deref(),
1639 &write.item.metadata,
1640 &write.item.repositories,
1641 Vec::new(),
1642 )?;
1643 let project = write.item.project.as_ref().map(|id| id.0.clone());
1644 let (query, variables, root) = match &write.target {
1645 Some(id) => {
1646 // A target this workspace does not hold is refused rather than created:
1647 // the engine established that id before asking, so an absent one is a race
1648 // this destination must not paper over by writing a second document.
1649 if self.get_document(id).await?.is_none() {
1650 return Err(SourceError::Refused {
1651 message: format!("source {} holds no document {}", self.name, id.0),
1652 });
1653 }
1654 (
1655 graphql::DOCUMENT_UPDATE,
1656 json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
1657 MutationRoot::DocumentUpdate,
1658 )
1659 }
1660 None => {
1661 let mut input = json!({"title":write.item.title,"content":content});
1662 // A Linear document lives in a project, an initiative, an issue or a team.
1663 // One filed under no project needs the configured team to be its home, and
1664 // one filed under a project already has one — so the team is asked for
1665 // only where it is the answer, rather than made a condition of every write.
1666 //
1667 // **`projectId` is left out rather than sent as null, and that is Linear's
1668 // rule rather than tidiness.** `documentCreate` refuses an input that names
1669 // more than one home — `Exactly one of initiativeId, teamId, issueId,
1670 // releaseId, cycleId or projectId must be defined.` — and it counts a
1671 // *present* key, observed on 2026-09-04: `{projectId: null, teamId: …}` is
1672 // refused where `{teamId: …}` is accepted. So a document filed under no
1673 // project must carry no `projectId` at all. `documentUpdate` is the
1674 // opposite and keeps its explicit null, because there the null is the
1675 // instruction — it is how a document is moved out of a project, and
1676 // omitting the key would leave it where it was.
1677 match &project {
1678 Some(project) => input["projectId"] = Value::String(project.clone()),
1679 None => input["teamId"] = Value::String(self.team_id().await?.0),
1680 }
1681 (
1682 graphql::DOCUMENT_CREATE,
1683 json!({ "input": input }),
1684 MutationRoot::DocumentCreate,
1685 )
1686 }
1687 };
1688 let data = self.send(query, variables).await?;
1689 let document = mutation_payload(&data, root)?
1690 .get("document")
1691 .ok_or_else(|| SourceError::Malformed {
1692 message: format!("missing {}.document", root.as_str()),
1693 })?;
1694 Ok(NativeId(backend_id(document, "id")?.into()))
1695 }
1696 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
1697 // An id naming nothing is the state this asks for, on exactly the terms
1698 // `delete_task` reads it on.
1699 if self.get_document(id).await?.is_none() {
1700 return Ok(());
1701 }
1702 let data = self
1703 .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
1704 .await?;
1705 mutation_payload(&data, MutationRoot::DocumentDelete)?;
1706 Ok(())
1707 }
1708 async fn task_comments(
1709 &self,
1710 task: &NativeId,
1711 page: &PageRequest,
1712 ) -> Result<Option<Page<Comment>>, SourceError> {
1713 // A page of no rows is not a page: refused here rather than sent as `last: 0`, which
1714 // would answer an empty page that reads as a task with no comments.
1715 if page.limit == 0 {
1716 return Err(SourceError::Config {
1717 message: "a page limit of 0 is not a page; ask for at least 1 comment".to_owned(),
1718 });
1719 }
1720 // One request rather than a task lookup and then a read: the issue the comments
1721 // hang off answers "no such task" by itself, on exactly the terms `get_task` reads
1722 // it — null, or trashed.
1723 let d = self
1724 .send(
1725 graphql::ISSUE_COMMENTS,
1726 json!({"id":task.0,"last":page.limit.min(MAX_PAGE_SIZE),"before":page.cursor.as_ref().map(|c|&c.0)}),
1727 )
1728 .await?;
1729 optional(&d, "issue", comment_page)
1730 }
1731 async fn add_comment(
1732 &self,
1733 task: &NativeId,
1734 comment: &NewComment,
1735 ) -> Result<Option<Comment>, SourceError> {
1736 // Before anything is sent, because nothing Linear could answer changes it: see the
1737 // ruling on the author in this crate's module documentation.
1738 if let Some(author) = &comment.author {
1739 return Err(SourceError::Refused {
1740 message: format!(
1741 "source {} cannot post a comment as {author:?}, because Linear records the \
1742 user whose API key makes the request as the author of every comment; \
1743 leave --author out to post as that user",
1744 self.name
1745 ),
1746 });
1747 }
1748 let Some(issue) = self.commented_issue(task).await? else {
1749 return Ok(None);
1750 };
1751 let data = self
1752 .send(
1753 graphql::COMMENT_CREATE,
1754 json!({"input":{"issueId":issue.0,"body":comment.body.as_str()}}),
1755 )
1756 .await?;
1757 written_comment(&data, MutationRoot::CommentCreate).map(Some)
1758 }
1759 async fn edit_comment(
1760 &self,
1761 task: &NativeId,
1762 comment: &NativeId,
1763 body: &CommentBody,
1764 ) -> Result<Option<Comment>, SourceError> {
1765 if !self.comment_is_on(task, comment).await? {
1766 return Ok(None);
1767 }
1768 // `body` alone: the id, the author and the time it was written are the comment's
1769 // own, so nothing else is sent that Linear could move.
1770 let data = self
1771 .send(
1772 graphql::COMMENT_UPDATE,
1773 json!({"id":comment.0,"input":{"body":body.as_str()}}),
1774 )
1775 .await?;
1776 written_comment(&data, MutationRoot::CommentUpdate).map(Some)
1777 }
1778 async fn delete_comment(
1779 &self,
1780 task: &NativeId,
1781 comment: &NativeId,
1782 ) -> Result<Option<NativeId>, SourceError> {
1783 if !self.comment_is_on(task, comment).await? {
1784 return Ok(None);
1785 }
1786 let data = self
1787 .send(graphql::COMMENT_DELETE, json!({"id":comment.0}))
1788 .await?;
1789 mutation_payload(&data, MutationRoot::CommentDelete)?;
1790 Ok(Some(comment.clone()))
1791 }
1792 async fn set_task_status(
1793 &self,
1794 id: &NativeId,
1795 category: StatusCategory,
1796 ) -> Result<Option<Status>, SourceError> {
1797 // Before any request: a category no workflow state has is not one Linear could
1798 // answer differently for another issue. `workflow_state_types` is the same mapping
1799 // the status filter narrows with, so a status this sets is one that filter finds.
1800 let Some(state_type) = workflow_state_types(&category).first().copied() else {
1801 return Err(SourceError::Refused {
1802 message: format!(
1803 "source {} cannot set a task's status to {}: that category is disabled for \
1804 this source, because Linear has no workflow state of that kind — its \
1805 workflow states are triage, backlog, unstarted, started, completed and \
1806 canceled; choose backlog, todo, in-progress, done or cancelled",
1807 self.name,
1808 category_word(category)
1809 ),
1810 });
1811 };
1812 let Some(task) = self.get_task(id).await? else {
1813 return Ok(None);
1814 };
1815 // Already in the category asked for: its own state is left where it is. A team can
1816 // hold several states of one type — `In Progress` and `In Review` are both `started`
1817 // — and moving an issue from one to the other is a change nobody asked for.
1818 if task.status.category == category {
1819 return Ok(Some(task.status));
1820 }
1821 let team = self.team_id().await?;
1822 let data = self
1823 .send(
1824 graphql::ISSUE_STATE_OF_TYPE,
1825 json!({"type":state_type,"team":team.0}),
1826 )
1827 .await?;
1828 let nodes = data
1829 .get("workflowStates")
1830 .and_then(|v| v.get("nodes"))
1831 .and_then(Value::as_array)
1832 .ok_or_else(|| SourceError::Malformed {
1833 message: "missing workflowStates.nodes".into(),
1834 })?;
1835 // The first node Linear lists, and deliberately no choice beyond that: every state of
1836 // this type reads back as the category asked for, which is the whole of what a status
1837 // write owes, and nothing a category carries says which of several the caller meant.
1838 let Some(state) = nodes.first() else {
1839 return Err(SourceError::Refused {
1840 message: format!(
1841 "source {} cannot set task {} to {}: its configured team has no workflow \
1842 state of type {state_type}; add one to the team in Linear",
1843 self.name,
1844 id.0,
1845 category_word(category)
1846 ),
1847 });
1848 };
1849 let state_id = backend_id(state, "id")?;
1850 let name = str_at(state, "name")?.to_owned();
1851 // `stateId` alone, so nothing else about the issue can move: Linear's
1852 // `IssueUpdateInput` makes every member optional and leaves an absent one as it was.
1853 let data = self
1854 .send(
1855 graphql::ISSUE_UPDATE,
1856 json!({"id":task.id.0,"input":{"stateId":state_id}}),
1857 )
1858 .await?;
1859 let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
1860 .get("issue")
1861 .ok_or_else(|| SourceError::Malformed {
1862 message: "missing issueUpdate.issue".into(),
1863 })?;
1864 backend_id(issue, "id")?;
1865 Ok(Some(Status { category, name }))
1866 }
1867 async fn set_task_priority(
1868 &self,
1869 id: &NativeId,
1870 priority: Priority,
1871 ) -> Result<Option<Priority>, SourceError> {
1872 // Read first, on exactly the terms `set_task_status` reads: Linear answers an
1873 // `issueUpdate` naming no issue with an errored response rather than a null, so the
1874 // read is what tells "no such task" from a refusal — and a trashed issue is not one
1875 // this source holds, so it is never written to.
1876 let Some(task) = self.get_task(id).await? else {
1877 return Ok(None);
1878 };
1879 // `priority` alone: every member of `IssueUpdateInput` is optional and Linear leaves
1880 // an absent one as the issue holds it, so nothing else about the issue can move.
1881 let data = self
1882 .send(
1883 graphql::ISSUE_PRIORITY_UPDATE,
1884 json!({"id":task.id.0,"input":{"priority":linear_priority(priority)}}),
1885 )
1886 .await?;
1887 let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
1888 .get("issue")
1889 .filter(|issue| !issue.is_null())
1890 .ok_or_else(|| SourceError::Malformed {
1891 message: "missing issueUpdate.issue".into(),
1892 })?;
1893 written_is(issue, &task.id)?;
1894 issue_priority(issue).map(Some)
1895 }
1896 async fn set_task_content(
1897 &self,
1898 id: &NativeId,
1899 content: &str,
1900 ) -> Result<Option<()>, SourceError> {
1901 // The raw description, because the metadata slot lives in that same field and has to
1902 // go back byte for byte: re-encoding it would be a metadata write nobody asked for.
1903 // The whole issue is read as `get_task` reads it first, so an issue this source could
1904 // not read is refused before anything is written rather than overwritten blind.
1905 let data = self.send(ISSUE, json!({"id":id.0})).await?;
1906 let Some((issue, slot)) = optional(&data, "issue", |v| {
1907 map_task(v, &self.name)?;
1908 let slot = match optional_str(v, "description")? {
1909 Some(description) => metadata_slot(description)?.map(str::to_owned),
1910 None => None,
1911 };
1912 Ok((NativeId(backend_id(v, "id")?.into()), slot))
1913 })?
1914 else {
1915 return Ok(None);
1916 };
1917 let description = match &slot {
1918 None => content.to_owned(),
1919 Some(slot) if content.is_empty() => slot.clone(),
1920 // The separator `long_form` writes, so a content write and a copy leave one shape.
1921 Some(slot) => format!("{content}\n\n{slot}"),
1922 };
1923 // Checked before anything is sent: content ending in what this source reads as its own
1924 // metadata slot would read back as metadata rather than as the content it was.
1925 if metadata_slot(&description)? != slot.as_deref() {
1926 return Err(SourceError::Refused {
1927 message: format!(
1928 "this content ends in what source {} reads as its own metadata slot, so part \
1929 of it would read back as metadata rather than as content; next: remove that \
1930 trailing block from the content",
1931 self.name
1932 ),
1933 });
1934 }
1935 // And what a read will report is exactly what was asked for, or nothing is sent.
1936 let (reads, _) = metadata_description(Some(description.clone()))?;
1937 if reads.as_deref().unwrap_or_default() != content {
1938 return Err(SourceError::Refused {
1939 message: format!(
1940 "this content would read back from source {} as {:?} rather than as itself; \
1941 next: change how the content ends",
1942 self.name,
1943 reads.as_deref().unwrap_or_default()
1944 ),
1945 });
1946 }
1947 // `description` alone, for the reason `set_task_priority` sends `priority` alone.
1948 let data = self
1949 .send(
1950 graphql::ISSUE_UPDATE,
1951 json!({"id":issue.0,"input":{"description":description}}),
1952 )
1953 .await?;
1954 let written = mutation_payload(&data, MutationRoot::IssueUpdate)?
1955 .get("issue")
1956 .filter(|issue| !issue.is_null())
1957 .ok_or_else(|| SourceError::Malformed {
1958 message: "missing issueUpdate.issue".into(),
1959 })?;
1960 written_is(written, &issue)?;
1961 Ok(Some(()))
1962 }
1963 async fn set_delivered_by(
1964 &self,
1965 id: &NativeId,
1966 delivered_by: &[TaskRef],
1967 ) -> Result<Option<()>, SourceError> {
1968 let _ = (id, delivered_by);
1969 Err(self.undeliverable("delivered_by", "task"))
1970 }
1971}
1972
1973/// Refuse a narrow write's payload naming an issue other than the one it was sent for.
1974///
1975/// An `issueUpdate` answering with another issue is not this write landing, so it is reported
1976/// as the malformed answer it is rather than as the task having been written.
1977fn written_is(issue: &Value, asked: &NativeId) -> Result<(), SourceError> {
1978 let written = backend_id(issue, "id")?;
1979 if written == asked.0 {
1980 return Ok(());
1981 }
1982 Err(SourceError::Malformed {
1983 message: format!("issueUpdate for {asked} answered with the issue {written}"),
1984 })
1985}
1986
1987/// Why this source carries neither [`Task::delivers`] nor [`Task::delivered_by`].
1988///
1989/// Linear has no field for either, and standing one up in the description's metadata slot is
1990/// what this source does only for the keys whose owner is the item itself. `delivered_by` is
1991/// the store's to keep in step across every source, and a slot in somebody's issue
1992/// description is not a store that step can be kept in — so both are refused by name rather
1993/// than written, and read only when something else put them there.
1994const NO_DELIVERY: &str = "Linear has no field recording which tasks a task delivers or is \
1995 delivered by, and this source does not record either in its \
1996 description's metadata slot";
1997
1998/// The reserved delivery key `metadata` carries, if it carries one.
1999fn delivery_key_in(metadata: &std::collections::BTreeMap<String, Value>) -> Option<&'static str> {
2000 [TaskRef::DELIVERS_KEY, TaskRef::DELIVERED_BY_KEY]
2001 .into_iter()
2002 .find(|key| metadata.contains_key(*key))
2003}
2004
2005/// A category as the wire spells it — `in-progress`, `queued` — for a message.
2006fn category_word(category: StatusCategory) -> String {
2007 serde_json::to_value(category)
2008 .ok()
2009 .and_then(|value| value.as_str().map(str::to_owned))
2010 .unwrap_or_else(|| format!("{category:?}"))
2011}
2012
2013impl LinearSource {
2014 /// The refusal a write naming `named` — a field or a reserved key — on a `what` gets.
2015 fn undeliverable(&self, named: &str, what: &str) -> SourceError {
2016 SourceError::Refused {
2017 message: format!(
2018 "source {} cannot carry {named} on a {what}: {NO_DELIVERY}; write the {what} \
2019 without it",
2020 self.name
2021 ),
2022 }
2023 }
2024
2025 /// The backend id of the issue `task` names, or `None` when this source holds no such
2026 /// task — resolved by `get_task` itself, so a comment call and a task read cannot
2027 /// disagree about whether a task is there.
2028 ///
2029 /// The id Linear answers with rather than the one asked for, because `issue(id:)` also
2030 /// takes an identifier such as `ENG-1`, and the comment's own `issue{id}` is compared
2031 /// against — and a comment is created on — the backend id.
2032 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
2033 Ok(self.get_task(task).await?.map(|task| task.id))
2034 }
2035
2036 /// Whether `comment` is a comment on the issue `task` names.
2037 ///
2038 /// Asked before any edit or removal, so an id belonging to another issue — or to no
2039 /// issue, or to nothing — is answered as no such comment without a mutation reaching
2040 /// Linear. `commentUpdate` and `commentDelete` address a comment by its id alone, so
2041 /// without this a task named in error would edit or remove somebody else's comment.
2042 async fn comment_is_on(
2043 &self,
2044 task: &NativeId,
2045 comment: &NativeId,
2046 ) -> Result<bool, SourceError> {
2047 let Some(issue) = self.commented_issue(task).await? else {
2048 return Ok(false);
2049 };
2050 let data = self.send(graphql::COMMENT, json!({"id":comment.0})).await?;
2051 Ok(optional(&data, "comment", comment_issue)?.flatten() == Some(issue))
2052 }
2053}
2054
2055/// One page of an issue's comments, oldest first.
2056///
2057/// Linear answered newest first, walking backwards from `before`, so the page is reversed
2058/// and the next cursor is the one *behind* it; see the ruling on comments in this crate's
2059/// module documentation for why the walk runs that way.
2060fn comment_page(v: &Value) -> Result<Page<Comment>, SourceError> {
2061 let c = v.get("comments").ok_or_else(|| SourceError::Malformed {
2062 message: "missing comments connection".into(),
2063 })?;
2064 let mut items = c
2065 .get("nodes")
2066 .and_then(Value::as_array)
2067 .ok_or_else(|| SourceError::Malformed {
2068 message: "missing comment nodes".into(),
2069 })?
2070 .iter()
2071 .map(map_comment)
2072 .collect::<Result<Vec<_>, _>>()?;
2073 items.reverse();
2074 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
2075 message: "missing pageInfo".into(),
2076 })?;
2077 let older = info
2078 .get("hasPreviousPage")
2079 .and_then(Value::as_bool)
2080 .ok_or_else(|| SourceError::Malformed {
2081 message: "missing boolean pageInfo.hasPreviousPage".into(),
2082 })?;
2083 let next = if older {
2084 Some(Cursor(str_at(info, "startCursor")?.into()))
2085 } else {
2086 None
2087 };
2088 Ok(Page { items, next })
2089}
2090
2091fn map_comment(v: &Value) -> Result<Comment, SourceError> {
2092 let author = match v.get("user") {
2093 None => {
2094 return Err(SourceError::Malformed {
2095 message: "missing comment user field".into(),
2096 });
2097 }
2098 // An integration or a bot: Linear names no user, and this source invents none.
2099 Some(Value::Null) => None,
2100 Some(user) => Some(str_at(user, "displayName")?.to_owned()),
2101 };
2102 Ok(Comment {
2103 id: NativeId(backend_id(v, "id")?.into()),
2104 author,
2105 created_at: time(v, "createdAt")?,
2106 updated_at: time(v, "updatedAt")?,
2107 body: str_at(v, "body")?.into(),
2108 url: optional_string(v, "url")?,
2109 })
2110}
2111
2112/// The comment a `commentCreate` or `commentUpdate` answered with, as Linear now holds it.
2113fn written_comment(data: &Value, root: MutationRoot) -> Result<Comment, SourceError> {
2114 let comment = mutation_payload(data, root)?
2115 .get("comment")
2116 .ok_or_else(|| SourceError::Malformed {
2117 message: format!("missing {}.comment", root.as_str()),
2118 })?;
2119 map_comment(comment)
2120}
2121
2122/// The issue a comment is on, or `None` for a comment on something else — a project, a
2123/// document, an update — which is a comment no task of this source has.
2124fn comment_issue(v: &Value) -> Result<Option<NativeId>, SourceError> {
2125 match v.get("issue") {
2126 None => Err(SourceError::Malformed {
2127 message: "missing comment issue field".into(),
2128 }),
2129 Some(Value::Null) => Ok(None),
2130 Some(issue) => Ok(Some(NativeId(backend_id(issue, "id")?.into()))),
2131 }
2132}
2133
2134/// Linear relates one Linear item to another and nothing else, so an edge whose far end
2135/// is in a different source is the one edge no `relations` entry can hold. Those edges
2136/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
2137/// are served *after* the native relations are spent: a page under this cursor is the
2138/// recorded tail of the same walk, which keeps the native pages exactly what they were.
2139const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
2140
2141impl LinearSource {
2142 async fn dependencies(
2143 &self,
2144 query: &str,
2145 root: DependencyRoot,
2146 id: &NativeId,
2147 direction: Direction,
2148 page: &PageRequest,
2149 ) -> Result<Page<DependencyEdge>, SourceError> {
2150 let limit = page.limit.min(MAX_PAGE_SIZE);
2151 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
2152 if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
2153 // This cursor resumes the *forward* tail and only a forward walk ever issues
2154 // one, so a reverse read carrying it is resuming a walk it did not come from.
2155 // Serving it would answer a reverse read with forward edges, which is the one
2156 // thing a recorded edge must never do — its reverse is derived from the far
2157 // end and is never written down here.
2158 if direction != Direction::DependsOn {
2159 return Err(SourceError::Malformed {
2160 message: format!(
2161 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a reverse dependency read never issues; resume it in the direction that reported it"
2162 ),
2163 });
2164 }
2165 let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
2166 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
2167 })?;
2168 let d = self
2169 .send(query, json!({"id":id.0,"first":1,"after":null}))
2170 .await?;
2171 return Ok(recorded_page(
2172 recorded(&d, root, id, &self.name)?,
2173 offset,
2174 limit as usize,
2175 ));
2176 }
2177 let d = self
2178 .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
2179 .await?;
2180 let mut answered = relation_page(&d, root, id, direction)?;
2181 // Only forwards: the reverse of a recorded edge is derived from the far end, never
2182 // written down on the near item.
2183 if answered.next.is_none()
2184 && direction == Direction::DependsOn
2185 && !recorded(&d, root, id, &self.name)?.is_empty()
2186 {
2187 answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
2188 }
2189 Ok(answered)
2190 }
2191}
2192
2193fn recorded(
2194 d: &Value,
2195 root: DependencyRoot,
2196 id: &NativeId,
2197 name: &SourceName,
2198) -> Result<Vec<DependencyEdge>, SourceError> {
2199 let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
2200 message: format!("missing {}", root.as_str()),
2201 })?;
2202 let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
2203 // `relations` on an issue holds issues and on a project holds projects, both of this
2204 // workspace — so a same-kind far end in this same source is one Linear itself was
2205 // supposed to hold, and the key is refused rather than quietly read, whether the entry
2206 // left the source out or spelled this one.
2207 DependencyEdge::recorded(
2208 &metadata,
2209 id,
2210 root.item_kind(),
2211 name,
2212 Some(root.item_kind()),
2213 )
2214 .map_err(|message| SourceError::Malformed { message })
2215}
2216
2217fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
2218 let total = edges.len();
2219 let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
2220 let end = offset.saturating_add(items.len());
2221 Page {
2222 items,
2223 next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
2224 }
2225}
2226
2227// 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.
2228/// A category as `WorkflowState.type` spells it — the vocabulary an **issue**'s state has.
2229///
2230/// Linear's workflow states are triage, backlog, unstarted, started, completed and
2231/// canceled. None of them is a draft, so `Draft` narrows to nothing exactly as `Unknown`
2232/// does rather than filtering on a state Linear does not have.
2233fn workflow_state_types(s: &StatusCategory) -> Vec<&'static str> {
2234 match s {
2235 StatusCategory::Draft => vec![],
2236 StatusCategory::Backlog => vec!["backlog"],
2237 StatusCategory::Todo => vec!["unstarted"],
2238 // Linear has no state for work that is claimed and not yet started: `unstarted` is
2239 // `todo` and `started` is `in-progress`, and a Linear issue reads back as one of
2240 // those. So `queued` narrows to nothing, exactly as `draft` does — mapping it onto
2241 // either neighbour would have a `queued` filter return an item that reads back as
2242 // `todo` or `in-progress`, which is capability rule 1 broken.
2243 StatusCategory::Queued => vec![],
2244 StatusCategory::InProgress => vec!["started"],
2245 StatusCategory::Done => vec!["completed"],
2246 StatusCategory::Cancelled => vec!["canceled"],
2247 StatusCategory::Unknown => vec![],
2248 }
2249}
2250/// A category as `ProjectStatus.type` spells it — a **different** vocabulary, and a
2251/// different enum: Linear declares that field `ProjectStatusType!`, whose members are
2252/// backlog, planned, started, paused, completed and canceled.
2253///
2254/// Two of them have no issue counterpart and are why this cannot be the function above.
2255/// `planned` is where `unstarted` would be, so it is what `Todo` narrows to; a project
2256/// filtered with `unstarted` matches nothing and is refused by nothing, which is how this
2257/// went unnoticed. And `paused` is a project that has started and is neither finished nor
2258/// cancelled, so it reads as in progress — the same reading [`status`] gives it, which is
2259/// what keeps this narrowing and that mapping the same claim rather than two.
2260fn project_status_types(s: &StatusCategory) -> Vec<&'static str> {
2261 match s {
2262 StatusCategory::Draft => vec![],
2263 StatusCategory::Backlog => vec!["backlog"],
2264 StatusCategory::Todo => vec!["planned"],
2265 // No `ProjectStatusType` is claimed-and-not-started either, so `queued` narrows to
2266 // nothing here for the reason it does for an issue above.
2267 StatusCategory::Queued => vec![],
2268 StatusCategory::InProgress => vec!["started", "paused"],
2269 StatusCategory::Done => vec!["completed"],
2270 StatusCategory::Cancelled => vec!["canceled"],
2271 StatusCategory::Unknown => vec![],
2272 }
2273}
2274/// The category a Linear status name and type normalise to, at either level.
2275///
2276/// One mapper for both vocabularies, because the two are disjoint where they differ: no
2277/// issue is ever `planned` or `paused`, and no project is ever `unstarted` or `triage`. It
2278/// is the inverse of [`workflow_state_types`] and [`project_status_types`] together, and
2279/// has to stay so: a category this reports and that filter cannot ask for is capability
2280/// rule 1 broken, and the row would go missing rather than be refused.
2281///
2282/// **It never answers `Queued` or `Draft`**, and that is the other half of the same claim:
2283/// both filters narrow those two to nothing, because no Linear state or project status means
2284/// either, so a row this reported as one would be a row no filter for it could return. A type
2285/// Linear does not document — even one spelled `queued` — is `Unknown`, never a guess.
2286fn status(v: &Value) -> Result<Status, SourceError> {
2287 let name = str_at(v, "name")?.into();
2288 let category = match str_at(v, "type")? {
2289 "backlog" => StatusCategory::Backlog,
2290 "unstarted" | "planned" => StatusCategory::Todo,
2291 "started" | "paused" => StatusCategory::InProgress,
2292 "completed" => StatusCategory::Done,
2293 "canceled" => StatusCategory::Cancelled,
2294 _ => StatusCategory::Unknown,
2295 };
2296 Ok(Status { category, name })
2297}
2298// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2299fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
2300 v.get(k)
2301 .and_then(Value::as_str)
2302 .ok_or_else(|| SourceError::Malformed {
2303 message: format!("missing string field {k}"),
2304 })
2305}
2306fn map_label(v: &Value) -> Result<Label, SourceError> {
2307 Ok(Label {
2308 id: NativeId(str_at(v, "id")?.into()),
2309 name: str_at(v, "name")?.into(),
2310 color: optional_string(v, "color")?,
2311 })
2312}
2313fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
2314 v.get("nodes")
2315 .and_then(Value::as_array)
2316 .ok_or_else(|| SourceError::Malformed {
2317 message: "missing label nodes".into(),
2318 })?
2319 .iter()
2320 .map(map_label)
2321 .collect()
2322}
2323fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2324 optional_str(v, k)?
2325 .map(|s| {
2326 s.parse().map_err(|e| SourceError::Malformed {
2327 message: format!("invalid {k}: {e}"),
2328 })
2329 })
2330 .transpose()
2331}
2332/// One issue as a task, `source` being this source's configured name.
2333///
2334/// The name is what lets [`TaskRef::listed`] tell `work:I-1` on the issue `I-1` of the
2335/// source `work` apart as that issue itself, rather than recognising only the bare spelling.
2336fn map_task(v: &Value, source: &SourceName) -> Result<Task, SourceError> {
2337 let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
2338 let repositories = Repository::from_metadata(&metadata)
2339 .map_err(|message| SourceError::Malformed { message })?;
2340 let url = optional_string(v, "url")?;
2341 let id = NativeId(str_at(v, "id")?.into());
2342 // Taken out of the caller's metadata as they are read: a reserved key is this product's,
2343 // and reporting it there as well would hand a consumer two spellings of one list.
2344 let delivers = delivery_list(&mut metadata, TaskRef::DELIVERS_KEY, &id, source)?;
2345 let delivered_by = delivery_list(&mut metadata, TaskRef::DELIVERED_BY_KEY, &id, source)?;
2346 Ok(Task {
2347 id,
2348 // `Issue.identifier` is `String!` and every read of an issue selects it, so a
2349 // response without one is a response this source cannot read rather than an issue
2350 // with no handle — Linear gives every issue one.
2351 key: Some(str_at(v, "identifier")?.into()),
2352 title: str_at(v, "title")?.into(),
2353 content,
2354 status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
2355 message: "missing state".into(),
2356 })?)?,
2357 priority: issue_priority(v)?,
2358 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
2359 message: "missing labels".into(),
2360 })?)?,
2361 project: filed_under(v)?,
2362 location: web_address(url.as_deref()),
2363 url,
2364 created_at: time(v, "createdAt")?,
2365 updated_at: time(v, "updatedAt")?,
2366 metadata,
2367 repositories,
2368 delivers,
2369 delivered_by,
2370 })
2371}
2372/// A priority as Linear's `Issue.priority` and its two input members spell it.
2373///
2374/// Linear's own scale, as its published schema describes the field: `0` is no priority,
2375/// `1` urgent, `2` high, `3` normal and `4` low. Normal is this contract's `medium`.
2376const fn linear_priority(priority: Priority) -> u8 {
2377 match priority {
2378 Priority::None => 0,
2379 Priority::Urgent => 1,
2380 Priority::High => 2,
2381 Priority::Medium => 3,
2382 Priority::Low => 4,
2383 }
2384}
2385
2386/// The priority an issue carries, read from `Issue.priority`.
2387///
2388/// Linear declares that field `Float!` while its inputs take an `Int`, so `2` and `2.0` are
2389/// the same answer. Anything else — absent, null, fractional, or outside `0` to `4` — is a
2390/// response this source cannot read, never a guess at the nearest level: a priority reported
2391/// that a filter for it could not find is capability rule 1 broken.
2392fn issue_priority(v: &Value) -> Result<Priority, SourceError> {
2393 let raw = v.get("priority").ok_or_else(|| SourceError::Malformed {
2394 message: "missing number field priority".into(),
2395 })?;
2396 let level = raw.as_f64().filter(|level| level.fract() == 0.0);
2397 Priority::ALL
2398 .into_iter()
2399 .find(|priority| level == Some(f64::from(linear_priority(*priority))))
2400 .ok_or_else(|| SourceError::Malformed {
2401 message: format!(
2402 "field priority is {raw}, which is none of Linear's priorities 0 (none), \
2403 1 (urgent), 2 (high), 3 (normal) and 4 (low)"
2404 ),
2405 })
2406}
2407
2408/// One delivery list read out of an issue's metadata slot, and removed from it.
2409///
2410/// An entry that is not a task id, that names the issue itself, or that repeats is a
2411/// malformed response naming the task and the entry, never a list quietly shortened.
2412fn delivery_list(
2413 metadata: &mut std::collections::BTreeMap<String, Value>,
2414 key: &str,
2415 task: &NativeId,
2416 source: &SourceName,
2417) -> Result<Vec<TaskRef>, SourceError> {
2418 let held = metadata.remove(key);
2419 TaskRef::from_value(key, task, Some(source), held.as_ref())
2420 .map_err(|message| SourceError::Malformed { message })
2421}
2422/// Remove the two delivery keys from a project's or a document's metadata.
2423///
2424/// Neither is work that delivers anything, so a key there names nothing this contract has,
2425/// and it is not the caller's free metadata either: it is this product's reserved spelling.
2426fn strip_delivery_keys(metadata: &mut std::collections::BTreeMap<String, Value>) {
2427 metadata.remove(TaskRef::DELIVERS_KEY);
2428 metadata.remove(TaskRef::DELIVERED_BY_KEY);
2429}
2430fn map_project(v: &Value) -> Result<Project, SourceError> {
2431 let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
2432 strip_delivery_keys(&mut metadata);
2433 let repositories = Repository::from_metadata(&metadata)
2434 .map_err(|message| SourceError::Malformed { message })?;
2435 let url = optional_string(v, "url")?;
2436 Ok(Project {
2437 id: NativeId(str_at(v, "id")?.into()),
2438 title: str_at(v, "name")?.into(),
2439 content,
2440 status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
2441 message: "missing status".into(),
2442 })?)?,
2443 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
2444 message: "missing project labels".into(),
2445 })?)?,
2446 location: web_address(url.as_deref()),
2447 url,
2448 created_at: time(v, "createdAt")?,
2449 updated_at: time(v, "updatedAt")?,
2450 metadata,
2451 repositories,
2452 })
2453}
2454
2455/// Where a Linear entity is: the web address Linear itself reports for it, as a link.
2456///
2457/// Every issue, project and document of a Linear workspace has a page a person can open,
2458/// so this source says so for all three — the counterpart of a folder of Markdown
2459/// reporting the path of the file behind an item. A source that reported nothing here is
2460/// what leaves a reader holding an opaque id, and `None` is reserved for the case Linear
2461/// really did not say, which is not the same as saying the entity is nowhere.
2462fn web_address(url: Option<&str>) -> Option<Location> {
2463 url.map(|url| Location::Url(url.to_owned()))
2464}
2465
2466/// The project a Linear item is filed under, or `None` for one filed under nothing.
2467///
2468/// One reader for issues and documents alike, because the field is the same field: an
2469/// absent `project` key is a malformed response, a null one is an orphan.
2470fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
2471 match v.get("project") {
2472 None => Err(SourceError::Malformed {
2473 message: "missing project field".into(),
2474 }),
2475 Some(Value::Null) => Ok(None),
2476 Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
2477 }
2478}
2479
2480fn map_document(v: &Value) -> Result<Document, SourceError> {
2481 let (content, mut metadata) = metadata_description(optional_string(v, "content")?)?;
2482 strip_delivery_keys(&mut metadata);
2483 let repositories = Repository::from_metadata(&metadata)
2484 .map_err(|message| SourceError::Malformed { message })?;
2485 let url = optional_string(v, "url")?;
2486 Ok(Document {
2487 id: NativeId(str_at(v, "id")?.into()),
2488 title: str_at(v, "title")?.into(),
2489 content,
2490 project: filed_under(v)?,
2491 // Linear's `Document` carries no labels, and that is the published schema rather
2492 // than a gap here: the types of it that carry `labels` are `Issue`, `Project`,
2493 // `Team`, `Initiative` and `Organization`. Reporting none is what a source with no
2494 // native slot owes; standing one up beside a first-class type is what this source
2495 // exists not to do, and `write_document` refuses a label by name for the same
2496 // reason rather than dropping it.
2497 labels: Vec::new(),
2498 location: web_address(url.as_deref()),
2499 url,
2500 created_at: time(v, "createdAt")?,
2501 updated_at: time(v, "updatedAt")?,
2502 metadata,
2503 repositories,
2504 })
2505}
2506
2507/// Whether this document satisfies the predicates this source applies to a fetched page.
2508///
2509/// Two of them reach a page rather than the `documents(filter:)` variables, and each for a
2510/// reason of Linear's own. `DocumentFilter.project` is a `ProjectFilter` where
2511/// `IssueFilter.project` is a `NullableProjectFilter`, so only the issue side can be asked
2512/// for the items belonging to no project. And a Linear document carries no label at all,
2513/// so a query demanding one keeps nothing and a query excluding one keeps everything —
2514/// which is this source *applying* the predicate it declares native, over the labels the
2515/// document really has, rather than ignoring it.
2516fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
2517 let carries = |name: &String| {
2518 document
2519 .labels
2520 .iter()
2521 .any(|label| label.name.eq_ignore_ascii_case(name))
2522 };
2523 let filed = match project {
2524 ProjectFilter::Any => true,
2525 ProjectFilter::Orphans => document.project.is_none(),
2526 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
2527 };
2528 filed
2529 && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
2530 && labels.all_of.iter().all(&carries)
2531 && !labels.none_of.iter().any(&carries)
2532}
2533
2534fn optional<T>(
2535 d: &Value,
2536 k: &str,
2537 f: impl Fn(&Value) -> Result<T, SourceError>,
2538) -> Result<Option<T>, SourceError> {
2539 match d.get(k) {
2540 None => Err(SourceError::Malformed {
2541 message: format!("missing {k}"),
2542 }),
2543 Some(Value::Null) => Ok(None),
2544 // An item Linear no longer shows is not an item this source holds, and Linear says
2545 // so with `archivedAt` rather than by answering null.
2546 //
2547 // **None of Linear's three `delete` verbs removes anything.** `issueDelete`,
2548 // `projectDelete` and `documentDelete` move the item to the trash: observed on
2549 // 2026-09-04, each answered `success: true` and the item still read back by id,
2550 // carrying `archivedAt` and `trashed: true`. Its separate *archive* verb is a third
2551 // state — `archivedAt` set, `trashed` null — and Linear excludes both from every
2552 // connection, so `issues`, `projects` and `documents` had already stopped returning
2553 // them while a read by id still did.
2554 //
2555 // `archivedAt` rather than `trashed` for exactly that reason: it is the marker both
2556 // states share, so a read by id answers what a listing answers, and a delete means
2557 // what a copy's undo needs it to mean — the item this run created is gone.
2558 Some(value) if !matches!(value.get("archivedAt"), None | Some(Value::Null)) => Ok(None),
2559 Some(value) => f(value).map(Some),
2560 }
2561}
2562fn connection<T>(
2563 d: &Value,
2564 k: &str,
2565 f: impl Fn(&Value) -> Result<T, SourceError>,
2566) -> Result<Page<T>, SourceError> {
2567 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
2568 message: format!("missing {k} connection"),
2569 })?;
2570 let items = c
2571 .get("nodes")
2572 .and_then(Value::as_array)
2573 .ok_or_else(|| SourceError::Malformed {
2574 message: "missing nodes".into(),
2575 })?
2576 .iter()
2577 .map(f)
2578 .collect::<Result<_, _>>()?;
2579 let next = page_next(c)?;
2580 Ok(Page { items, next })
2581}
2582#[derive(Clone, Copy)]
2583enum DependencyRoot {
2584 Issue,
2585 Project,
2586}
2587impl DependencyRoot {
2588 const fn item_kind(self) -> ItemKind {
2589 match self {
2590 Self::Issue => ItemKind::Task,
2591 Self::Project => ItemKind::Project,
2592 }
2593 }
2594 const fn as_str(self) -> &'static str {
2595 match self {
2596 Self::Issue => "issue",
2597 Self::Project => "project",
2598 }
2599 }
2600}
2601fn relation_page(
2602 d: &Value,
2603 root: DependencyRoot,
2604 id: &NativeId,
2605 direction: Direction,
2606) -> Result<Page<DependencyEdge>, SourceError> {
2607 let key = if direction == Direction::DependsOn {
2608 "relations"
2609 } else {
2610 "inverseRelations"
2611 };
2612 let c = d
2613 .get(root.as_str())
2614 .and_then(|v| v.get(key))
2615 .ok_or_else(|| SourceError::Malformed {
2616 message: format!("missing {key}"),
2617 })?;
2618 let nodes = c
2619 .get("nodes")
2620 .and_then(Value::as_array)
2621 .ok_or_else(|| SourceError::Malformed {
2622 message: "missing relation nodes".into(),
2623 })?;
2624 let mut items = Vec::new();
2625 for n in nodes {
2626 let other = n
2627 .get(if direction == Direction::DependsOn {
2628 "relatedIssue"
2629 } else {
2630 "issue"
2631 })
2632 .or_else(|| {
2633 n.get(if direction == Direction::DependsOn {
2634 "relatedProject"
2635 } else {
2636 "project"
2637 })
2638 })
2639 .and_then(|v| v.get("id"))
2640 .and_then(Value::as_str)
2641 .ok_or_else(|| SourceError::Malformed {
2642 message: "missing related id".into(),
2643 })?;
2644 let (from, to) = if direction == Direction::DependsOn {
2645 (id.clone(), NativeId(other.into()))
2646 } else {
2647 (NativeId(other.into()), id.clone())
2648 };
2649 // 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.
2650 let relation_type =
2651 n.get("type")
2652 .and_then(Value::as_str)
2653 .ok_or_else(|| SourceError::Malformed {
2654 message: "missing relation type".into(),
2655 })?;
2656 // An issue relation and a project relation do not share a vocabulary. Linear
2657 // spells a project dependency `dependency`, where an issue's is `blocks`; the
2658 // write side sends exactly that pair and says why. So each root reads only its
2659 // own, and a value the other root would have accepted is refused here rather than
2660 // read as an edge this source could not have written.
2661 //
2662 // `related` is one of those values, and only an issue relation has it. Linear's
2663 // validator enumerates a project relation's `type` as `dependency` alone — see
2664 // the write side, which had `related` refused by the real API on 2026-09-04 — so
2665 // a project relation typed `related` is not a relation this workspace can hold.
2666 let kind = match (root, relation_type) {
2667 (DependencyRoot::Issue, "blocks") | (DependencyRoot::Project, "dependency") => {
2668 DependencyKind::Blocks
2669 }
2670 (DependencyRoot::Issue, "related") => DependencyKind::Related,
2671 _ => {
2672 return Err(SourceError::Malformed {
2673 message: format!(
2674 "invalid relation type: {relation_type} on a {} relation",
2675 root.as_str()
2676 ),
2677 });
2678 }
2679 };
2680 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2681 let item_kind = root.item_kind();
2682 items.push(DependencyEdge {
2683 from: DependencyEndpoint::from_native(from, item_kind),
2684 to: DependencyEndpoint::from_native(to, item_kind),
2685 kind,
2686 });
2687 }
2688 let next = page_next(c)?;
2689 Ok(Page { items, next })
2690}
2691
2692fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
2693 match v.get(k) {
2694 None => Err(SourceError::Malformed {
2695 message: format!("missing field {k}"),
2696 }),
2697 Some(Value::Null) => Ok(None),
2698 Some(value) => value
2699 .as_str()
2700 .map(Some)
2701 .ok_or_else(|| SourceError::Malformed {
2702 message: format!("field {k} is not a string"),
2703 }),
2704 }
2705}
2706
2707/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment
2708/// at the end of `description`; its later write side must use this exact encoding.
2709const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2710const METADATA_CLOSE: &str = "\n-->";
2711/// The same close as Linear hands a document's `content` back: it stores a document as
2712/// Markdown and escapes a line opening `-->`, so the slot this source wrote reads back with
2713/// a backslash before its close (observed from the real API on 2026-09-14). An issue's or a
2714/// project's `description` comes back as written. The write side keeps the one encoding.
2715const METADATA_CLOSE_ESCAPED: &str = "\n\\-->";
2716
2717/// Where the trailing metadata slot of `description` is: the byte its opening marker starts
2718/// at, and the span of the encoded JSON inside it — or `None` when it ends in no slot.
2719///
2720/// The one place the slot is recognised, so what [`metadata_description`] reads out and
2721/// what [`metadata_slot`] keeps for a content write are the same bytes.
2722fn slot_bounds(description: &str) -> Result<Option<(usize, usize, usize)>, SourceError> {
2723 let Some(start) = description.rfind(METADATA_OPEN) else {
2724 return Ok(None);
2725 };
2726 let encoded_start = start + METADATA_OPEN.len();
2727 let close = [METADATA_CLOSE, METADATA_CLOSE_ESCAPED]
2728 .into_iter()
2729 .filter_map(|close| {
2730 description[encoded_start..]
2731 .find(close)
2732 .map(|at| (at, close.len()))
2733 })
2734 .min();
2735 let Some((relative_end, close_len)) = close else {
2736 return Err(SourceError::Malformed {
2737 message: "unterminated onetaskgraph metadata slot in Linear description".into(),
2738 });
2739 };
2740 let encoded_end = encoded_start + relative_end;
2741 if !description[encoded_end + close_len..].trim().is_empty() {
2742 return Ok(None);
2743 }
2744 Ok(Some((start, encoded_start, encoded_end)))
2745}
2746
2747/// The metadata slot `description` ends in, exactly as it is stored, or `None`.
2748fn metadata_slot(description: &str) -> Result<Option<&str>, SourceError> {
2749 Ok(slot_bounds(description)?.map(|(start, _, _)| &description[start..]))
2750}
2751
2752fn metadata_description(
2753 description: Option<String>,
2754) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
2755 let Some(description) = description else {
2756 return Ok((None, Default::default()));
2757 };
2758 let Some((start, encoded_start, encoded_end)) = slot_bounds(&description)? else {
2759 return Ok((Some(description), Default::default()));
2760 };
2761 let metadata =
2762 serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
2763 SourceError::Malformed {
2764 message: format!(
2765 "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
2766 ),
2767 }
2768 })?;
2769 // Exactly the text above the slot less the one blank line `long_form` sets it off by, so
2770 // content whose own end is whitespace reads back as itself. A description edited in Linear
2771 // down to a single line break before the slot loses just that one.
2772 let above = &description[..start];
2773 let visible = above
2774 .strip_suffix("\n\n")
2775 .or_else(|| above.strip_suffix('\n'))
2776 .unwrap_or(above);
2777 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2778}
2779
2780fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
2781 Ok(optional_str(v, k)?.map(Into::into))
2782}
2783fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2784 let id = str_at(value, field)?;
2785 (!id.is_empty())
2786 .then_some(id)
2787 .ok_or_else(|| SourceError::Malformed {
2788 message: format!("field {field} is an empty backend id"),
2789 })
2790}
2791fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
2792 let root = root.as_str();
2793 let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
2794 message: format!("missing {root}"),
2795 })?;
2796 match payload.get("success").and_then(Value::as_bool) {
2797 Some(true) => Ok(payload),
2798 Some(false) => Err(SourceError::Refused {
2799 message: format!("Linear reported {root} was unsuccessful"),
2800 }),
2801 None => Err(SourceError::Malformed {
2802 message: format!("missing boolean {root}.success"),
2803 }),
2804 }
2805}
2806fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
2807 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
2808 message: "missing pageInfo".into(),
2809 })?;
2810 let more = info
2811 .get("hasNextPage")
2812 .and_then(Value::as_bool)
2813 .ok_or_else(|| SourceError::Malformed {
2814 message: "missing boolean pageInfo.hasNextPage".into(),
2815 })?;
2816 if !more {
2817 return Ok(None);
2818 }
2819 let cursor = str_at(info, "endCursor")?;
2820 Ok(Some(Cursor(cursor.into())))
2821}