Skip to main content

onetaskgraph_linear/
lib.rs

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