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