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