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