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