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