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