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 match matched.as_slice() {
914 [] => Err(SourceError::Refused {
915 message: format!(
916 "source {} cannot resolve {}: found 0 matches",
917 self.name,
918 lookup.diagnostic()
919 ),
920 }),
921 [node] => Ok(NativeId(backend_id(node, "id")?.to_owned())),
922 nodes => {
923 let ids = nodes
924 .iter()
925 .map(|node| backend_id(node, "id"))
926 .collect::<Result<Vec<_>, _>>()?;
927 Err(SourceError::Refused {
928 message: format!(
929 "source {} cannot resolve {}: found {} matches with ids {ids:?}",
930 self.name,
931 lookup.diagnostic(),
932 nodes.len()
933 ),
934 })
935 }
936 }
937 }
938 async fn team_id(&self) -> Result<NativeId, SourceError> {
939 let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
940 message: format!(
941 "source {} needs config.team before it can create Linear items",
942 self.name
943 ),
944 })?;
945 self.one_id(Lookup::Team(&team.0)).await
946 }
947 async fn label_ids(
948 &self,
949 labels: &[Label],
950 kind: WriteKind,
951 ) -> Result<Vec<NativeId>, SourceError> {
952 let mut ids = Vec::with_capacity(labels.len());
953 for label in labels {
954 ids.push(
955 self.one_id(if matches!(kind, WriteKind::Project) {
956 Lookup::ProjectLabel(&label.name)
957 } else {
958 Lookup::IssueLabel(&label.name)
959 })
960 .await?,
961 );
962 }
963 Ok(ids)
964 }
965 fn write_description(
966 &self,
967 content: Option<&str>,
968 metadata: &std::collections::BTreeMap<String, Value>,
969 repositories: &[Repository],
970 edges: &[DependencyEdge],
971 kind: WriteKind,
972 ) -> Result<Option<String>, SourceError> {
973 let recorded = edges
974 .iter()
975 .filter(|edge| {
976 edge.to.kind
977 != match kind {
978 WriteKind::Task => ItemKind::Task,
979 WriteKind::Project => ItemKind::Project,
980 }
981 || edge
982 .to
983 .id()
984 .split_once(':')
985 .is_some_and(|(source, _)| source != self.name.as_str())
986 })
987 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
988 .collect::<Vec<_>>();
989 Self::long_form(content, metadata, repositories, recorded)
990 }
991
992 /// The one long-form field a Linear item has, with this source's own slot at the end.
993 ///
994 /// Shared by every kind this source writes rather than reimplemented per kind: a
995 /// document keeps caller metadata in exactly the slot an issue and a project do, which
996 /// is what lets the same read side take it back out.
997 fn long_form(
998 content: Option<&str>,
999 metadata: &std::collections::BTreeMap<String, Value>,
1000 repositories: &[Repository],
1001 recorded: Vec<Value>,
1002 ) -> Result<Option<String>, SourceError> {
1003 let mut metadata = metadata.clone();
1004 if repositories.is_empty() {
1005 metadata.remove(Repository::METADATA_KEY);
1006 } else {
1007 metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
1008 }
1009 if recorded.is_empty() {
1010 metadata.remove(DependencyEdge::RECORDED_KEY);
1011 } else {
1012 metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
1013 }
1014 let visible = content.unwrap_or_default();
1015 if metadata.is_empty() {
1016 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
1017 }
1018 let encoded = serde_json::to_string(&metadata).map_err(|error| SourceError::Malformed {
1019 message: error.to_string(),
1020 })?;
1021 Ok(Some(if visible.is_empty() {
1022 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
1023 } else {
1024 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
1025 }))
1026 }
1027 /// What this source says when asked for a project edge carrying no ordering.
1028 ///
1029 /// Linear's project relations have exactly one type and it is an ordering. Asked on
1030 /// 2026-09-04 to create one typed `related` — and separately `blocks` and `dependsOn`
1031 /// — the real API refused each with `Argument Validation Error` and
1032 /// `constraints: {"isEnum": "type must be one of the following values: dependency"}`.
1033 /// That is Linear's own enumeration of the field, from the validator behind GraphQL
1034 /// where introspection cannot reach it, and it has one member. An issue relation is a
1035 /// different relation with a different set, which does include `related`, so this
1036 /// reaches projects alone.
1037 fn unordered_project_relation(&self, near: &NativeId, far: &str) -> SourceError {
1038 SourceError::Refused {
1039 message: format!(
1040 "source {} cannot carry an unordered dependency between projects, because \
1041 Linear types every project relation `dependency` and that is an ordering; \
1042 record {near} to {far} as a dependency, or between tasks",
1043 self.name,
1044 near = near.0,
1045 ),
1046 }
1047 }
1048 /// The one edge [`Self::unordered_project_relation`] refuses, if there is one here.
1049 fn unordered_project_edge(edges: &[DependencyEdge]) -> Option<&DependencyEdge> {
1050 edges
1051 .iter()
1052 .find(|edge| edge.to.kind == ItemKind::Project && edge.kind == DependencyKind::Related)
1053 }
1054 async fn write_relations(
1055 &self,
1056 near: &NativeId,
1057 edges: &[DependencyEdge],
1058 kind: WriteKind,
1059 ) -> Result<(), SourceError> {
1060 let mut cursor: Option<Cursor> = None;
1061 loop {
1062 let data = self
1063 .send(
1064 if matches!(kind, WriteKind::Project) {
1065 PROJECT_RELATIONS
1066 } else {
1067 ISSUE_RELATIONS
1068 },
1069 json!({"id":near.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
1070 )
1071 .await?;
1072 let root = data
1073 .get(if matches!(kind, WriteKind::Project) {
1074 "project"
1075 } else {
1076 "issue"
1077 })
1078 .ok_or_else(|| SourceError::Malformed {
1079 message: "missing relation item".into(),
1080 })?;
1081 let relations = root
1082 .get("relations")
1083 .ok_or_else(|| SourceError::Malformed {
1084 message: "missing relations".into(),
1085 })?;
1086 for relation in relations
1087 .get("nodes")
1088 .and_then(Value::as_array)
1089 .ok_or_else(|| SourceError::Malformed {
1090 message: "missing relations.nodes".into(),
1091 })?
1092 {
1093 let id = backend_id(relation, "id")?;
1094 let (query, mutation) = if matches!(kind, WriteKind::Project) {
1095 (
1096 graphql::PROJECT_RELATION_DELETE,
1097 MutationRoot::ProjectRelationDelete,
1098 )
1099 } else {
1100 (
1101 graphql::ISSUE_RELATION_DELETE,
1102 MutationRoot::IssueRelationDelete,
1103 )
1104 };
1105 let deleted = self.send(query, json!({"id":id})).await?;
1106 mutation_payload(&deleted, mutation)?;
1107 }
1108 let Some(next) = page_next(relations)? else {
1109 break;
1110 };
1111 cursor = Some(next);
1112 }
1113 // Linear requires an anchor at each end of a project relation and validates both
1114 // against an enum GraphQL cannot see: `ProjectRelationCreateInput` declares them
1115 // `String!` and enumerates nothing, and the field descriptions read as a choice
1116 // between the project and a milestone, which is not what they are. Linear's own
1117 // refusal enumerates them — sent `project` in both, it answered `anchorType must
1118 // be one of the following values: start, end, milestone` — and `milestone` needs
1119 // an id this source never sends, so the two whole-project anchors are the whole of
1120 // what it can send.
1121 //
1122 // **Which of them goes where carries the direction, and the two id slots do not.**
1123 // Linear stores whatever pair it is given and reads a backwards dependency as
1124 // readily as the right one, so acceptance settles nothing; what does is Linear's
1125 // own reading of a stored relation, published as the computed `ProjectFilter`
1126 // members `hasBlockingRelations` ("projects which are blocking") and
1127 // `hasBlockedByRelations` ("projects which are blocked"). Three relations between
1128 // two scratch projects, read back through them on 2026-09-04:
1129 //
1130 // | `projectId` | `anchorType` | `relatedProjectId` | `relatedAnchorType` | blocked | blocking |
1131 // | ----------- | ------------ | ------------------ | ------------------- | ------- | -------- |
1132 // | A | `start` | B | `end` | A | B |
1133 // | A | `end` | B | `start` | B | A |
1134 // | B | `end` | A | `start` | A | B |
1135 //
1136 // Rows one and three exchange the ids and the anchors together and read alike;
1137 // rows one and two exchange only the anchors and the reading flips. So the project
1138 // anchored `start` is the one that waits, whichever slot it sits in, and row one is
1139 // what this source sends — `near`, the item that depends, in `projectId`. Linear's
1140 // own callers put the blocker there instead, so copying their `end`/`start` pair
1141 // across by position would state every dependency backwards in the workspace, and
1142 // nothing would refuse it.
1143 const NEAR_ANCHOR: &str = "start";
1144 const FAR_ANCHOR: &str = "end";
1145 for edge in edges {
1146 if edge.to.kind
1147 != match kind {
1148 WriteKind::Task => ItemKind::Task,
1149 WriteKind::Project => ItemKind::Project,
1150 }
1151 {
1152 continue;
1153 }
1154 let far = match edge.to.id().split_once(':') {
1155 Some((source, native)) if source == self.name.as_str() => native,
1156 Some(_) => continue,
1157 None => edge.to.id(),
1158 };
1159 // A project relation is not spelled the way an issue relation is, and this is
1160 // the whole of what a project's `type` may say.
1161 //
1162 // `blocks` there is what the live journey's project write was refused for
1163 // once the two anchors above stopped being missing: Linear answered HTTP 200
1164 // with `Argument Validation Error`, the message class its input validator
1165 // raises for a value outside an accepted set, having already accepted every
1166 // field of the same input by name — which is what tells that refusal apart
1167 // from the missing-field one before it, and what says the anchors were not the
1168 // cause.
1169 //
1170 // Which field, and what it takes, was measured against the real API on
1171 // 2026-09-04 rather than inferred. Each of `blocks`, `dependsOn`, `related`
1172 // and `DEPENDENCY` was refused with `property: "type"` and
1173 // `constraints: {"isEnum": "type must be one of the following values:
1174 // dependency"}`; `dependency` was accepted. That enumeration, like the
1175 // anchors' above, reaches this source through the validator's `extensions`;
1176 // see `GqlError::said`.
1177 //
1178 // A `Related` project edge is refused at the top of this function by that same
1179 // enumeration: it has one member and it is an ordering. An issue relation is a
1180 // different relation with a different set, which does include `related`.
1181 let relation_type = match (kind, edge.kind) {
1182 (WriteKind::Project, DependencyKind::Blocks) => "dependency",
1183 (WriteKind::Task, DependencyKind::Blocks) => "blocks",
1184 (WriteKind::Task, DependencyKind::Related) => "related",
1185 // Unreachable past `write_project`'s guard, and an error rather than a
1186 // skip so it stays that way: an edge dropped here would be a copy
1187 // reporting success for a dependency the destination does not hold.
1188 (WriteKind::Project, DependencyKind::Related) => {
1189 return Err(self.unordered_project_relation(near, edge.to.id()));
1190 }
1191 };
1192 let (query, input) = if matches!(kind, WriteKind::Project) {
1193 (
1194 graphql::PROJECT_RELATION_CREATE,
1195 json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type,"anchorType":NEAR_ANCHOR,"relatedAnchorType":FAR_ANCHOR}),
1196 )
1197 } else {
1198 (
1199 graphql::ISSUE_RELATION_CREATE,
1200 json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
1201 )
1202 };
1203 let data = self.send(query, json!({"input":input})).await?;
1204 let mutation = if matches!(kind, WriteKind::Project) {
1205 MutationRoot::ProjectRelationCreate
1206 } else {
1207 MutationRoot::IssueRelationCreate
1208 };
1209 let payload = mutation_payload(&data, mutation)?;
1210 let relation = payload
1211 .get(if matches!(kind, WriteKind::Project) {
1212 "projectRelation"
1213 } else {
1214 "issueRelation"
1215 })
1216 .ok_or_else(|| SourceError::Malformed {
1217 message: format!("missing {} relation", mutation.as_str()),
1218 })?;
1219 backend_id(relation, "id")?;
1220 }
1221 Ok(())
1222 }
1223
1224 async fn prepare_edges(
1225 &self,
1226 edges: &[DependencyEdge],
1227 kind: WriteKind,
1228 ) -> Result<Vec<DependencyEdge>, SourceError> {
1229 let mut prepared = Vec::with_capacity(edges.len());
1230 for edge in edges {
1231 let mut edge = edge.clone();
1232 if edge.to.kind
1233 == match kind {
1234 WriteKind::Task => ItemKind::Task,
1235 WriteKind::Project => ItemKind::Project,
1236 }
1237 && edge
1238 .to
1239 .id()
1240 .split_once(':')
1241 .is_some_and(|(source, _)| source != self.name.as_str())
1242 {
1243 let mut cursor: Option<Cursor> = None;
1244 loop {
1245 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?;
1246 let (items, next) = if matches!(kind, WriteKind::Project) {
1247 let page = connection(&data, "projects", map_project)?;
1248 (
1249 page.items
1250 .into_iter()
1251 .map(|item| (item.id, item.metadata))
1252 .collect::<Vec<_>>(),
1253 page.next,
1254 )
1255 } else {
1256 let page = connection(&data, "issues", |v| map_task(v, &self.name))?;
1257 (
1258 page.items
1259 .into_iter()
1260 .map(|item| (item.id, item.metadata))
1261 .collect::<Vec<_>>(),
1262 page.next,
1263 )
1264 };
1265 if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
1266 metadata.get("onetaskgraph.origin").and_then(Value::as_str)
1267 == Some(edge.to.id())
1268 }) {
1269 edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
1270 break;
1271 }
1272 let Some(next) = next else { break };
1273 cursor = Some(next);
1274 }
1275 }
1276 prepared.push(edge);
1277 }
1278 Ok(prepared)
1279 }
1280}
1281
1282#[async_trait::async_trait]
1283impl TaskSource for LinearSource {
1284 fn kind(&self) -> &'static str {
1285 KIND
1286 }
1287 fn capabilities(&self) -> Capabilities {
1288 Capabilities {
1289 projects: Support::Native,
1290 documents: Support::Native,
1291 comments: Support::Native,
1292 orphan_tasks: Support::Native,
1293 filter_by_label: Support::Native,
1294 filter_by_status: Support::Native,
1295 search_title: Support::Unsupported,
1296 search_content: Support::Unsupported,
1297 task_dependencies: DependencySupport::BothDirections,
1298 project_dependencies: DependencySupport::BothDirections,
1299 max_page_size: MAX_PAGE_SIZE,
1300 }
1301 }
1302 fn writes(&self) -> WriteSupport {
1303 WriteSupport::Supported
1304 }
1305 async fn health(&self) -> Result<Health, SourceError> {
1306 let data = self.send(VIEWER, json!({})).await?;
1307 str_at(
1308 data.get("viewer").ok_or_else(|| SourceError::Malformed {
1309 message: "missing viewer".into(),
1310 })?,
1311 "id",
1312 )?;
1313 Ok(Health {
1314 reachable: true,
1315 detail: None,
1316 })
1317 }
1318 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1319 let d = self.send(ISSUE, json!({"id":id.0})).await?;
1320 optional(&d, "issue", |v| map_task(v, &self.name))
1321 }
1322 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1323 let d = self.send(PROJECT, json!({"id":id.0})).await?;
1324 optional(&d, "project", map_project)
1325 }
1326 async fn query_tasks(
1327 &self,
1328 query: &TaskQuery,
1329 page: &PageRequest,
1330 ) -> Result<Page<Task>, SourceError> {
1331 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?;
1332 connection(&d, "issues", |v| map_task(v, &self.name))
1333 }
1334 async fn query_projects(
1335 &self,
1336 query: &ProjectQuery,
1337 page: &PageRequest,
1338 ) -> Result<Page<Project>, SourceError> {
1339 // 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.
1340 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?;
1341 connection(&d, "projects", map_project)
1342 }
1343 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1344 let d = self
1345 .send(
1346 LABELS,
1347 json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0)}),
1348 )
1349 .await?;
1350 connection(&d, "issueLabels", map_label)
1351 }
1352 async fn task_dependencies(
1353 &self,
1354 id: &NativeId,
1355 direction: Direction,
1356 page: &PageRequest,
1357 ) -> Result<Page<DependencyEdge>, SourceError> {
1358 self.dependencies(ISSUE_RELATIONS, DependencyRoot::Issue, id, direction, page)
1359 .await
1360 }
1361 async fn project_dependencies(
1362 &self,
1363 id: &NativeId,
1364 direction: Direction,
1365 page: &PageRequest,
1366 ) -> Result<Page<DependencyEdge>, SourceError> {
1367 self.dependencies(
1368 PROJECT_RELATIONS,
1369 DependencyRoot::Project,
1370 id,
1371 direction,
1372 page,
1373 )
1374 .await
1375 }
1376 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1377 // Before anything is read or written, because nothing Linear could answer changes
1378 // it: see `NO_DELIVERY`. A write that dropped either list would report success for a
1379 // task the destination does not hold.
1380 let named = if !write.item.delivers.is_empty() {
1381 Some("delivers")
1382 } else if !write.item.delivered_by.is_empty() {
1383 Some("delivered_by")
1384 } else {
1385 delivery_key_in(&write.item.metadata)
1386 };
1387 if let Some(named) = named {
1388 return Err(self.undeliverable(named, "task"));
1389 }
1390 let edges = self
1391 .prepare_edges(&write.depends_on, WriteKind::Task)
1392 .await?;
1393 let team = self.team_id().await?;
1394 let state = self
1395 .one_id(Lookup::IssueState {
1396 name: &write.item.status.name,
1397 team: &team,
1398 })
1399 .await?;
1400 let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
1401 let description = self.write_description(
1402 write.item.content.as_deref(),
1403 &write.item.metadata,
1404 &write.item.repositories,
1405 &edges,
1406 WriteKind::Task,
1407 )?;
1408 let input = json!({"title":write.item.title,"description":description,"stateId":state,"labelIds":labels,"projectId":write.item.project.as_ref().map(|id| id.0.clone())});
1409 let (query, variables, root) = match &write.target {
1410 Some(id) => (
1411 graphql::ISSUE_UPDATE,
1412 json!({"id":id.0,"input":input}),
1413 MutationRoot::IssueUpdate,
1414 ),
1415 None => (
1416 graphql::ISSUE_CREATE,
1417 {
1418 let mut input = input;
1419 input["teamId"] = Value::String(team.0);
1420 json!({"input":input})
1421 },
1422 MutationRoot::IssueCreate,
1423 ),
1424 };
1425 let data = self.send(query, variables).await?;
1426 let issue =
1427 mutation_payload(&data, root)?
1428 .get("issue")
1429 .ok_or_else(|| SourceError::Malformed {
1430 message: format!("missing {}.issue", root.as_str()),
1431 })?;
1432 let id = NativeId(backend_id(issue, "id")?.into());
1433 self.write_relations(&id, &edges, WriteKind::Task).await?;
1434 Ok(id)
1435 }
1436 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1437 // Before anything is read or written, and before the item's own description
1438 // records these edges: an edge Linear will never accept has to refuse the whole
1439 // write, or a copy would create the project and then fail relating it, leaving the
1440 // undo to clean up a write that could have been refused without a call at all.
1441 if let Some(edge) = Self::unordered_project_edge(&write.depends_on) {
1442 return Err(self.unordered_project_relation(&write.item.id, edge.to.id()));
1443 }
1444 if let Some(key) = delivery_key_in(&write.item.metadata) {
1445 return Err(self.undeliverable(key, "project"));
1446 }
1447 let edges = self
1448 .prepare_edges(&write.depends_on, WriteKind::Project)
1449 .await?;
1450 let team = self.team_id().await?;
1451 let status = self
1452 .one_id(Lookup::ProjectStatus(&write.item.status.name))
1453 .await?;
1454 let labels = self
1455 .label_ids(&write.item.labels, WriteKind::Project)
1456 .await?;
1457 let description = self.write_description(
1458 write.item.content.as_deref(),
1459 &write.item.metadata,
1460 &write.item.repositories,
1461 &edges,
1462 WriteKind::Project,
1463 )?;
1464 let input = json!({"name":write.item.title,"description":description,"statusId":status,"labelIds":labels});
1465 let (query, variables, root) = match &write.target {
1466 Some(id) => (
1467 graphql::PROJECT_UPDATE,
1468 json!({"id":id.0,"input":input}),
1469 MutationRoot::ProjectUpdate,
1470 ),
1471 None => (
1472 graphql::PROJECT_CREATE,
1473 {
1474 let mut input = input;
1475 input["teamIds"] = json!([team]);
1476 json!({"input":input})
1477 },
1478 MutationRoot::ProjectCreate,
1479 ),
1480 };
1481 let data = self.send(query, variables).await?;
1482 let project = mutation_payload(&data, root)?
1483 .get("project")
1484 .ok_or_else(|| SourceError::Malformed {
1485 message: format!("missing {}.project", root.as_str()),
1486 })?;
1487 let id = NativeId(backend_id(project, "id")?.into());
1488 self.write_relations(&id, &edges, WriteKind::Project)
1489 .await?;
1490 Ok(id)
1491 }
1492 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
1493 // An id naming nothing is the state this asks for, not an error — Linear reports
1494 // an unknown issue as an errored response rather than an unsuccessful payload, and
1495 // `get_task` answering `None` is what says the item is already gone.
1496 if self.get_task(id).await?.is_none() {
1497 return Ok(());
1498 }
1499 let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
1500 mutation_payload(&data, MutationRoot::IssueDelete)?;
1501 Ok(())
1502 }
1503 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
1504 // An id naming nothing is the state this asks for, on exactly the terms
1505 // `delete_task` reads it on.
1506 if self.get_project(id).await?.is_none() {
1507 return Ok(());
1508 }
1509 let data = self
1510 .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
1511 .await?;
1512 mutation_payload(&data, MutationRoot::ProjectDelete)?;
1513 Ok(())
1514 }
1515 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
1516 // Read as an optional although the pinned `document(id:)` returns `Document!`, for
1517 // the reason `delete_task` records: Linear answers an id naming nothing with an
1518 // errored response rather than a null, and reading the null defensively is what
1519 // keeps a responder that does answer one from being a malformed-response failure.
1520 let d = self.send(DOCUMENT, json!({"id":id.0})).await?;
1521 optional(&d, "document", map_document)
1522 }
1523 async fn query_documents(
1524 &self,
1525 query: &DocumentQuery,
1526 page: &PageRequest,
1527 ) -> Result<Page<Document>, SourceError> {
1528 // `query.text` is read by nothing here on purpose. Both searches are declared
1529 // `Unsupported`, and capability rule 2 says an ignored predicate returns the
1530 // *wider* set for the engine to narrow — half-applying one is what would drop rows.
1531 let want = page.limit.min(MAX_PAGE_SIZE) as usize;
1532 let mut filter = serde_json::Map::new();
1533 if let ProjectFilter::Is(id) = &query.project {
1534 filter.insert("project".into(), json!({"id": {"eq": id.0}}));
1535 }
1536 let filter = Value::Object(filter);
1537 let mut items = Vec::new();
1538 let mut cursor = page.cursor.clone();
1539 loop {
1540 // Only what is still owed, so the predicates applied here can never make this
1541 // return more than the caller asked for, and never drop what it fetched.
1542 let first = want.saturating_sub(items.len()).max(1);
1543 let d = self
1544 .send(
1545 DOCUMENTS,
1546 json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
1547 )
1548 .await?;
1549 let fetched = connection(&d, "documents", map_document)?;
1550 items.extend(
1551 fetched
1552 .items
1553 .into_iter()
1554 .filter(|document| document_matches(document, &query.project, &query.labels)),
1555 );
1556 cursor = fetched.next;
1557 if cursor.is_none() || items.len() >= want {
1558 return Ok(Page {
1559 items,
1560 next: cursor,
1561 });
1562 }
1563 }
1564 }
1565 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
1566 // Two refusals by name rather than two silent drops. Linear's own document type
1567 // has no labels and a document is not work, so neither a label nor a dependency
1568 // has anywhere here to land — and a copy that dropped one would report success for
1569 // an item the destination does not hold.
1570 if !write.item.labels.is_empty() {
1571 let named = write
1572 .item
1573 .labels
1574 .iter()
1575 .map(|label| label.name.as_str())
1576 .collect::<Vec<_>>()
1577 .join(", ");
1578 return Err(SourceError::Refused {
1579 message: format!(
1580 "source {} cannot carry a document's labels, because Linear's own \
1581 document type has none: {named}",
1582 self.name
1583 ),
1584 });
1585 }
1586 if !write.depends_on.is_empty()
1587 || write
1588 .item
1589 .metadata
1590 .contains_key(DependencyEdge::RECORDED_KEY)
1591 {
1592 return Err(SourceError::Refused {
1593 message: format!(
1594 "source {} cannot carry {} on a document, because a document is not \
1595 work and nothing may depend on one",
1596 self.name,
1597 DependencyEdge::RECORDED_KEY
1598 ),
1599 });
1600 }
1601 if let Some(key) = delivery_key_in(&write.item.metadata) {
1602 return Err(self.undeliverable(key, "document"));
1603 }
1604 let content = Self::long_form(
1605 write.item.content.as_deref(),
1606 &write.item.metadata,
1607 &write.item.repositories,
1608 Vec::new(),
1609 )?;
1610 let project = write.item.project.as_ref().map(|id| id.0.clone());
1611 let (query, variables, root) = match &write.target {
1612 Some(id) => {
1613 // A target this workspace does not hold is refused rather than created:
1614 // the engine established that id before asking, so an absent one is a race
1615 // this destination must not paper over by writing a second document.
1616 if self.get_document(id).await?.is_none() {
1617 return Err(SourceError::Refused {
1618 message: format!("source {} holds no document {}", self.name, id.0),
1619 });
1620 }
1621 (
1622 graphql::DOCUMENT_UPDATE,
1623 json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
1624 MutationRoot::DocumentUpdate,
1625 )
1626 }
1627 None => {
1628 let mut input = json!({"title":write.item.title,"content":content});
1629 // A Linear document lives in a project, an initiative, an issue or a team.
1630 // One filed under no project needs the configured team to be its home, and
1631 // one filed under a project already has one — so the team is asked for
1632 // only where it is the answer, rather than made a condition of every write.
1633 //
1634 // **`projectId` is left out rather than sent as null, and that is Linear's
1635 // rule rather than tidiness.** `documentCreate` refuses an input that names
1636 // more than one home — `Exactly one of initiativeId, teamId, issueId,
1637 // releaseId, cycleId or projectId must be defined.` — and it counts a
1638 // *present* key, observed on 2026-09-04: `{projectId: null, teamId: …}` is
1639 // refused where `{teamId: …}` is accepted. So a document filed under no
1640 // project must carry no `projectId` at all. `documentUpdate` is the
1641 // opposite and keeps its explicit null, because there the null is the
1642 // instruction — it is how a document is moved out of a project, and
1643 // omitting the key would leave it where it was.
1644 match &project {
1645 Some(project) => input["projectId"] = Value::String(project.clone()),
1646 None => input["teamId"] = Value::String(self.team_id().await?.0),
1647 }
1648 (
1649 graphql::DOCUMENT_CREATE,
1650 json!({ "input": input }),
1651 MutationRoot::DocumentCreate,
1652 )
1653 }
1654 };
1655 let data = self.send(query, variables).await?;
1656 let document = mutation_payload(&data, root)?
1657 .get("document")
1658 .ok_or_else(|| SourceError::Malformed {
1659 message: format!("missing {}.document", root.as_str()),
1660 })?;
1661 Ok(NativeId(backend_id(document, "id")?.into()))
1662 }
1663 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
1664 // An id naming nothing is the state this asks for, on exactly the terms
1665 // `delete_task` reads it on.
1666 if self.get_document(id).await?.is_none() {
1667 return Ok(());
1668 }
1669 let data = self
1670 .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
1671 .await?;
1672 mutation_payload(&data, MutationRoot::DocumentDelete)?;
1673 Ok(())
1674 }
1675 async fn task_comments(
1676 &self,
1677 task: &NativeId,
1678 page: &PageRequest,
1679 ) -> Result<Option<Page<Comment>>, SourceError> {
1680 // A page of no rows is not a page: refused here rather than sent as `last: 0`, which
1681 // would answer an empty page that reads as a task with no comments.
1682 if page.limit == 0 {
1683 return Err(SourceError::Config {
1684 message: "a page limit of 0 is not a page; ask for at least 1 comment".to_owned(),
1685 });
1686 }
1687 // One request rather than a task lookup and then a read: the issue the comments
1688 // hang off answers "no such task" by itself, on exactly the terms `get_task` reads
1689 // it — null, or trashed.
1690 let d = self
1691 .send(
1692 graphql::ISSUE_COMMENTS,
1693 json!({"id":task.0,"last":page.limit.min(MAX_PAGE_SIZE),"before":page.cursor.as_ref().map(|c|&c.0)}),
1694 )
1695 .await?;
1696 optional(&d, "issue", comment_page)
1697 }
1698 async fn add_comment(
1699 &self,
1700 task: &NativeId,
1701 comment: &NewComment,
1702 ) -> Result<Option<Comment>, SourceError> {
1703 // Before anything is sent, because nothing Linear could answer changes it: see the
1704 // ruling on the author in this crate's module documentation.
1705 if let Some(author) = &comment.author {
1706 return Err(SourceError::Refused {
1707 message: format!(
1708 "source {} cannot post a comment as {author:?}, because Linear records the \
1709 user whose API key makes the request as the author of every comment; \
1710 leave --author out to post as that user",
1711 self.name
1712 ),
1713 });
1714 }
1715 let Some(issue) = self.commented_issue(task).await? else {
1716 return Ok(None);
1717 };
1718 let data = self
1719 .send(
1720 graphql::COMMENT_CREATE,
1721 json!({"input":{"issueId":issue.0,"body":comment.body.as_str()}}),
1722 )
1723 .await?;
1724 written_comment(&data, MutationRoot::CommentCreate).map(Some)
1725 }
1726 async fn edit_comment(
1727 &self,
1728 task: &NativeId,
1729 comment: &NativeId,
1730 body: &CommentBody,
1731 ) -> Result<Option<Comment>, SourceError> {
1732 if !self.comment_is_on(task, comment).await? {
1733 return Ok(None);
1734 }
1735 // `body` alone: the id, the author and the time it was written are the comment's
1736 // own, so nothing else is sent that Linear could move.
1737 let data = self
1738 .send(
1739 graphql::COMMENT_UPDATE,
1740 json!({"id":comment.0,"input":{"body":body.as_str()}}),
1741 )
1742 .await?;
1743 written_comment(&data, MutationRoot::CommentUpdate).map(Some)
1744 }
1745 async fn delete_comment(
1746 &self,
1747 task: &NativeId,
1748 comment: &NativeId,
1749 ) -> Result<Option<NativeId>, SourceError> {
1750 if !self.comment_is_on(task, comment).await? {
1751 return Ok(None);
1752 }
1753 let data = self
1754 .send(graphql::COMMENT_DELETE, json!({"id":comment.0}))
1755 .await?;
1756 mutation_payload(&data, MutationRoot::CommentDelete)?;
1757 Ok(Some(comment.clone()))
1758 }
1759 async fn set_task_status(
1760 &self,
1761 id: &NativeId,
1762 category: StatusCategory,
1763 ) -> Result<Option<Status>, SourceError> {
1764 // Before any request: a category no workflow state has is not one Linear could
1765 // answer differently for another issue. `workflow_state_types` is the same mapping
1766 // the status filter narrows with, so a status this sets is one that filter finds.
1767 let Some(state_type) = workflow_state_types(&category).first().copied() else {
1768 return Err(SourceError::Refused {
1769 message: format!(
1770 "source {} cannot set a task's status to {}: that category is disabled for \
1771 this source, because Linear has no workflow state of that kind — its \
1772 workflow states are triage, backlog, unstarted, started, completed and \
1773 canceled; choose backlog, todo, in-progress, done or cancelled",
1774 self.name,
1775 category_word(category)
1776 ),
1777 });
1778 };
1779 let Some(task) = self.get_task(id).await? else {
1780 return Ok(None);
1781 };
1782 // Already in the category asked for: its own state is left where it is. A team can
1783 // hold several states of one type — `In Progress` and `In Review` are both `started`
1784 // — and moving an issue from one to the other is a change nobody asked for.
1785 if task.status.category == category {
1786 return Ok(Some(task.status));
1787 }
1788 let team = self.team_id().await?;
1789 let data = self
1790 .send(
1791 graphql::ISSUE_STATE_OF_TYPE,
1792 json!({"type":state_type,"team":team.0}),
1793 )
1794 .await?;
1795 let nodes = data
1796 .get("workflowStates")
1797 .and_then(|v| v.get("nodes"))
1798 .and_then(Value::as_array)
1799 .ok_or_else(|| SourceError::Malformed {
1800 message: "missing workflowStates.nodes".into(),
1801 })?;
1802 // The first node Linear lists, and deliberately no choice beyond that: every state of
1803 // this type reads back as the category asked for, which is the whole of what a status
1804 // write owes, and nothing a category carries says which of several the caller meant.
1805 let Some(state) = nodes.first() else {
1806 return Err(SourceError::Refused {
1807 message: format!(
1808 "source {} cannot set task {} to {}: its configured team has no workflow \
1809 state of type {state_type}; add one to the team in Linear",
1810 self.name,
1811 id.0,
1812 category_word(category)
1813 ),
1814 });
1815 };
1816 let state_id = backend_id(state, "id")?;
1817 let name = str_at(state, "name")?.to_owned();
1818 // `stateId` alone, so nothing else about the issue can move: Linear's
1819 // `IssueUpdateInput` makes every member optional and leaves an absent one as it was.
1820 let data = self
1821 .send(
1822 graphql::ISSUE_UPDATE,
1823 json!({"id":task.id.0,"input":{"stateId":state_id}}),
1824 )
1825 .await?;
1826 let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
1827 .get("issue")
1828 .ok_or_else(|| SourceError::Malformed {
1829 message: "missing issueUpdate.issue".into(),
1830 })?;
1831 backend_id(issue, "id")?;
1832 Ok(Some(Status { category, name }))
1833 }
1834 async fn set_delivered_by(
1835 &self,
1836 id: &NativeId,
1837 delivered_by: &[TaskRef],
1838 ) -> Result<Option<()>, SourceError> {
1839 let _ = (id, delivered_by);
1840 Err(self.undeliverable("delivered_by", "task"))
1841 }
1842}
1843
1844/// Why this source carries neither [`Task::delivers`] nor [`Task::delivered_by`].
1845///
1846/// Linear has no field for either, and standing one up in the description's metadata slot is
1847/// what this source does only for the keys whose owner is the item itself. `delivered_by` is
1848/// the store's to keep in step across every source, and a slot in somebody's issue
1849/// description is not a store that step can be kept in — so both are refused by name rather
1850/// than written, and read only when something else put them there.
1851const NO_DELIVERY: &str = "Linear has no field recording which tasks a task delivers or is \
1852 delivered by, and this source does not record either in its \
1853 description's metadata slot";
1854
1855/// The reserved delivery key `metadata` carries, if it carries one.
1856fn delivery_key_in(metadata: &std::collections::BTreeMap<String, Value>) -> Option<&'static str> {
1857 [TaskRef::DELIVERS_KEY, TaskRef::DELIVERED_BY_KEY]
1858 .into_iter()
1859 .find(|key| metadata.contains_key(*key))
1860}
1861
1862/// A category as the wire spells it — `in-progress`, `queued` — for a message.
1863fn category_word(category: StatusCategory) -> String {
1864 serde_json::to_value(category)
1865 .ok()
1866 .and_then(|value| value.as_str().map(str::to_owned))
1867 .unwrap_or_else(|| format!("{category:?}"))
1868}
1869
1870impl LinearSource {
1871 /// The refusal a write naming `named` — a field or a reserved key — on a `what` gets.
1872 fn undeliverable(&self, named: &str, what: &str) -> SourceError {
1873 SourceError::Refused {
1874 message: format!(
1875 "source {} cannot carry {named} on a {what}: {NO_DELIVERY}; write the {what} \
1876 without it",
1877 self.name
1878 ),
1879 }
1880 }
1881
1882 /// The backend id of the issue `task` names, or `None` when this source holds no such
1883 /// task — resolved by `get_task` itself, so a comment call and a task read cannot
1884 /// disagree about whether a task is there.
1885 ///
1886 /// The id Linear answers with rather than the one asked for, because `issue(id:)` also
1887 /// takes an identifier such as `ENG-1`, and the comment's own `issue{id}` is compared
1888 /// against — and a comment is created on — the backend id.
1889 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
1890 Ok(self.get_task(task).await?.map(|task| task.id))
1891 }
1892
1893 /// Whether `comment` is a comment on the issue `task` names.
1894 ///
1895 /// Asked before any edit or removal, so an id belonging to another issue — or to no
1896 /// issue, or to nothing — is answered as no such comment without a mutation reaching
1897 /// Linear. `commentUpdate` and `commentDelete` address a comment by its id alone, so
1898 /// without this a task named in error would edit or remove somebody else's comment.
1899 async fn comment_is_on(
1900 &self,
1901 task: &NativeId,
1902 comment: &NativeId,
1903 ) -> Result<bool, SourceError> {
1904 let Some(issue) = self.commented_issue(task).await? else {
1905 return Ok(false);
1906 };
1907 let data = self.send(graphql::COMMENT, json!({"id":comment.0})).await?;
1908 Ok(optional(&data, "comment", comment_issue)?.flatten() == Some(issue))
1909 }
1910}
1911
1912/// One page of an issue's comments, oldest first.
1913///
1914/// Linear answered newest first, walking backwards from `before`, so the page is reversed
1915/// and the next cursor is the one *behind* it; see the ruling on comments in this crate's
1916/// module documentation for why the walk runs that way.
1917fn comment_page(v: &Value) -> Result<Page<Comment>, SourceError> {
1918 let c = v.get("comments").ok_or_else(|| SourceError::Malformed {
1919 message: "missing comments connection".into(),
1920 })?;
1921 let mut items = c
1922 .get("nodes")
1923 .and_then(Value::as_array)
1924 .ok_or_else(|| SourceError::Malformed {
1925 message: "missing comment nodes".into(),
1926 })?
1927 .iter()
1928 .map(map_comment)
1929 .collect::<Result<Vec<_>, _>>()?;
1930 items.reverse();
1931 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
1932 message: "missing pageInfo".into(),
1933 })?;
1934 let older = info
1935 .get("hasPreviousPage")
1936 .and_then(Value::as_bool)
1937 .ok_or_else(|| SourceError::Malformed {
1938 message: "missing boolean pageInfo.hasPreviousPage".into(),
1939 })?;
1940 let next = if older {
1941 Some(Cursor(str_at(info, "startCursor")?.into()))
1942 } else {
1943 None
1944 };
1945 Ok(Page { items, next })
1946}
1947
1948fn map_comment(v: &Value) -> Result<Comment, SourceError> {
1949 let author = match v.get("user") {
1950 None => {
1951 return Err(SourceError::Malformed {
1952 message: "missing comment user field".into(),
1953 });
1954 }
1955 // An integration or a bot: Linear names no user, and this source invents none.
1956 Some(Value::Null) => None,
1957 Some(user) => Some(str_at(user, "displayName")?.to_owned()),
1958 };
1959 Ok(Comment {
1960 id: NativeId(backend_id(v, "id")?.into()),
1961 author,
1962 created_at: time(v, "createdAt")?,
1963 updated_at: time(v, "updatedAt")?,
1964 body: str_at(v, "body")?.into(),
1965 url: optional_string(v, "url")?,
1966 })
1967}
1968
1969/// The comment a `commentCreate` or `commentUpdate` answered with, as Linear now holds it.
1970fn written_comment(data: &Value, root: MutationRoot) -> Result<Comment, SourceError> {
1971 let comment = mutation_payload(data, root)?
1972 .get("comment")
1973 .ok_or_else(|| SourceError::Malformed {
1974 message: format!("missing {}.comment", root.as_str()),
1975 })?;
1976 map_comment(comment)
1977}
1978
1979/// The issue a comment is on, or `None` for a comment on something else — a project, a
1980/// document, an update — which is a comment no task of this source has.
1981fn comment_issue(v: &Value) -> Result<Option<NativeId>, SourceError> {
1982 match v.get("issue") {
1983 None => Err(SourceError::Malformed {
1984 message: "missing comment issue field".into(),
1985 }),
1986 Some(Value::Null) => Ok(None),
1987 Some(issue) => Ok(Some(NativeId(backend_id(issue, "id")?.into()))),
1988 }
1989}
1990
1991/// Linear relates one Linear item to another and nothing else, so an edge whose far end
1992/// is in a different source is the one edge no `relations` entry can hold. Those edges
1993/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
1994/// are served *after* the native relations are spent: a page under this cursor is the
1995/// recorded tail of the same walk, which keeps the native pages exactly what they were.
1996const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1997
1998impl LinearSource {
1999 async fn dependencies(
2000 &self,
2001 query: &str,
2002 root: DependencyRoot,
2003 id: &NativeId,
2004 direction: Direction,
2005 page: &PageRequest,
2006 ) -> Result<Page<DependencyEdge>, SourceError> {
2007 let limit = page.limit.min(MAX_PAGE_SIZE);
2008 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
2009 if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
2010 // This cursor resumes the *forward* tail and only a forward walk ever issues
2011 // one, so a reverse read carrying it is resuming a walk it did not come from.
2012 // Serving it would answer a reverse read with forward edges, which is the one
2013 // thing a recorded edge must never do — its reverse is derived from the far
2014 // end and is never written down here.
2015 if direction != Direction::DependsOn {
2016 return Err(SourceError::Malformed {
2017 message: format!(
2018 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a reverse dependency read never issues; resume it in the direction that reported it"
2019 ),
2020 });
2021 }
2022 let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
2023 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
2024 })?;
2025 let d = self
2026 .send(query, json!({"id":id.0,"first":1,"after":null}))
2027 .await?;
2028 return Ok(recorded_page(
2029 recorded(&d, root, id, &self.name)?,
2030 offset,
2031 limit as usize,
2032 ));
2033 }
2034 let d = self
2035 .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
2036 .await?;
2037 let mut answered = relation_page(&d, root, id, direction)?;
2038 // Only forwards: the reverse of a recorded edge is derived from the far end, never
2039 // written down on the near item.
2040 if answered.next.is_none()
2041 && direction == Direction::DependsOn
2042 && !recorded(&d, root, id, &self.name)?.is_empty()
2043 {
2044 answered.next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
2045 }
2046 Ok(answered)
2047 }
2048}
2049
2050fn recorded(
2051 d: &Value,
2052 root: DependencyRoot,
2053 id: &NativeId,
2054 name: &SourceName,
2055) -> Result<Vec<DependencyEdge>, SourceError> {
2056 let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
2057 message: format!("missing {}", root.as_str()),
2058 })?;
2059 let (_, metadata) = metadata_description(optional_string(item, "description")?)?;
2060 // `relations` on an issue holds issues and on a project holds projects, both of this
2061 // workspace — so a same-kind far end in this same source is one Linear itself was
2062 // supposed to hold, and the key is refused rather than quietly read, whether the entry
2063 // left the source out or spelled this one.
2064 DependencyEdge::recorded(
2065 &metadata,
2066 id,
2067 root.item_kind(),
2068 name,
2069 Some(root.item_kind()),
2070 )
2071 .map_err(|message| SourceError::Malformed { message })
2072}
2073
2074fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
2075 let total = edges.len();
2076 let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
2077 let end = offset.saturating_add(items.len());
2078 Page {
2079 items,
2080 next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
2081 }
2082}
2083
2084// 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.
2085/// A category as `WorkflowState.type` spells it — the vocabulary an **issue**'s state has.
2086///
2087/// Linear's workflow states are triage, backlog, unstarted, started, completed and
2088/// canceled. None of them is a draft, so `Draft` narrows to nothing exactly as `Unknown`
2089/// does rather than filtering on a state Linear does not have.
2090fn workflow_state_types(s: &StatusCategory) -> Vec<&'static str> {
2091 match s {
2092 StatusCategory::Draft => vec![],
2093 StatusCategory::Backlog => vec!["backlog"],
2094 StatusCategory::Todo => vec!["unstarted"],
2095 // Linear has no state for work that is claimed and not yet started: `unstarted` is
2096 // `todo` and `started` is `in-progress`, and a Linear issue reads back as one of
2097 // those. So `queued` narrows to nothing, exactly as `draft` does — mapping it onto
2098 // either neighbour would have a `queued` filter return an item that reads back as
2099 // `todo` or `in-progress`, which is capability rule 1 broken.
2100 StatusCategory::Queued => vec![],
2101 StatusCategory::InProgress => vec!["started"],
2102 StatusCategory::Done => vec!["completed"],
2103 StatusCategory::Cancelled => vec!["canceled"],
2104 StatusCategory::Unknown => vec![],
2105 }
2106}
2107/// A category as `ProjectStatus.type` spells it — a **different** vocabulary, and a
2108/// different enum: Linear declares that field `ProjectStatusType!`, whose members are
2109/// backlog, planned, started, paused, completed and canceled.
2110///
2111/// Two of them have no issue counterpart and are why this cannot be the function above.
2112/// `planned` is where `unstarted` would be, so it is what `Todo` narrows to; a project
2113/// filtered with `unstarted` matches nothing and is refused by nothing, which is how this
2114/// went unnoticed. And `paused` is a project that has started and is neither finished nor
2115/// cancelled, so it reads as in progress — the same reading [`status`] gives it, which is
2116/// what keeps this narrowing and that mapping the same claim rather than two.
2117fn project_status_types(s: &StatusCategory) -> Vec<&'static str> {
2118 match s {
2119 StatusCategory::Draft => vec![],
2120 StatusCategory::Backlog => vec!["backlog"],
2121 StatusCategory::Todo => vec!["planned"],
2122 // No `ProjectStatusType` is claimed-and-not-started either, so `queued` narrows to
2123 // nothing here for the reason it does for an issue above.
2124 StatusCategory::Queued => vec![],
2125 StatusCategory::InProgress => vec!["started", "paused"],
2126 StatusCategory::Done => vec!["completed"],
2127 StatusCategory::Cancelled => vec!["canceled"],
2128 StatusCategory::Unknown => vec![],
2129 }
2130}
2131/// The category a Linear status name and type normalise to, at either level.
2132///
2133/// One mapper for both vocabularies, because the two are disjoint where they differ: no
2134/// issue is ever `planned` or `paused`, and no project is ever `unstarted` or `triage`. It
2135/// is the inverse of [`workflow_state_types`] and [`project_status_types`] together, and
2136/// has to stay so: a category this reports and that filter cannot ask for is capability
2137/// rule 1 broken, and the row would go missing rather than be refused.
2138///
2139/// **It never answers `Queued` or `Draft`**, and that is the other half of the same claim:
2140/// both filters narrow those two to nothing, because no Linear state or project status means
2141/// either, so a row this reported as one would be a row no filter for it could return. A type
2142/// Linear does not document — even one spelled `queued` — is `Unknown`, never a guess.
2143fn status(v: &Value) -> Result<Status, SourceError> {
2144 let name = str_at(v, "name")?.into();
2145 let category = match str_at(v, "type")? {
2146 "backlog" => StatusCategory::Backlog,
2147 "unstarted" | "planned" => StatusCategory::Todo,
2148 "started" | "paused" => StatusCategory::InProgress,
2149 "completed" => StatusCategory::Done,
2150 "canceled" => StatusCategory::Cancelled,
2151 _ => StatusCategory::Unknown,
2152 };
2153 Ok(Status { category, name })
2154}
2155// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2156fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
2157 v.get(k)
2158 .and_then(Value::as_str)
2159 .ok_or_else(|| SourceError::Malformed {
2160 message: format!("missing string field {k}"),
2161 })
2162}
2163fn map_label(v: &Value) -> Result<Label, SourceError> {
2164 Ok(Label {
2165 id: NativeId(str_at(v, "id")?.into()),
2166 name: str_at(v, "name")?.into(),
2167 color: optional_string(v, "color")?,
2168 })
2169}
2170fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
2171 v.get("nodes")
2172 .and_then(Value::as_array)
2173 .ok_or_else(|| SourceError::Malformed {
2174 message: "missing label nodes".into(),
2175 })?
2176 .iter()
2177 .map(map_label)
2178 .collect()
2179}
2180fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2181 optional_str(v, k)?
2182 .map(|s| {
2183 s.parse().map_err(|e| SourceError::Malformed {
2184 message: format!("invalid {k}: {e}"),
2185 })
2186 })
2187 .transpose()
2188}
2189/// One issue as a task, `source` being this source's configured name.
2190///
2191/// The name is what lets [`TaskRef::listed`] tell `work:I-1` on the issue `I-1` of the
2192/// source `work` apart as that issue itself, rather than recognising only the bare spelling.
2193fn map_task(v: &Value, source: &SourceName) -> Result<Task, SourceError> {
2194 let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
2195 let repositories = Repository::from_metadata(&metadata)
2196 .map_err(|message| SourceError::Malformed { message })?;
2197 let url = optional_string(v, "url")?;
2198 let id = NativeId(str_at(v, "id")?.into());
2199 // Taken out of the caller's metadata as they are read: a reserved key is this product's,
2200 // and reporting it there as well would hand a consumer two spellings of one list.
2201 let delivers = delivery_list(&mut metadata, TaskRef::DELIVERS_KEY, &id, source)?;
2202 let delivered_by = delivery_list(&mut metadata, TaskRef::DELIVERED_BY_KEY, &id, source)?;
2203 Ok(Task {
2204 id,
2205 title: str_at(v, "title")?.into(),
2206 content,
2207 status: status(v.get("state").ok_or_else(|| SourceError::Malformed {
2208 message: "missing state".into(),
2209 })?)?,
2210 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
2211 message: "missing labels".into(),
2212 })?)?,
2213 project: filed_under(v)?,
2214 location: web_address(url.as_deref()),
2215 url,
2216 created_at: time(v, "createdAt")?,
2217 updated_at: time(v, "updatedAt")?,
2218 metadata,
2219 repositories,
2220 delivers,
2221 delivered_by,
2222 })
2223}
2224/// One delivery list read out of an issue's metadata slot, and removed from it.
2225///
2226/// An entry that is not a task id, that names the issue itself, or that repeats is a
2227/// malformed response naming the task and the entry, never a list quietly shortened.
2228fn delivery_list(
2229 metadata: &mut std::collections::BTreeMap<String, Value>,
2230 key: &str,
2231 task: &NativeId,
2232 source: &SourceName,
2233) -> Result<Vec<TaskRef>, SourceError> {
2234 let held = metadata.remove(key);
2235 TaskRef::from_value(key, task, Some(source), held.as_ref())
2236 .map_err(|message| SourceError::Malformed { message })
2237}
2238/// Remove the two delivery keys from a project's or a document's metadata.
2239///
2240/// Neither is work that delivers anything, so a key there names nothing this contract has,
2241/// and it is not the caller's free metadata either: it is this product's reserved spelling.
2242fn strip_delivery_keys(metadata: &mut std::collections::BTreeMap<String, Value>) {
2243 metadata.remove(TaskRef::DELIVERS_KEY);
2244 metadata.remove(TaskRef::DELIVERED_BY_KEY);
2245}
2246fn map_project(v: &Value) -> Result<Project, SourceError> {
2247 let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
2248 strip_delivery_keys(&mut metadata);
2249 let repositories = Repository::from_metadata(&metadata)
2250 .map_err(|message| SourceError::Malformed { message })?;
2251 let url = optional_string(v, "url")?;
2252 Ok(Project {
2253 id: NativeId(str_at(v, "id")?.into()),
2254 title: str_at(v, "name")?.into(),
2255 content,
2256 status: status(v.get("status").ok_or_else(|| SourceError::Malformed {
2257 message: "missing status".into(),
2258 })?)?,
2259 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
2260 message: "missing project labels".into(),
2261 })?)?,
2262 location: web_address(url.as_deref()),
2263 url,
2264 created_at: time(v, "createdAt")?,
2265 updated_at: time(v, "updatedAt")?,
2266 metadata,
2267 repositories,
2268 })
2269}
2270
2271/// Where a Linear entity is: the web address Linear itself reports for it, as a link.
2272///
2273/// Every issue, project and document of a Linear workspace has a page a person can open,
2274/// so this source says so for all three — the counterpart of a folder of Markdown
2275/// reporting the path of the file behind an item. A source that reported nothing here is
2276/// what leaves a reader holding an opaque id, and `None` is reserved for the case Linear
2277/// really did not say, which is not the same as saying the entity is nowhere.
2278fn web_address(url: Option<&str>) -> Option<Location> {
2279 url.map(|url| Location::Url(url.to_owned()))
2280}
2281
2282/// The project a Linear item is filed under, or `None` for one filed under nothing.
2283///
2284/// One reader for issues and documents alike, because the field is the same field: an
2285/// absent `project` key is a malformed response, a null one is an orphan.
2286fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
2287 match v.get("project") {
2288 None => Err(SourceError::Malformed {
2289 message: "missing project field".into(),
2290 }),
2291 Some(Value::Null) => Ok(None),
2292 Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
2293 }
2294}
2295
2296fn map_document(v: &Value) -> Result<Document, SourceError> {
2297 let (content, mut metadata) = metadata_description(optional_string(v, "content")?)?;
2298 strip_delivery_keys(&mut metadata);
2299 let repositories = Repository::from_metadata(&metadata)
2300 .map_err(|message| SourceError::Malformed { message })?;
2301 let url = optional_string(v, "url")?;
2302 Ok(Document {
2303 id: NativeId(str_at(v, "id")?.into()),
2304 title: str_at(v, "title")?.into(),
2305 content,
2306 project: filed_under(v)?,
2307 // Linear's `Document` carries no labels, and that is the published schema rather
2308 // than a gap here: the types of it that carry `labels` are `Issue`, `Project`,
2309 // `Team`, `Initiative` and `Organization`. Reporting none is what a source with no
2310 // native slot owes; standing one up beside a first-class type is what this source
2311 // exists not to do, and `write_document` refuses a label by name for the same
2312 // reason rather than dropping it.
2313 labels: Vec::new(),
2314 location: web_address(url.as_deref()),
2315 url,
2316 created_at: time(v, "createdAt")?,
2317 updated_at: time(v, "updatedAt")?,
2318 metadata,
2319 repositories,
2320 })
2321}
2322
2323/// Whether this document satisfies the predicates this source applies to a fetched page.
2324///
2325/// Two of them reach a page rather than the `documents(filter:)` variables, and each for a
2326/// reason of Linear's own. `DocumentFilter.project` is a `ProjectFilter` where
2327/// `IssueFilter.project` is a `NullableProjectFilter`, so only the issue side can be asked
2328/// for the items belonging to no project. And a Linear document carries no label at all,
2329/// so a query demanding one keeps nothing and a query excluding one keeps everything —
2330/// which is this source *applying* the predicate it declares native, over the labels the
2331/// document really has, rather than ignoring it.
2332fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
2333 let carries = |name: &String| {
2334 document
2335 .labels
2336 .iter()
2337 .any(|label| label.name.eq_ignore_ascii_case(name))
2338 };
2339 let filed = match project {
2340 ProjectFilter::Any => true,
2341 ProjectFilter::Orphans => document.project.is_none(),
2342 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
2343 };
2344 filed
2345 && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
2346 && labels.all_of.iter().all(&carries)
2347 && !labels.none_of.iter().any(&carries)
2348}
2349
2350fn optional<T>(
2351 d: &Value,
2352 k: &str,
2353 f: impl Fn(&Value) -> Result<T, SourceError>,
2354) -> Result<Option<T>, SourceError> {
2355 match d.get(k) {
2356 None => Err(SourceError::Malformed {
2357 message: format!("missing {k}"),
2358 }),
2359 Some(Value::Null) => Ok(None),
2360 // An item Linear no longer shows is not an item this source holds, and Linear says
2361 // so with `archivedAt` rather than by answering null.
2362 //
2363 // **None of Linear's three `delete` verbs removes anything.** `issueDelete`,
2364 // `projectDelete` and `documentDelete` move the item to the trash: observed on
2365 // 2026-09-04, each answered `success: true` and the item still read back by id,
2366 // carrying `archivedAt` and `trashed: true`. Its separate *archive* verb is a third
2367 // state — `archivedAt` set, `trashed` null — and Linear excludes both from every
2368 // connection, so `issues`, `projects` and `documents` had already stopped returning
2369 // them while a read by id still did.
2370 //
2371 // `archivedAt` rather than `trashed` for exactly that reason: it is the marker both
2372 // states share, so a read by id answers what a listing answers, and a delete means
2373 // what a copy's undo needs it to mean — the item this run created is gone.
2374 Some(value) if !matches!(value.get("archivedAt"), None | Some(Value::Null)) => Ok(None),
2375 Some(value) => f(value).map(Some),
2376 }
2377}
2378fn connection<T>(
2379 d: &Value,
2380 k: &str,
2381 f: impl Fn(&Value) -> Result<T, SourceError>,
2382) -> Result<Page<T>, SourceError> {
2383 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
2384 message: format!("missing {k} connection"),
2385 })?;
2386 let items = c
2387 .get("nodes")
2388 .and_then(Value::as_array)
2389 .ok_or_else(|| SourceError::Malformed {
2390 message: "missing nodes".into(),
2391 })?
2392 .iter()
2393 .map(f)
2394 .collect::<Result<_, _>>()?;
2395 let next = page_next(c)?;
2396 Ok(Page { items, next })
2397}
2398#[derive(Clone, Copy)]
2399enum DependencyRoot {
2400 Issue,
2401 Project,
2402}
2403impl DependencyRoot {
2404 const fn item_kind(self) -> ItemKind {
2405 match self {
2406 Self::Issue => ItemKind::Task,
2407 Self::Project => ItemKind::Project,
2408 }
2409 }
2410 const fn as_str(self) -> &'static str {
2411 match self {
2412 Self::Issue => "issue",
2413 Self::Project => "project",
2414 }
2415 }
2416}
2417fn relation_page(
2418 d: &Value,
2419 root: DependencyRoot,
2420 id: &NativeId,
2421 direction: Direction,
2422) -> Result<Page<DependencyEdge>, SourceError> {
2423 let key = if direction == Direction::DependsOn {
2424 "relations"
2425 } else {
2426 "inverseRelations"
2427 };
2428 let c = d
2429 .get(root.as_str())
2430 .and_then(|v| v.get(key))
2431 .ok_or_else(|| SourceError::Malformed {
2432 message: format!("missing {key}"),
2433 })?;
2434 let nodes = c
2435 .get("nodes")
2436 .and_then(Value::as_array)
2437 .ok_or_else(|| SourceError::Malformed {
2438 message: "missing relation nodes".into(),
2439 })?;
2440 let mut items = Vec::new();
2441 for n in nodes {
2442 let other = n
2443 .get(if direction == Direction::DependsOn {
2444 "relatedIssue"
2445 } else {
2446 "issue"
2447 })
2448 .or_else(|| {
2449 n.get(if direction == Direction::DependsOn {
2450 "relatedProject"
2451 } else {
2452 "project"
2453 })
2454 })
2455 .and_then(|v| v.get("id"))
2456 .and_then(Value::as_str)
2457 .ok_or_else(|| SourceError::Malformed {
2458 message: "missing related id".into(),
2459 })?;
2460 let (from, to) = if direction == Direction::DependsOn {
2461 (id.clone(), NativeId(other.into()))
2462 } else {
2463 (NativeId(other.into()), id.clone())
2464 };
2465 // 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.
2466 let relation_type =
2467 n.get("type")
2468 .and_then(Value::as_str)
2469 .ok_or_else(|| SourceError::Malformed {
2470 message: "missing relation type".into(),
2471 })?;
2472 // An issue relation and a project relation do not share a vocabulary. Linear
2473 // spells a project dependency `dependency`, where an issue's is `blocks`; the
2474 // write side sends exactly that pair and says why. So each root reads only its
2475 // own, and a value the other root would have accepted is refused here rather than
2476 // read as an edge this source could not have written.
2477 //
2478 // `related` is one of those values, and only an issue relation has it. Linear's
2479 // validator enumerates a project relation's `type` as `dependency` alone — see
2480 // the write side, which had `related` refused by the real API on 2026-09-04 — so
2481 // a project relation typed `related` is not a relation this workspace can hold.
2482 let kind = match (root, relation_type) {
2483 (DependencyRoot::Issue, "blocks") | (DependencyRoot::Project, "dependency") => {
2484 DependencyKind::Blocks
2485 }
2486 (DependencyRoot::Issue, "related") => DependencyKind::Related,
2487 _ => {
2488 return Err(SourceError::Malformed {
2489 message: format!(
2490 "invalid relation type: {relation_type} on a {} relation",
2491 root.as_str()
2492 ),
2493 });
2494 }
2495 };
2496 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2497 let item_kind = root.item_kind();
2498 items.push(DependencyEdge {
2499 from: DependencyEndpoint::from_native(from, item_kind),
2500 to: DependencyEndpoint::from_native(to, item_kind),
2501 kind,
2502 });
2503 }
2504 let next = page_next(c)?;
2505 Ok(Page { items, next })
2506}
2507
2508fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
2509 match v.get(k) {
2510 None => Err(SourceError::Malformed {
2511 message: format!("missing field {k}"),
2512 }),
2513 Some(Value::Null) => Ok(None),
2514 Some(value) => value
2515 .as_str()
2516 .map(Some)
2517 .ok_or_else(|| SourceError::Malformed {
2518 message: format!("field {k} is not a string"),
2519 }),
2520 }
2521}
2522
2523/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment
2524/// at the end of `description`; its later write side must use this exact encoding.
2525const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2526const METADATA_CLOSE: &str = "\n-->";
2527/// The same close as Linear hands a document's `content` back: it stores a document as
2528/// Markdown and escapes a line opening `-->`, so the slot this source wrote reads back with
2529/// a backslash before its close (observed from the real API on 2026-09-14). An issue's or a
2530/// project's `description` comes back as written. The write side keeps the one encoding.
2531const METADATA_CLOSE_ESCAPED: &str = "\n\\-->";
2532
2533fn metadata_description(
2534 description: Option<String>,
2535) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
2536 let Some(description) = description else {
2537 return Ok((None, Default::default()));
2538 };
2539 let Some(start) = description.rfind(METADATA_OPEN) else {
2540 return Ok((Some(description), Default::default()));
2541 };
2542 let encoded_start = start + METADATA_OPEN.len();
2543 let close = [METADATA_CLOSE, METADATA_CLOSE_ESCAPED]
2544 .into_iter()
2545 .filter_map(|close| {
2546 description[encoded_start..]
2547 .find(close)
2548 .map(|at| (at, close.len()))
2549 })
2550 .min();
2551 let Some((relative_end, close_len)) = close else {
2552 return Err(SourceError::Malformed {
2553 message: "unterminated onetaskgraph metadata slot in Linear description".into(),
2554 });
2555 };
2556 let encoded_end = encoded_start + relative_end;
2557 if !description[encoded_end + close_len..].trim().is_empty() {
2558 return Ok((Some(description), Default::default()));
2559 }
2560 let metadata =
2561 serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
2562 SourceError::Malformed {
2563 message: format!(
2564 "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
2565 ),
2566 }
2567 })?;
2568 let visible = description[..start].trim_end();
2569 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2570}
2571
2572fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
2573 Ok(optional_str(v, k)?.map(Into::into))
2574}
2575fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2576 let id = str_at(value, field)?;
2577 (!id.is_empty())
2578 .then_some(id)
2579 .ok_or_else(|| SourceError::Malformed {
2580 message: format!("field {field} is an empty backend id"),
2581 })
2582}
2583fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
2584 let root = root.as_str();
2585 let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
2586 message: format!("missing {root}"),
2587 })?;
2588 match payload.get("success").and_then(Value::as_bool) {
2589 Some(true) => Ok(payload),
2590 Some(false) => Err(SourceError::Refused {
2591 message: format!("Linear reported {root} was unsuccessful"),
2592 }),
2593 None => Err(SourceError::Malformed {
2594 message: format!("missing boolean {root}.success"),
2595 }),
2596 }
2597}
2598fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
2599 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
2600 message: "missing pageInfo".into(),
2601 })?;
2602 let more = info
2603 .get("hasNextPage")
2604 .and_then(Value::as_bool)
2605 .ok_or_else(|| SourceError::Malformed {
2606 message: "missing boolean pageInfo.hasNextPage".into(),
2607 })?;
2608 if !more {
2609 return Ok(None);
2610 }
2611 let cursor = str_at(info, "endCursor")?;
2612 Ok(Some(Cursor(cursor.into())))
2613}