Skip to main content

onetaskgraph_linear/
lib.rs

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