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