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 an issue's `WorkflowState.name` and a
5//! project's `ProjectStatus.name` are preserved as the status's name while the source's
6//! `status_mapping` — the one grammar every source that names its statuses is configured with,
7//! [`StatusMapping`] — decides its category. Issue `relations`/`inverseRelations` and
8//! project relations provide native dependency traversal in both directions.
9//!
10//! Label, workflow-state, project, and orphan filters are sent in the
11//! `issues(filter:)`/`projects(filter:)` variables. Pagination uses Relay `first` and
12//! `after`.
13//!
14//! Every issue, project and document reports its own Linear web address as its
15//! [`Location`], as a link rather than a path — the counterpart of a folder of Markdown
16//! reporting the path of the file behind an item. It does not replace the `url` field
17//! those types already carry; it is the same address said in the shape a reader can act on.
18//!
19//! # What this source declares, field by field
20//!
21//! One verdict per field of [`Capabilities`]. A field is *supported and proven* when this
22//! source applies it and a shared journey drives it against the real binary; the shared
23//! table is `crates/onetaskgraph-e2e-support/src/fixtures.rs`, the journeys are beside it, and
24//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
25//! [`capabilities`](TaskSource::capabilities) from parting.
26//!
27//! | Field | Verdict |
28//! | --- | --- |
29//! | `projects` | **Supported and proven.** `issues(filter:{project:{id:{eq:…}}})`. |
30//! | `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. |
31//! | `comments` | **Supported and proven,** as the issue's own comments: read oldest first through `issue(id:){comments(last:,before:)}`, added with `commentCreate`, edited with `commentUpdate` and removed with `commentDelete` — each of the last two only once `comment(id:)` has placed the comment on that very issue. See the ruling below on the order and on the author. |
32//! | `assets` | **Supported and proven.** Native asset support: Images are uploaded through Linear's `fileUpload` flow, verified by an authenticated GET, and their Markdown references rewritten before content is written. Matching recorded SHA-256 values reuse their URLs. Images are visible to authenticated members of the workspace; see [`assets`] for the mechanism and bounded retries. |
33//! | `priority` | **Supported,** as Linear's own `Issue.priority`: read on every issue, written by `issueCreate`/`issueUpdate` through `IssueCreateInput.priority`/`IssueUpdateInput.priority`, and set on its own by an `issueUpdate` carrying nothing else. See the ruling below on the scale. |
34//! | `filter_by_priority` | **Supported and proven.** `issues(filter:{priority:{in:[…]}})` over Linear's own `0`–`4` scale, confirmed against each issue read. |
35//! | `filter_by_comment_activity` | **Supported and proven.** `comments:{some:{or:[{createdAt:{gte:…}},{updatedAt:{gte:…}}]}}` — the issues with a comment created or last edited at or after the instant, over the same two fields a comment read reports. |
36//! | `filter_by_metadata` | **Supported and proven.** `description:{contains:"\"<value>\""}` for each match — the value as every JSON encoder writes it, which a slot holding it contains however it spaces or spells its keys — and every candidate confirmed over the parsed slot, so prose carrying the phrase and a slot holding another value are both kept out. A value with a character an encoder may escape is not sent, and the confirmation decides alone. |
37//! | `filter_by_origin` | **Supported and proven,** on the same terms, for the slot's `onetaskgraph.origin`. |
38//! | `reconcile_status` | **Unsupported — unsupportable.** A Linear issue's workflow state *is* its closure: there is no card beside it to contradict it, so nothing could need reconciling and the source is sent nothing. |
39//! | `complete_by_issue` | **Unsupported — unsupportable** here: a Linear issue is not a GitHub issue a landing's `closes` names, and its state is its closure, so a completion lists this source unsupported and sends it nothing. |
40//! | `orphan_tasks` | **Supported and proven.** `issues(filter:{project:{null:true}})`. |
41//! | `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`. |
42//! | `filter_by_status` | **Supported and proven,** and spelled twice. An issue narrows by its workflow state's name, `state:{name:{eqIgnoreCase:…}}`; a project by its project status's name, `status:{name:{eqIgnoreCase:…}}` — a different member of a different filter over a different vocabulary — each by the names its kind's half of `status_mapping` gives, and `unknown` by every name that half does not give. See the ruling below. |
43//! | `search_title` | **Supported and proven.** A task query's text is `title:{containsIgnoreCase:…}`, every candidate confirmed by the contract's case-insensitive substring rule; a project or document query's text is applied by that same rule over the page Linear answered. |
44//! | `search_content` | **Supported and proven,** on the same terms, `description:{containsIgnoreCase:…}`, confirmed over the visible content — the trailing metadata slot Linear's comparator also reads is not part of what the rule confirms. A `title-or-content` search sends the two under one `or`. |
45//! | `task_dependencies` | **Supported and proven,** in both directions: `relations` and `inverseRelations`. |
46//! | `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. |
47//! | `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`]. |
48//!
49//! ## Ruling: the follow-up searches are native, and what each rests on
50//!
51//! Six predicates a follow-up search sends — metadata, origin, comment activity, priority,
52//! and the two text searches — are each sent to Linear as a narrowing of `issues(filter:)`
53//! and confirmed in process before a row is returned. Each narrowing is a candidate set that
54//! cannot miss a row the contract's predicate keeps, which is what makes sending it sound;
55//! the confirmation is what makes the answer exact. Every member below is pinned in
56//! `tests/fixtures/schema.graphql`, and each rests on one observation of the real API,
57//! against the scratch team `TES` on 2026-10-02, which `drive_follow_ups` in `tests/live.rs`
58//! asserts again — the comparators by name, and every narrowing through this source with a
59//! decoy whose prose carries the searched phrases:
60//!
61//! - **`IssueFilter.description.contains` reads the whole stored description, the metadata
62//! slot included, and is case-sensitive.** An issue whose slot held
63//! `"caller.key":"needle-…"` was returned for `contains` of that exact phrase, and of the
64//! `"onetaskgraph.origin":"…"` pair beside it; the same description's prose, upper-cased,
65//! was returned for `containsIgnoreCase` and *not* for `contains`. So a metadata match or an
66//! origin is sent as its value in quotes, `"<value>"` — the bytes any JSON encoder writes a
67//! string as, which a slot holding it contains whether it is the code span this source
68//! writes, the multi-line slot it wrote before, or one a person spaced by hand — and
69//! confirmed over the parsed slot. A value holding a character an encoder may escape is not
70//! sent, and the confirmation decides alone.
71//! - **`IssueFilter.title.containsIgnoreCase` and `description.containsIgnoreCase` match
72//! regardless of case** — the title `… Alpha Title` was returned for `alpha TITLE`, and not
73//! for `contains` of it. They are the two text searches; the content search is confirmed
74//! over the visible content, because the comparator also reads the slot.
75//! - **`IssueFilter.priority.in` narrows by Linear's own number** — an issue at `2` was
76//! returned for `in:[2]` and not for `in:[3]`.
77//! - **`IssueFilter.comments.some` with `createdAt`/`updatedAt` `gte` narrows by a comment's
78//! own times** — an issue whose comment had been edited a moment earlier was returned for an
79//! instant before the edit and not for one a day later, and editing the comment moved its
80//! `updatedAt` while leaving `createdAt`. The comment read reports those same two fields,
81//! so the filter and the contract's rule read one value.
82//!
83//! ## Ruling: what Linear does to an HTML comment, settled
84//!
85//! A follow-up tool marks what it writes with HTML comments, and this source keeps its own
86//! metadata in one, so what survives is a fact this crate records rather than assumes.
87//! Observed on 2026-10-02 against the scratch team `TES`, each written and read back by id,
88//! and asserted again — the probe text and its stored form exactly — by `drive_follow_ups` in
89//! `tests/live.rs`, a leg of its `real_linear_applies_every_declared_capability_and_leaves_no_residue`:
90//!
91//! - **A comment's `body` keeps every HTML comment byte for byte** — on one line or across
92//! several, a bare `-->` closing line, domain-like text and JSON included.
93//! - **An issue's `description` and a document's `content` do not.** Linear stores both as
94//! Markdown and normalizes the text *inside* an HTML comment exactly as it normalizes prose:
95//! a domain-like token or a URL is autolinked — `example.com` comes back
96//! `[example.com](<http://example.com>)`, and a key such as `caller.live` likewise — `[` and
97//! `]` come back `\[` and `\]`, `~` comes back `\~`, `_y_` comes back `*y*`, the backslash
98//! of `\"` is dropped, a backslash before a letter is doubled, and a line opening `-->` comes
99//! back `\-->`. Text with none of those in it — `{"k":"v"}`, `caller.key`, `sha256:…`,
100//! `gh:I_kwDO…` — comes back as written. An autolink can run on past the token, swallowing
101//! what follows it up to the next delimiter.
102//! - **One thing in those two fields comes back byte for byte: a code span.** An HTML comment
103//! on one line whose payload is inside backticks — ``<!-- probe `{…}` -->`` — came back
104//! identical with every one of the payloads above inside it, and re-writing what Linear
105//! handed back changed nothing more. So this source writes its own slot that way (see
106//! `METADATA_OPEN_SPAN`), and a marker meant to survive an issue's description or a
107//! document's content belongs in one too.
108//!
109//! ## Ruling: what Linear does to Markdown on write, declared
110//!
111//! Linear stores an issue's `description`, a project's `content` and a document's `content` as
112//! Markdown it normalizes on write, so the bytes a read hands back are not the bytes a write
113//! sent. A rendering's `onetaskgraph.template` `body_digest` is the digest of its content, so
114//! a store recording it over what it sent records a digest no read of the item matches. This
115//! source therefore **declares** the rewrite, as [`TaskSource::stored_form`] — the one function
116//! [`stored_form::stored_form`] — and the engine applies it before a copy, a create or a render
117//! sends the content: what it sends is already what Linear keeps, and the digest it records is
118//! of that. Each of the eight writes that carry the long form — [`graphql::ISSUE_CREATE`],
119//! [`graphql::ISSUE_UPDATE`], [`graphql::ISSUE_REWRITE`], [`graphql::PROJECT_CREATE`],
120//! [`graphql::PROJECT_UPDATE`], [`graphql::PROJECT_REWRITE`], [`graphql::DOCUMENT_CREATE`] and
121//! [`graphql::DOCUMENT_UPDATE`] — selects what Linear stored in its own answer, with no request
122//! of its own, and [`TaskSource::stored_content`] hands it to the
123//! engine, which compares it with what it sent byte for byte and refuses the item as
124//! `stored-content-diverged` where they differ. **A difference is a rule missing from this
125//! section, and the fix is to add it here and to [`stored_form`] together.** Both halves hold
126//! whichever way the store loads this source: served over the stdio plugin protocol, the rewrite
127//! crosses as `stored_form` and the stored content as a write result's `stored_content`
128//! (`docs/plugin-protocol.md` §3.16, §4.25).
129//!
130//! Every rule applies outside a fenced block — from a line of three or more backticks or tildes,
131//! indented at most three spaces, to the line closing it with at least as many of the same — and
132//! outside an inline code span opened and closed on one line; both are kept byte for byte, which
133//! is what the HTML-comment ruling above observed of a code span. A Markdown link or image,
134//! `[text](target)` or ``, is kept as written too, which is why rewriting Linear's
135//! own output changes nothing more. A relative image reference goes on through the asset upload
136//! ([`assets`]), which points it at its `uploads.linear.app` URL before the write.
137//!
138//! | Rule | Written | Stored |
139//! | --- | --- | --- |
140//! | A line opening `- `, indented or not, is a `* ` bullet | ``- one\n - two`` | ``* one\n * two`` |
141//! | A line after a list item that opens no block of its own — no bullet, fence, lone image, heading, quotation, HTML block or `1. ` item — runs on from the item, indented under its text, and is prose rather than a table | ``- bullet\n\| a \| b \|\n\| --- \| --- \|`` | ``* bullet\n \| a \| b \|\n \| --- \| --- \|`` |
142//! | A table's delimiter row outside a list item is one `--` a cell, its alignment colons kept, one space beside every pipe | ``\| --- \|:---:\|`` | ``\| -- \| :--: \|\n`` |
143//! | A URL opening `http://` or `https://` is linked to itself, trailing punctuation outside the link | ``see https://example.com/a.`` | ``see [https://example.com/a](<https://example.com/a>).`` |
144//! | A domain-like token under one of [`stored_form::AUTOLINKED_TOP_LEVEL_DOMAINS`], not following a letter, a digit or one of `@ / . - _ : \ # = + %`, is linked to `http://` and itself | ``example.com and "caller.live"`` | ``[example.com](<http://example.com>) and "[caller.live](<http://caller.live>)"`` |
145//! | A `[` that opens no link, and every `]`, are escaped | ``[x] done`` | ``\[x\] done`` |
146//! | A `~` is escaped | ``~5 days`` | ``\~5 days`` |
147//! | A single-underscore emphasis is written with asterisks; an underscore inside a word is not one | ``_y_ and snake_case`` | ``*y* and snake_case`` |
148//! | The backslash of `\"` is dropped | ``say \"hi\"`` | ``say "hi"`` |
149//! | A backslash before a letter is doubled | ``a \d b`` | ``a \\d b`` |
150//! | A line opening `-->` is escaped | ``-->`` | ``\-->`` |
151//! | A fenced block is kept byte for byte, and set apart by a blank line from the line before it and the line after it | `` see\n```\n- [x] _y_ example.com ~\n```\n- z `` | `` see\n\n```\n- [x] _y_ example.com ~\n```\n\n* z `` |
152//! | An image alone on a line is a block of its own, set apart by a blank line from the lines beside it | ``\n`` | ``\n\n`` |
153//! | An inline code span is kept byte for byte | ``a `- [x] _y_ example.com ~` b`` | ``a `- [x] _y_ example.com ~` b`` |
154//! | A run of blank lines is one blank line | ``a\n\n\n\nb`` | ``a\n\nb`` |
155//! | The content ends at its last line holding anything: the line breaks and blank lines after it are not kept | ``\n\n`` | ```` |
156//! | A table ending the content is followed by its last row's line break, and nothing more | ``\| a \|\n\| -- \|\n\n`` | ``\| a \|\n\| -- \|\n`` |
157//!
158//! The three block rules — a list item's lines running on under it, a fence set apart, a lone
159//! image set apart — are what real Linear did in CI run 39361674659, and Linear's own answer is
160//! their evidence. The live probe below came back with every line after `* bullet` indented two
161//! spaces under it (so its `| --- |` row was never a table's, and was kept), and with a blank line
162//! before its fence and another after it. The asset journey wrote two images on adjacent lines,
163//! `` then ``, and Linear's answer kept the first and held an empty line
164//! second. That is declared as the blank line setting the second image apart, the reading the
165//! fence's blank lines in the same run make of a block Linear holds on its own. Two other
166//! readings answer the same at that line — Linear keeping both images but its answer to the write
167//! leaving the second out, and Linear dropping it — so the engine never takes an answer lacking,
168//! or blanking, a line it sent as an image at its word: it reads the item once and compares that
169//! read instead, accepting the first and refusing the second as `stored-content-diverged` naming
170//! the image line, because no rule here explains an image's removal.
171//! `a_copy_never_records_a_digest_over_stored_content_missing_an_image_it_sent` in the
172//! `onetaskgraph-linear-e2e` suite drives all three offline; the asset journey against Linear
173//! itself is what settles which one Linear takes. The escapes, the
174//! angle-bracketed link targets, the autolinked domains and `*y*` came back in that run exactly
175//! as the rows above declare them.
176//!
177//! The last three rows are what real Linear did in CI run 39375395042. The asset journey wrote
178//! its two images with a line break after the second, and this source sets its metadata slot one
179//! blank line after the content, so the description held two blank lines before the slot; Linear
180//! handed back one, and the content above it ended at the second image. And the live probe, which
181//! ends in a table and carries no slot, came back with one line break after the table's last row,
182//! where every other block Linear ends a field on — a paragraph, an image, a fence — it ends with
183//! none. That line break is part of what Linear stores, so it is part of the stored form, and a
184//! read hands it back. With this source's metadata slot after the table, Linear holds the table's
185//! line break and then the one blank line setting the slot apart, so a read takes that blank line
186//! as the slot's and the line break as the content's.
187//!
188//! Each example is the rule's input and the stored form it produces, and
189//! `every_example_the_ruling_states_is_what_the_rewrite_stores` reads this table and fails when
190//! [`stored_form`] stores anything else. The HTML-comment probe above is held to it as well:
191//! [`stored_form`] of that probe is exactly what Linear handed back on 2026-10-02. The comparison
192//! against Linear itself is `drive_follow_ups` in `tests/live.rs`, a leg of
193//! `real_linear_applies_every_declared_capability_and_leaves_no_residue`, which writes a probe
194//! holding one instance of each rule to an issue's description and a document's content and
195//! asserts its stored form exactly — every rule but the lone image, whose comparison against
196//! Linear is `live_disposable_issue_and_document_assets_copy_and_recopy` in the
197//! `onetaskgraph-linear-e2e` suite, because only an upload gives it a target Linear keeps. Text none of these rules names — `**bold**`, `1. one`,
198//! `snake_case`, `README.md`, `caller.key` — comes back as written.
199//!
200//! ## Ruling: a Linear document carries no label, and that is Linear's
201//!
202//! Unlike the two searches above, this one *is* a property of the remote service. The
203//! types of Linear's published schema carrying a `labels` field are `Issue`, `Project`,
204//! `Team`, `Initiative` and `Organization`; `Document` is not among them, re-observed
205//! 2026-09-01 and pinned in `tests/fixtures/schema.graphql`. So this source reports a
206//! document's labels as none and **refuses by name** a document write carrying one, rather
207//! than dropping it or standing a slot up beside a first-class type. The shared journey
208//! table's row says so, and the shared document journeys drive that claim.
209//!
210//! Two predicates therefore reach a fetched page rather than the `documents(filter:)`
211//! variables, and both are still *applied* — which is what `Native` means here, and why
212//! the declaration stays honest. Labels, for the reason above. And orphans, because
213//! `DocumentFilter.project` is a `ProjectFilter` where `IssueFilter.project` is a
214//! `NullableProjectFilter`: only the nullable one carries `null:`, so Linear cannot be
215//! asked for the documents belonging to no project. The page-by-page walk asks for only
216//! what is still owed, so neither predicate can make a read return more than the caller
217//! asked for, and neither can drop a document the walk already fetched.
218//!
219//! ## Ruling: a comment is read backwards, and its author is Linear's to record
220//!
221//! **The order.** The contract owes a task's comments oldest first, across pages, and Linear's
222//! `Issue.comments` takes no sort direction — only `orderBy`, whose members are `createdAt`
223//! (the default) and `updatedAt`. Linear's pagination documentation says results are "ordered
224//! by `createdAt`" and that "to get most recently updated resources, you can alternatively
225//! order by `updatedAt`", which reads that ordering as newest first. So this source walks the
226//! connection from its far end: `last` with `before`, each page reversed, the next page's
227//! cursor being `startCursor` while `hasPreviousPage` holds. Reversing within a page and
228//! walking backwards across them is what makes the whole walk oldest first rather than each
229//! page alone. **That direction is inferred from the documentation's wording rather than
230//! observed against the real API,** which is the one reading here a live run has not yet
231//! confirmed; if Linear is found to list oldest first, the correction is this walk's
232//! direction and nothing else.
233//!
234//! **The author.** Linear records the user whose credential made the request as a comment's
235//! author, and this source authenticates with an API key. `CommentCreateInput.createAsUser`
236//! exists but is, in Linear's own words, "only available to OAuth applications creating
237//! comments in `actor=app` mode", which a key is not. So a comment carrying an author is
238//! **refused before any request is sent**, naming why and what to do instead, rather than
239//! posted under a name other than the one it was given. An author read back is the user's
240//! `displayName`, which Linear keeps unique within a workspace, and is absent when Linear
241//! names no user — a comment an integration or a bot wrote.
242//!
243//! **What "no such comment" means.** An edit or a removal first asks `comment(id:)` which
244//! issue the comment is on, and answers "no such comment" — no mutation sent — unless it is
245//! the task's own issue: a comment on another issue, on no issue at all, or trashed, is not a
246//! comment this task has. The body is Linear's `body`, which its schema describes as markdown
247//! derived from a rich-text document, so what an add or an edit answers with is what Linear
248//! now holds rather than an echo of what was sent.
249//!
250//! ## Ruling: a project's filter is not an issue's, and neither is its status
251//!
252//! Linear's `IssueFilter` and `ProjectFilter` read as one filter over two kinds of row.
253//! They are two input types, and this source built one object for both until 2026-09-04,
254//! which put two members into `projects(filter:)` that Linear does not have there. It
255//! refused the first outright — `Field "team" is not defined by type "ProjectFilter". Did
256//! you mean "lead"?` — and would have refused the second next.
257//!
258//! A project has no team; it has the teams it is accessible from, so the configured team
259//! reaches `accessibleTeams:{some:{key:{eqIgnoreCase:…}}}`. And a project's status is not
260//! an issue's state: the counterpart of `IssueFilter.state` is `ProjectFilter.status`,
261//! while `ProjectFilter.state` exists and is a bare `StringComparator` over something else.
262//! The two do not even share a vocabulary — a project's statuses are the workspace's,
263//! `Idea`, `Proposal`, `Planned`, `Completed` among them, where an issue's states are
264//! the team's — which is why `status_mapping` names each kind's statuses separately, and why a
265//! filter spelled in the other level's names matches nothing while being refused by nothing.
266//!
267//! **Neither of those could be caught by reading a document, and that is the general
268//! lesson.** A filter is built at runtime and handed over as `$filter`, so it appears in no
269//! operation this crate declares, and the two pinned-schema checks that parse those
270//! operations could not see it — Linear was the only reader, one refusal per round trip.
271//! `every_variables_object_this_source_sends_conforms_to_the_pinned_schema` closes that:
272//! it drives this source's whole surface, records what really went out, and walks every
273//! variables object against the pinned type of the argument it stands at.
274//!
275//! ## Ruling: a Linear project relation is always an ordering
276//!
277//! This one is Linear's too, and the validator says so in as many words. Asked on
278//! 2026-09-04 for a project relation typed `related` — and separately `blocks` and
279//! `dependsOn` — the real API refused each with `Argument Validation Error` and
280//! `constraints: {"isEnum": "type must be one of the following values: dependency"}`. That
281//! enumeration has one member and it is a timeline dependency, which is why the input
282//! carries an anchor at each end at all.
283//!
284//! So a project edge carrying no ordering has nowhere here to land, and this source
285//! **refuses it by name** before the write rather than sending a value Linear will reject
286//! or quietly promoting it to a dependency it does not mean. `DependencyKind::Related`
287//! keeps its issue-level spelling, `related`, because `IssueRelationCreateInput` really
288//! does take it: the two relations are different relations with different vocabularies,
289//! and each level's read accepts only its own.
290//!
291//! Which end of a project relation waits is carried by the two anchors and not by the two
292//! id slots — measured, not reasoned, from Linear's own `ProjectFilter.hasBlockedByRelations`
293//! against relations written both ways round. `tests/fixtures/README.md` records the whole
294//! probe, and `write_relations` records why the pair this source sends is the oriented one.
295//!
296//! Caller metadata is canonical JSON in a trailing
297//! ``<!-- onetaskgraph.metadata `…` -->`` Markdown comment in the item's long form — an
298//! issue's `description`, a project's or a document's `content` — on one
299//! line with the JSON in a code span — the one spelling Linear keeps byte for byte, see the
300//! ruling above; the multi-line spelling this source wrote before is still read. The visible
301//! description is returned unchanged without that slot. Writes put the same canonical
302//! encoding back beside the visible description, and use Linear issue/project relations for
303//! same-source dependencies. Only cross-source far ends use the reserved
304//! `onetaskgraph.depends_on` metadata key.
305//!
306//! ## Ruling: a status is the name `status_mapping` gives the item's kind, and nothing else
307//!
308//! Linear has no built-in names: a team's workflow states and a workspace's project statuses
309//! are whatever the people who own them called them, and a type says nothing about which of
310//! several states of it a category means — `Todo` and `Queued` are both `unstarted`. So the
311//! source's `status_mapping` ([`StatusMapping`]) is the whole of what a status is written as
312//! and read by: a task's names are the configured team's workflow states, a project's the
313//! workspace's project statuses.
314//!
315//! **A write** of a category is the name the mapping gives the kind of the item being written —
316//! `set_task_status`, the targeted update and `write_task` for a task, so `task create` and every
317//! copy; `write_project` for a project, whose own status name plays no part. A write the mapping
318//! gives that kind no name for — a category it does not mention, one set to `null`, one a
319//! per-kind object leaves out — is refused before any request, naming the source, the kind, the
320//! category and the key to set; one whose name that kind's vocabulary does not hold is refused
321//! naming the name, after the resolution and before any mutation. Nothing falls back by type, or
322//! by the name a status was called where it came from, and a source with no mapping refuses
323//! every status write.
324//!
325//! So what a write leaves an item at is the mapped name, whatever the name it was given, and
326//! [`TaskSource::stored_status`] says so: a copy compares an item already in Linear with the
327//! status at that name, and an item whose category Linear already holds reads as unchanged.
328//!
329//! **A read** of an item at a name its kind's mapping gives is that category, under the name;
330//! every other name reads as `unknown`, under its own name, whatever its type — the review
331//! states only people write, `Triage` among them. `filter_by_status` returns exactly the items
332//! that read as each category asked for: those at the name the kind's mapping gives it, and for
333//! `unknown` every item at a name that mapping does not give at all. Each is confirmed in process
334//! as well, so a row reading as another category is never returned.
335//!
336//! ## Ruling: the resolution is read once per source instance, and a status write reads nothing
337//!
338//! A Linear key is shared by every manager of a host, so what a status write costs is counted at
339//! Linear's endpoint, and `crates/onetaskgraph-linear-e2e/budgets.yaml` holds it. A source resolves
340//! the configured team's id, its workflow states and the workspace's project statuses in one
341//! request — [`graphql::RESOLUTION`] — the first time a write or `sources fields` needs them, and
342//! holds the answer for its own lifetime: per instance, never per process, never shared between
343//! sources. Building a source sends nothing. A mapped name the held answer lacks is looked for
344//! once more in a fresh read, so one added in Linear since is found; nothing a failed call
345//! answered is held, and a write that fails while carrying a held id drops the answer, so the
346//! next reads afresh rather than sending that id again. A name `sources fields --apply` creates is
347//! added to what is held.
348//!
349//! `set_task_status`, and a targeted update naming a status and nothing else, are one
350//! `issueUpdate` and no read: [`graphql::ISSUE_UPDATE_READ`] selects the whole issue, which is
351//! what the answered status — and the engine keeping a delivered task in step — need. So writing
352//! the category an issue already reads as sends the same state again, setting `unknown` on an
353//! issue at a name the mapping does not give moves it to the mapped `unknown` name, and an issue
354//! Linear does not hold is no such task from the mutation's own refusal — `Entity not found` —
355//! rather than from a read. That spelling is the one Linear documents for its own input
356//! validation; a live run has not yet re-observed it here. A source scoped to one project is no
357//! exception: its status write goes to the issue it names wherever that issue is filed — see
358//! the scope's ruling below. A targeted update naming anything else
359//! keeps its one read of the issue: the metadata slot is merged into the description Linear
360//! holds, and writing it without that read would overwrite whatever a person wrote there since.
361//! A whole rewrite of an issue or a project reads the relations it replaces in its own answer
362//! ([`graphql::ISSUE_REWRITE`], [`graphql::PROJECT_REWRITE`]) rather than in a read of its own.
363//!
364//! ## Ruling: `sources fields --apply` creates every name the mapping needs
365//!
366//! For parity with a GitHub Projects board, whose `--apply` adds the `Status` options it lacks:
367//! `--apply` creates each name the mapping gives a task that the team lacks, as a workflow state
368//! (`workflowStateCreate`), and each name it gives a project that the workspace lacks, as a
369//! project status (`projectStatusCreate`) — a bare name in both. The type of what it creates is
370//! its category's, by this fixed table:
371//!
372//! | Category | Workflow state | Project status |
373//! | --- | --- | --- |
374//! | `backlog`, `draft` | `backlog` | `backlog` |
375//! | `todo`, `queued` | `unstarted` | `planned` |
376//! | `in-progress`, `unknown` | `started` | `started` |
377//! | `done` | `completed` | `completed` |
378//! | `cancelled` | `canceled` | `canceled` |
379//!
380//! Both create inputs also require a colour, and a project status a place in the workspace's
381//! flow, and nothing in a mapping says either: every name is created in Linear's neutral grey,
382//! `#95a2b3`, and a project status after the workspace's last. Nothing that exists is renamed, retyped or deleted, and a name present
383//! under another type is reported with its type and left as it is. A create Linear refuses stops
384//! the run, and the report names what it created before it. Whether to run it against a
385//! workspace is the operator's decision.
386//!
387//! ## Ruling: `project` scopes a source to one project
388//!
389//! With `project` set, every issue read carries `project:{id:{eq:…}}` beside the team, a
390//! project read carries `id:{eq:…}` and a document read the same project, and a read by id
391//! of anything filed elsewhere answers as no such item — so a content, a metadata or a comment
392//! write to it, each of which reads the item first, is answered the same way.
393//!
394//! **A status-only write is the exception, and goes to the item it names wherever that item is
395//! filed.** `task status set`, and a targeted update naming a status and nothing else, are one
396//! `issueUpdate` to the issue named, with no read before it — so a scoped source meets the same
397//! request budgets as an unscoped one — and answer the issue as it now reads, even when it is
398//! filed in another project of the team. Linear has no update conditional on where an issue is
399//! filed, so holding a status write to the scope would cost the read the budget refuses; the
400//! item was named outright, and the scope is what this source reads, lists and creates rather
401//! than where a status it is asked to set may land. Reads, listings and creation stay scoped: a
402//! task or a document written with no project is placed in that one, and one naming another is
403//! refused naming both. A project write other than to that project itself is refused before any
404//! request: a project this source created would be one none of its reads could find.
405//!
406//! ## Ruling: a narrow metadata write moves only the slot, and a task carries delivery
407//!
408//! `set_task_metadata`, `set_project_metadata` and `set_document_metadata` read the item and
409//! send one update of its long-form field — `description`, `description` and `content` —
410//! that differs from what Linear holds only inside the trailing metadata slot: every byte
411//! above it is kept as it was. A key already holding the value sends nothing. The answer is
412//! the item read back. `set_task_rendering` and `set_document_rendering` replace the content
413//! and the slot's `onetaskgraph.template` entry together in one such update, every other slot
414//! entry kept; this source keeps no template answers. So a copy's `onetaskgraph.copies` link
415//! is recorded on a Linear item rather than reported unrecorded.
416//!
417//! A task's `delivers` and `delivered_by` live in that same slot under
418//! `onetaskgraph.delivers` and `onetaskgraph.delivered_by`, each a list of qualified ids,
419//! with the shape and the rules the GitHub Projects plugin keeps: neither may name the task
420//! itself or name one task twice, which is refused by name before anything is sent; a write
421//! lands the typed lists in place of any caller metadata of those names; and
422//! `set_delivered_by` is one update of the slot. A project or a document naming either key is
423//! refused, because only a task delivers or is delivered.
424//!
425//! ## Ruling: a priority is Linear's own, and content shares a field with the slot
426//!
427//! A task's priority is `Issue.priority`, on Linear's scale: `0` none, `1` urgent, `2` high,
428//! `3` normal — this contract's `medium` — and `4` low. Linear declares the field `Float!`
429//! while `IssueCreateInput.priority` and `IssueUpdateInput.priority` are `Int`, so a read
430//! accepts `2` and `2.0` alike and refuses anything that is not one of the five as a
431//! malformed response naming the field. A copy sends it on a create and on an update, `0`
432//! included, so a task moved back to no priority is not left holding its old one.
433//! `set_task_priority` reads the issue first — no such issue, or a trashed one, is `None`
434//! with nothing written — then sends `issueUpdate` with `priority` alone and answers with
435//! the priority the mutation's own payload reports.
436//!
437//! `set_task_content` sends `issueUpdate` with `description` alone, and that description is
438//! the given content followed by the issue's metadata slot exactly as it was stored, so the
439//! slot, and every key in it, is untouched. What a later read reports as the content is the
440//! given bytes, trailing whitespace included: a read of an issue carrying a slot takes off only
441//! the one blank line that sets the slot off, and a write whose content would not read back as
442//! itself is refused before it is sent.
443//!
444//! Fixture provenance is recorded in `tests/fixtures/README.md`. The live journey in
445//! `tests/live.rs` drives every field of the table above against Linear itself: it builds its own fixture
446//! on the scratch team `LINEAR_WRITE_TEAM` names — two projects, one issue filed under
447//! each, one filed under neither, two labels and two workflow states — because that shape
448//! is what tells an honoured predicate from an ignored one, and a workspace where every
449//! issue carries the label answers a filter the same way either way. Everything the lane
450//! creates it deletes whether its assertions passed or failed, and it clears residue named
451//! the way it names its own before it starts. A failed live cleanup is reported as a test
452//! failure and may require manual deletion from that scratch team.
453#![deny(missing_docs)]
454
455/// The image upload mechanism and its bounded verification rule.
456pub mod assets;
457/// What Linear stores when it is written Markdown, as the ruling above declares it.
458pub mod stored_form;
459
460use chrono::{DateTime, Utc};
461use onetaskgraph_plugin_api::{
462 Capabilities, Classification, Comment, CommentBody, CommentOrigin, Cursor, DependencyEdge,
463 DependencyEndpoint, DependencyKind, DependencySupport, Designator, Direction, Document,
464 DocumentQuery, Health, ItemKind, ItemWrite, Label, LabelFilter, Location, MetadataKey,
465 MetadataRecord, NativeId, NewComment, Page, PageRequest, Priority, Project, ProjectFilter,
466 ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin, Status,
467 StatusCategory, StatusMapping, StatusName, Support, Task, TaskDetailRead, TaskQuery, TaskRef,
468 TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
469};
470use schemars::{Schema, schema_for};
471use secrecy::{ExposeSecret, SecretString};
472use serde::Deserialize;
473use serde_json::{Value, json};
474
475pub mod change_token;
476
477/// The plugin kind a `linear` source's `plugin:` field names.
478pub const KIND: &str = "linear";
479
480/// The largest page this source will ask Linear for, and the capability it declares.
481///
482/// **Not Linear's connection maximum, which is 250, because a connection maximum is not
483/// the only thing bounding a page.** Linear also scores each document for complexity and
484/// refuses one over 10000 with HTTP 400 and `The query is too complex.` — and the
485/// `projects` document this source sends scores 17475 at `first: 250`, because its nested
486/// `labels` connection, which names no `first` of its own, is charged Linear's default of
487/// 50 per node. Measured against the real API on 2026-09-04: the largest `first` that
488/// document is accepted at is **143**, exactly, and the filter it carries adds nothing.
489/// The `issues` document is accepted at 250, so this is the tighter of the two and a
490/// single declared maximum has to be the tighter one.
491///
492/// 100 rather than 143 because 143 is the cliff. A field added to either selection moves
493/// it, and a page size chosen at the edge of a budget nobody here controls fails in the
494/// live lane rather than in a check. This leaves 30% of the budget spare.
495///
496/// Nothing offline can hold this: complexity is scored by Linear's own runtime and appears
497/// in no schema, so `every_variables_object_this_source_sends_conforms_to_the_pinned_schema`
498/// cannot see it. What guards it is the live journey, which walks a real `projects` page at
499/// exactly this size.
500pub const MAX_PAGE_SIZE: u32 = 100;
501
502/// The rows a projects listing asks for at most while the store's cache stands in front of this
503/// source.
504///
505/// Linear prices `projects` by the rows a request asks for: 70 a row plain, 95 with the relations
506/// page a project's token is read from (`spike-linear-coverage`, about 9,491 at 100 rows), and,
507/// taking the inverse relations page at the relations page's 25, 120 with both. Seventy-four rows
508/// price at 8,880, under the `linear-cache-largest-query-complexity` guard of 9,000; a listing of
509/// more than seventy-four projects takes another request a page, which the hour budgets count.
510pub const PROJECTS_CACHED_PAGE: u32 = 74;
511const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
512
513/// Exact GraphQL query documents issued by this plugin.
514///
515/// Fixture servers consume these constants so their recognized contract cannot drift
516/// from the production requests.
517pub mod graphql {
518 /// Request Linear's signed upload URL, private asset URL, and required PUT headers.
519 pub const FILE_UPLOAD: &str = "mutation AssetUpload($contentType:String!,$filename:String!,$size:Int!){fileUpload(contentType:$contentType,filename:$filename,size:$size){success uploadFile{uploadUrl assetUrl headers{key value}}}}";
520
521 /// Linear's missing-issue error, reconciled with the service by the live status journey.
522 pub const ISSUE_NOT_FOUND_MESSAGE: &str = "Entity not found: Issue";
523 /// Its user-facing alternative; the loopback fixture shares this contract.
524 pub const ISSUE_NOT_FOUND_PRESENTABLE: &str = "Could not find referenced Issue.";
525 /// The same error for a project a mutation addresses: Linear spells every missing entity
526 /// `Entity not found: <type>`. Reconciled with the service by the live capability journey,
527 /// which sends a project status write to a nonexistent project and requires `None`; the
528 /// loopback fixture shares this contract.
529 pub const PROJECT_NOT_FOUND_MESSAGE: &str = "Entity not found: Project";
530 /// Its user-facing alternative, on the terms of [`ISSUE_NOT_FOUND_PRESENTABLE`].
531 pub const PROJECT_NOT_FOUND_PRESENTABLE: &str = "Could not find referenced Project.";
532
533 /// Check the authenticated viewer.
534 pub const VIEWER: &str = "query { viewer { id } }";
535 /// The workspace's own id, which this source's designator digests.
536 pub const ORGANIZATION: &str = "query { organization { id } }";
537 /// Fetch one issue.
538 pub const ISSUE: &str = "query($id:String!){ issue(id:$id){ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} } }";
539 /// Fetch one project.
540 pub const PROJECT: &str = "query($id:String!){ project(id:$id){ id name content url createdAt updatedAt archivedAt status{name type} labels{nodes{id name color}} } }";
541 /// List issues.
542 pub const ISSUES: &str = "query($first:Int!,$after:String,$filter:IssueFilter){ issues(first:$first,after:$after,filter:$filter){ nodes{id identifier title description url createdAt updatedAt state{name type} priority labels{nodes{id name color}} project{id}} pageInfo{hasNextPage endCursor} } }";
543 /// List projects.
544 pub const PROJECTS: &str = "query($first:Int!,$after:String,$filter:ProjectFilter){ projects(first:$first,after:$after,filter:$filter){ nodes{id name content url createdAt updatedAt status{name type} labels{nodes{id name color}}} pageInfo{hasNextPage endCursor} } }";
545 /// List issue labels.
546 pub const LABELS: &str = "query($first:Int,$after:String){ issueLabels(first:$first,after:$after){ nodes{id name color} pageInfo{hasNextPage endCursor} } }";
547 /// Fetch issue dependency relations.
548 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}} } }";
549 /// Fetch project dependency relations.
550 pub const PROJECT_RELATIONS: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ content 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}} } }";
551 /// What one issue depends on: [`ISSUE_RELATIONS`] without its `inverseRelations`, so a
552 /// dependency read asks Linear for the one direction it answers. The `description` rides
553 /// along because its metadata slot is where an edge to another source is recorded, so
554 /// those edges cost no second request.
555 pub const ISSUE_DEPENDS_ON: &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}} } }";
556 /// What depends on one issue: [`ISSUE_RELATIONS`] with its `inverseRelations` alone. No
557 /// `description`: the reverse of a recorded edge is derived from the far end, never
558 /// written down on the near item.
559 pub const ISSUE_DEPENDED_ON_BY: &str = "query($id:String!,$first:Int!,$after:String){ issue(id:$id){ inverseRelations(first:$first,after:$after){nodes{id type issue{id}} pageInfo{hasNextPage endCursor}} } }";
560 /// What one project depends on, on the terms of [`ISSUE_DEPENDS_ON`].
561 pub const PROJECT_DEPENDS_ON: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ content relations(first:$first,after:$after){nodes{id type relatedProject{id}} pageInfo{hasNextPage endCursor}} } }";
562 /// What depends on one project, on the terms of [`ISSUE_DEPENDED_ON_BY`].
563 pub const PROJECT_DEPENDED_ON_BY: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ inverseRelations(first:$first,after:$after){nodes{id type project{id}} pageInfo{hasNextPage endCursor}} } }";
564 /// Everything a status write resolves, in one request: the configured team's id, that
565 /// team's workflow states, and the workspace's project statuses, each with its type.
566 ///
567 /// One document rather than three lookups, because a status write's cost is counted at
568 /// Linear's endpoint on a key every manager of a workspace shares: a source sends this once
569 /// and holds the answer for its own lifetime (see the ruling on the resolution cache in this
570 /// crate's module documentation). `Team.states` and `Query.projectStatuses` are each read
571 /// as one page of Linear's connection maximum, 250, and refused rather than read short when
572 /// either says it has more — a team holds a few dozen states at most, and a workspace a
573 /// few dozen project statuses, so a resolution that does not fit one page is not one this
574 /// source guesses at — and `projectStatuses` takes no `filter`: Linear refuses one outright with `Unknown argument "filter" on field
575 /// "Query.projectStatuses"`, which is why a project status is matched by name here, locally.
576 /// `position` is read for one reason: a project status `sources fields --apply` creates is
577 /// placed after the workspace's last.
578 ///
579 /// `teams` is read at `first:2` and never at Linear's default page of 50, because Linear
580 /// scores a connection's selection once per node its page may hold: at the default, the 250
581 /// states asked of each of fifty possible teams scored this document 30905 against Linear's
582 /// limit of 10000, and the live journey's first status write was refused `Query too complex`.
583 /// Two rather than one so a key matching more than one team is still seen, and refused, by
584 /// the exactly-one rule that reads this answer. `states` and `projectStatuses` stay at 250,
585 /// whole or refused, as above: a name a page cut off would read as missing.
586 ///
587 /// Under Linear's documented model — a property 0.1, an object 1, and a connection's
588 /// children multiplied by its `first`, or 50 without one — the refused document scores
589 /// 50 × (1 + 0.1 + 250 × (1.3 + 1.1)) + 250 × (1.4 + 1.1) = 30680, and this one
590 /// 2 × 601.1 + 625 = 1827.2. Linear's own figures run 225 above the model on both
591 /// documents it has reported here (30905 for the refused one, 17475 for [`PROJECTS`] at
592 /// 250), and one point more for every node of every connection at every depth — 752 here —
593 /// still leaves this under 2580, about a quarter of the limit. Like [`super::MAX_PAGE_SIZE`],
594 /// nothing offline can hold this — complexity appears in no schema — and the live journey
595 /// is what guards it.
596 ///
597 /// `organization{id}` is the workspace this credential belongs to, read in the same request
598 /// rather than in one of its own: it is what a source declared private is verified
599 /// against, once per instance, and a cold write already sends this. It adds one object
600 /// and one property — 1.1 points by the model above — to the document's score.
601 pub const RESOLUTION: &str = "query($key:String!){ organization{id} teams(first:2,filter:{key:{eqIgnoreCase:$key}}){nodes{id states(first:250){nodes{id name type} pageInfo{hasNextPage}}}} projectStatuses(first:250){nodes{id name type position} pageInfo{hasNextPage}} }";
602 /// [`ISSUE`] and [`RESOLUTION`] in one request: what an update naming a status reads its
603 /// issue with while this instance holds no resolution yet, so a fresh source's settlement
604 /// costs its read and its mutation and no lookup between them.
605 pub const ISSUE_RESOLVED: &str = "query($id:String!,$key:String!){ issue(id:$id){ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} } organization{id} teams(first:2,filter:{key:{eqIgnoreCase:$key}}){nodes{id states(first:250){nodes{id name type} pageInfo{hasNextPage}}}} projectStatuses(first:250){nodes{id name type position} pageInfo{hasNextPage}} }";
606 /// Create a workflow state on the configured team, for `sources fields --apply`.
607 pub const WORKFLOW_STATE_CREATE: &str = "mutation($input:WorkflowStateCreateInput!){ workflowStateCreate(input:$input){success workflowState{id name type}} }";
608 /// Create a workspace project status, for `sources fields --apply`.
609 pub const PROJECT_STATUS_CREATE: &str = "mutation($input:ProjectStatusCreateInput!){ projectStatusCreate(input:$input){success status{id name type position}} }";
610 /// Resolve an issue-label display name.
611 pub const ISSUE_LABEL: &str =
612 "query($name:String!){ issueLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
613 /// Resolve a project-label display name.
614 pub const PROJECT_LABEL: &str =
615 "query($name:String!){ projectLabels(filter:{name:{eqIgnoreCase:$name}}){nodes{id}} }";
616 /// Create an issue.
617 pub const ISSUE_CREATE: &str = "mutation($input:IssueCreateInput!){ issueCreate(input:$input){success issue{id description}} }";
618 /// Update an issue.
619 pub const ISSUE_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id description}} }";
620 /// Update an issue and read back, in the same request, everything a task is read as.
621 ///
622 /// What a status write and a targeted update answer with: the selection is [`ISSUE`]'s, so
623 /// the task they report — its status, and the `delivers` the engine keeps in step with it —
624 /// is what Linear holds after the write, with no read before it and none after.
625 pub const ISSUE_UPDATE_READ: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} }} }";
626 /// Set an issue's priority on its own, and read back the priority Linear now holds.
627 ///
628 /// The same `issueUpdate` as [`ISSUE_UPDATE`], selecting `priority` in the payload
629 /// because a narrow priority write answers with what the source reads back rather than
630 /// an echo of what it sent. A document of its own rather than a wider [`ISSUE_UPDATE`],
631 /// so every other issue write keeps asking for exactly what it reads.
632 pub const ISSUE_PRIORITY_UPDATE: &str = "mutation($id:String!,$input:IssueUpdateInput!){ issueUpdate(id:$id,input:$input){success issue{id priority}} }";
633 /// Rewrite an issue whole, and read back in the same request the first page of the
634 /// relations it holds — the ones a whole write replaces.
635 ///
636 /// A whole write of an existing item replaces every relation it holds with the ones it was
637 /// given, so it has to know which it holds; selected here, that is the update's own answer
638 /// rather than a read of its own, and a re-write with no relations to replace is the one
639 /// request. The relations are read after the update, which moves none of them.
640 pub const ISSUE_REWRITE: &str = "mutation($id:String!,$input:IssueUpdateInput!,$first:Int!){ issueUpdate(id:$id,input:$input){success issue{id description relations(first:$first){nodes{id type relatedIssue{id}} pageInfo{hasNextPage endCursor}}}} }";
641 /// Rewrite a project whole, and read back the first page of its relations, on the terms of
642 /// [`ISSUE_REWRITE`].
643 pub const PROJECT_REWRITE: &str = "mutation($id:String!,$input:ProjectUpdateInput!,$first:Int!){ projectUpdate(id:$id,input:$input){success project{id content relations(first:$first){nodes{id type relatedProject{id}} pageInfo{hasNextPage endCursor}}}} }";
644 /// Create a project.
645 pub const PROJECT_CREATE: &str = "mutation($input:ProjectCreateInput!){ projectCreate(input:$input){success project{id content}} }";
646 /// Update a project.
647 pub const PROJECT_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id content}} }";
648 /// Set one project's status alone — its input carries `statusId` and nothing else — and
649 /// read back the status it then holds.
650 pub const PROJECT_STATUS_UPDATE: &str = "mutation($id:String!,$input:ProjectUpdateInput!){ projectUpdate(id:$id,input:$input){success project{id status{name type}}} }";
651 /// Create a native issue dependency.
652 pub const ISSUE_RELATION_CREATE: &str = "mutation($input:IssueRelationCreateInput!){ issueRelationCreate(input:$input){success issueRelation{id}} }";
653 /// Create a native project dependency.
654 pub const PROJECT_RELATION_CREATE: &str = "mutation($input:ProjectRelationCreateInput!){ projectRelationCreate(input:$input){success projectRelation{id}} }";
655 /// Delete a native issue dependency before replacing its full edge set.
656 pub const ISSUE_RELATION_DELETE: &str =
657 "mutation($id:String!){ issueRelationDelete(id:$id){success} }";
658 /// Delete a native project dependency before replacing its full edge set.
659 pub const PROJECT_RELATION_DELETE: &str =
660 "mutation($id:String!){ projectRelationDelete(id:$id){success} }";
661 /// Delete an issue, so a copy that could not finish can take back what it created.
662 pub const ISSUE_DELETE: &str = "mutation($id:String!){ issueDelete(id:$id){success} }";
663 /// Delete a project, for the same reason and on the same terms.
664 pub const PROJECT_DELETE: &str = "mutation($id:String!){ projectDelete(id:$id){success} }";
665 /// Fetch one document.
666 pub const DOCUMENT: &str = "query($id:String!){ document(id:$id){ id title content url createdAt updatedAt archivedAt project{id} } }";
667 /// List documents.
668 ///
669 /// `first` is an `Int` rather than an `Int!` because that is what Linear's `documents`
670 /// connection declares, unlike its `issues` one.
671 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} } }";
672 /// Create a document.
673 pub const DOCUMENT_CREATE: &str = "mutation($input:DocumentCreateInput!){ documentCreate(input:$input){success document{id content}} }";
674 /// Update a document.
675 pub const DOCUMENT_UPDATE: &str = "mutation($id:String!,$input:DocumentUpdateInput!){ documentUpdate(id:$id,input:$input){success document{id content}} }";
676 /// Delete a document, so a copy that could not finish can take back what it created.
677 pub const DOCUMENT_DELETE: &str = "mutation($id:String!){ documentDelete(id:$id){success} }";
678 /// One page of an issue's comments, walked backwards.
679 ///
680 /// `last`/`before` rather than `first`/`after`, and `pageInfo{hasPreviousPage
681 /// startCursor}` rather than its forward pair, because Linear lists a connection newest
682 /// first and the contract owes the oldest first — see the ruling on comments in this
683 /// crate's module documentation. `archivedAt` is selected for the reason every by-id read
684 /// here selects it: a trashed issue is not an issue this source holds.
685 pub const ISSUE_COMMENTS: &str = "query($id:String!,$last:Int,$before:String){ issue(id:$id){ archivedAt comments(last:$last,before:$before){ nodes{id body url createdAt updatedAt user{displayName}} pageInfo{hasPreviousPage startCursor} } } }";
686 /// One issue and one page of its comments, in one request: [`ISSUE`]'s selection and
687 /// [`ISSUE_COMMENTS`]'s connection on one `issue(id:)` field.
688 ///
689 /// What a copy reads a task with when it carries its comments: one request answers both
690 /// where the comments fit one page, and every further page through this same read carries
691 /// the issue beside it, so the copy can tell the issue changed between two of them.
692 pub const ISSUE_DETAIL: &str = "query($id:String!,$last:Int,$before:String){ issue(id:$id){ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} comments(last:$last,before:$before){ nodes{id body url createdAt updatedAt user{displayName}} pageInfo{hasPreviousPage startCursor} } } }";
693 /// The first page of an issue's relations a `lin1` token's relations part is spelled from:
694 /// one of what [`ISSUES_CACHED`], [`ISSUE_CACHED`] and [`ISSUE_DETAIL_CACHED`] add to the
695 /// documents they stand in for.
696 pub const RELATIONS_PAGE: &str =
697 "relations(first:10){nodes{id type relatedIssue{id}} pageInfo{hasNextPage}}";
698 /// The first page of an issue's inverse relations — what depends on it — a `lin1` token's
699 /// inverse part is spelled from, added beside [`RELATIONS_PAGE`].
700 pub const INVERSE_RELATIONS_PAGE: &str =
701 "inverseRelations(first:10){nodes{id type issue{id}} pageInfo{hasNextPage}}";
702 /// The first page of an issue's comments, each its id and when it last changed, a `lin1`
703 /// token's comments part is spelled from: what [`ISSUES_CACHED`], [`ISSUE_CACHED`] and
704 /// [`ISSUE_RELATIONS_CACHED`] add for it. [`ISSUE_DETAIL_CACHED`] spells it from the comments
705 /// page it already reads.
706 pub const COMMENTS_PAGE: &str = "comments(first:10){nodes{id updatedAt} pageInfo{hasNextPage}}";
707 /// The first page of a project's relations a project's `lin1` token's relations part is
708 /// spelled from: one of what [`PROJECTS_CACHED`] and [`PROJECT_CACHED`] add.
709 pub const PROJECT_RELATIONS_PAGE: &str =
710 "relations(first:10){nodes{id type relatedProject{id}} pageInfo{hasNextPage}}";
711 /// The first page of a project's inverse relations — what depends on it — a project's
712 /// `lin1` token's inverse part is spelled from, added beside [`PROJECT_RELATIONS_PAGE`].
713 pub const PROJECT_INVERSE_RELATIONS_PAGE: &str =
714 "inverseRelations(first:10){nodes{id type project{id}} pageInfo{hasNextPage}}";
715 /// What [`ISSUE_RELATIONS_CACHED`] adds to [`ISSUE_RELATIONS`]: every part of a task's
716 /// `lin1` token beside the two relations pages that document already reads, and nothing
717 /// else.
718 pub const TOKEN_PARTS: &str = "updatedAt identifier url state{name type} labels{nodes{id name color}} project{id} comments(first:10){nodes{id updatedAt} pageInfo{hasNextPage}}";
719 /// What [`PROJECT_RELATIONS_CACHED`] adds to [`PROJECT_RELATIONS`]: every part of a
720 /// project's `lin1` token beside the relations page that document already reads.
721 pub const PROJECT_TOKEN_PARTS: &str =
722 "updatedAt status{name type} labels{nodes{id name color}}";
723 /// [`ISSUES`], sent in its place while the store's cache stands in front of this source:
724 /// with [`COMMENTS_PAGE`], [`RELATIONS_PAGE`] and [`INVERSE_RELATIONS_PAGE`], so a listing
725 /// pins each task to the token a read of it fills.
726 pub const ISSUES_CACHED: &str = "query($first:Int!,$after:String,$filter:IssueFilter){ issues(first:$first,after:$after,filter:$filter){ nodes{id identifier title description url createdAt updatedAt state{name type} priority labels{nodes{id name color}} project{id} comments(first:10){nodes{id updatedAt} pageInfo{hasNextPage}} relations(first:10){nodes{id type relatedIssue{id}} pageInfo{hasNextPage}} inverseRelations(first:10){nodes{id type issue{id}} pageInfo{hasNextPage}}} pageInfo{hasNextPage endCursor} } }";
727 /// [`ISSUE`], sent in its place while the store's cache stands in front of this source.
728 pub const ISSUE_CACHED: &str = "query($id:String!){ issue(id:$id){ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} comments(first:10){nodes{id updatedAt} pageInfo{hasNextPage}} relations(first:10){nodes{id type relatedIssue{id}} pageInfo{hasNextPage}} inverseRelations(first:10){nodes{id type issue{id}} pageInfo{hasNextPage}} } }";
729 /// [`ISSUE_DETAIL`], sent in its place while the store's cache stands in front of this
730 /// source: its comments part is spelled from the comments page it already reads.
731 pub const ISSUE_DETAIL_CACHED: &str = "query($id:String!,$last:Int,$before:String){ issue(id:$id){ id identifier title description url createdAt updatedAt archivedAt state{name type} priority labels{nodes{id name color}} project{id} relations(first:10){nodes{id type relatedIssue{id}} pageInfo{hasNextPage}} inverseRelations(first:10){nodes{id type issue{id}} pageInfo{hasNextPage}} comments(last:$last,before:$before){ nodes{id body url createdAt updatedAt user{displayName}} pageInfo{hasPreviousPage startCursor} } } }";
732 /// [`ISSUE_RELATIONS`], sent in its place while the store's cache stands in front of this
733 /// source: with [`TOKEN_PARTS`], so a dependency read in either direction spells the token
734 /// its edges are kept at.
735 pub const ISSUE_RELATIONS_CACHED: &str = "query($id:String!,$first:Int!,$after:String){ issue(id:$id){ updatedAt identifier url state{name type} labels{nodes{id name color}} project{id} comments(first:10){nodes{id updatedAt} pageInfo{hasNextPage}} 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}} } }";
736 /// [`PROJECTS`], sent in its place while the store's cache stands in front of this source:
737 /// with [`PROJECT_RELATIONS_PAGE`] and [`PROJECT_INVERSE_RELATIONS_PAGE`], so a listing pins
738 /// each project to the token its edges are kept at. Sent at most
739 /// [`super::PROJECTS_CACHED_PAGE`] rows a page.
740 pub const PROJECTS_CACHED: &str = "query($first:Int!,$after:String,$filter:ProjectFilter){ projects(first:$first,after:$after,filter:$filter){ nodes{id name content url createdAt updatedAt status{name type} labels{nodes{id name color}} relations(first:10){nodes{id type relatedProject{id}} pageInfo{hasNextPage}} inverseRelations(first:10){nodes{id type project{id}} pageInfo{hasNextPage}}} pageInfo{hasNextPage endCursor} } }";
741 /// [`PROJECT`], sent in its place while the store's cache stands in front of this source.
742 pub const PROJECT_CACHED: &str = "query($id:String!){ project(id:$id){ id name content url createdAt updatedAt archivedAt status{name type} labels{nodes{id name color}} relations(first:10){nodes{id type relatedProject{id}} pageInfo{hasNextPage}} inverseRelations(first:10){nodes{id type project{id}} pageInfo{hasNextPage}} } }";
743 /// [`PROJECT_RELATIONS`], sent in its place while the store's cache stands in front of this
744 /// source: with [`PROJECT_TOKEN_PARTS`], so a dependency read in either direction spells the
745 /// token its edges are kept at.
746 pub const PROJECT_RELATIONS_CACHED: &str = "query($id:String!,$first:Int!,$after:String){ project(id:$id){ updatedAt status{name type} labels{nodes{id name color}} content 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}} } }";
747 /// Place one comment: which issue it is on, if any.
748 ///
749 /// `$id` is a nullable `String` because that is what `Query.comment` declares — it also
750 /// takes a `hash` instead — and a variable has to be exactly its argument's type.
751 pub const COMMENT: &str =
752 "query($id:String){ comment(id:$id){ id body archivedAt issue{id} } }";
753 /// Add a comment to an issue.
754 pub const COMMENT_CREATE: &str = "mutation($input:CommentCreateInput!){ commentCreate(input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
755 /// Replace a comment's body.
756 pub const COMMENT_UPDATE: &str = "mutation($id:String!,$input:CommentUpdateInput!){ commentUpdate(id:$id,input:$input){success comment{id body url createdAt updatedAt user{displayName}}} }";
757 /// Remove a comment.
758 pub const COMMENT_DELETE: &str = "mutation($id:String!){ commentDelete(id:$id){success} }";
759}
760
761use graphql::{
762 DOCUMENT, DOCUMENTS, ISSUE, ISSUE_RELATIONS, ISSUES, LABELS, PROJECT, PROJECT_RELATIONS,
763 PROJECTS, VIEWER,
764};
765
766/// One `linear` source's configuration.
767///
768/// It names the credential's environment variable, never its value. Serializable so the
769/// schema it is published under carries each member's default, which is what a configuration
770/// that leaves the member out means.
771#[derive(Debug, Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
772#[serde(default, deny_unknown_fields)]
773pub struct LinearConfig {
774 /// Environment variable resolved by the host.
775 #[schemars(with = "String")]
776 api_key_env: EnvName,
777 /// Linear team key/id used to narrow reads and required for item writes.
778 team: Option<Team>,
779 /// GraphQL endpoint override, primarily for fixture servers.
780 #[schemars(with = "String")]
781 endpoint: Endpoint,
782 /// Which workflow state of the configured team a task's status category is, and which of
783 /// the workspace's project statuses a project's is — the shared `status_mapping` grammar.
784 ///
785 /// Linear has no built-in names, so this is the whole of what a status is written as and
786 /// read by: a category is written as exactly the name its kind maps it to, and a status
787 /// write it gives no name for is refused before any mutation, naming the key to set; an
788 /// item at a name its kind maps reads as that category under the name, and every other
789 /// name reads as `unknown`. A source without it reads every item as `unknown` and refuses
790 /// every status write. Two categories mapped to one name of one kind are refused when this
791 /// configuration is read.
792 status_mapping: StatusMapping,
793 /// The id of one Linear project of the configured team, scoping this source to it.
794 ///
795 /// When set, every task read is narrowed to that project's issues, project and document
796 /// reads return only that project and its documents, a task or a document written with
797 /// no project is placed in it, and one naming another project is refused. Absent, the
798 /// source reads and writes team-wide.
799 project: Option<ProjectScope>,
800}
801
802/// The id of one Linear project, which a scoped source holds alone.
803#[derive(Debug, Clone, Deserialize, serde::Serialize, schemars::JsonSchema)]
804#[serde(try_from = "String", into = "String")]
805#[schemars(rename = "LinearProjectId", extend("minLength" = 1))]
806struct ProjectScope(String);
807impl From<ProjectScope> for String {
808 fn from(value: ProjectScope) -> Self {
809 value.0
810 }
811}
812impl TryFrom<String> for ProjectScope {
813 type Error = String;
814 fn try_from(value: String) -> Result<Self, Self::Error> {
815 if value.trim().is_empty() {
816 Err("a project id cannot be blank".into())
817 } else {
818 Ok(Self(value))
819 }
820 }
821}
822
823#[derive(Debug, Clone, Deserialize, serde::Serialize)]
824#[serde(try_from = "String", into = "String")]
825struct EnvName(String);
826impl From<EnvName> for String {
827 fn from(value: EnvName) -> Self {
828 value.0
829 }
830}
831impl TryFrom<String> for EnvName {
832 type Error = String;
833 fn try_from(value: String) -> Result<Self, Self::Error> {
834 let mut bytes = value.bytes();
835 if bytes
836 .next()
837 .is_some_and(|byte| byte == b'_' || byte.is_ascii_uppercase())
838 && bytes.all(|byte| byte == b'_' || byte.is_ascii_uppercase() || byte.is_ascii_digit())
839 {
840 Ok(Self(value))
841 } else {
842 Err("must be an uppercase environment-variable name".into())
843 }
844 }
845}
846/// A Linear team's key or id.
847#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize, schemars::JsonSchema)]
848#[serde(try_from = "String", into = "String")]
849#[schemars(rename = "LinearTeam", extend("minLength" = 1))]
850struct Team(String);
851impl From<Team> for String {
852 fn from(value: Team) -> Self {
853 value.0
854 }
855}
856impl TryFrom<String> for Team {
857 type Error = String;
858 fn try_from(value: String) -> Result<Self, Self::Error> {
859 if value.trim().is_empty() {
860 Err("must not be empty".into())
861 } else {
862 Ok(Self(value))
863 }
864 }
865}
866#[derive(Debug, Clone, Deserialize, serde::Serialize)]
867#[serde(try_from = "String", into = "String")]
868struct Endpoint(String);
869impl From<Endpoint> for String {
870 fn from(value: Endpoint) -> Self {
871 value.0
872 }
873}
874impl TryFrom<String> for Endpoint {
875 type Error = String;
876 fn try_from(value: String) -> Result<Self, Self::Error> {
877 let url = reqwest::Url::parse(&value).map_err(|e| e.to_string())?;
878 if matches!(url.scheme(), "http" | "https") {
879 Ok(Self(value))
880 } else {
881 Err("must use http or https".into())
882 }
883 }
884}
885
886impl Default for LinearConfig {
887 fn default() -> Self {
888 Self {
889 api_key_env: EnvName("LINEAR_API_KEY".into()),
890 team: None,
891 endpoint: Endpoint(DEFAULT_ENDPOINT.into()),
892 status_mapping: StatusMapping::default(),
893 project: None,
894 }
895 }
896}
897
898/// An item kind as this source's configuration and reports spell it.
899const fn kind_word(kind: ItemKind) -> &'static str {
900 match kind {
901 ItemKind::Task => "task",
902 ItemKind::Project => "project",
903 }
904}
905
906/// What a name of `kind` is called in Linear, for a message.
907const fn vocabulary_word(kind: ItemKind) -> &'static str {
908 match kind {
909 ItemKind::Task => "workflow state",
910 ItemKind::Project => "project status",
911 }
912}
913
914/// A `WorkflowState.type` a workflow state `sources fields --apply` creates is given — the five
915/// Linear's `WorkflowStateCreateInput.type` documents taking.
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
917enum CreatedStateType {
918 Backlog,
919 Unstarted,
920 Started,
921 Completed,
922 Canceled,
923}
924
925impl CreatedStateType {
926 /// The type as Linear spells it.
927 const fn as_str(self) -> &'static str {
928 match self {
929 Self::Backlog => "backlog",
930 Self::Unstarted => "unstarted",
931 Self::Started => "started",
932 Self::Completed => "completed",
933 Self::Canceled => "canceled",
934 }
935 }
936}
937
938/// A `ProjectStatusType` a project status `sources fields --apply` creates is given — a member
939/// of that enum, which `paused` is too and which nothing here creates.
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941enum CreatedStatusType {
942 Backlog,
943 Planned,
944 Started,
945 Completed,
946 Canceled,
947}
948
949impl CreatedStatusType {
950 /// The type as Linear spells it.
951 const fn as_str(self) -> &'static str {
952 match self {
953 Self::Backlog => "backlog",
954 Self::Planned => "planned",
955 Self::Started => "started",
956 Self::Completed => "completed",
957 Self::Canceled => "canceled",
958 }
959 }
960}
961
962/// The `WorkflowState.type` a workflow state `sources fields --apply` creates for `category`
963/// is given, and the `ProjectStatusType` a project status it creates is given.
964///
965/// One row per category, fixed: a category is created as the type whose meaning is nearest
966/// it, and `draft`, `queued` and `unknown` — which no type of either vocabulary means — as the
967/// nearest neighbour's. An exhaustive match, so a category the contract adds fails to compile
968/// here rather than being created as something unstated.
969const fn created_types(category: StatusCategory) -> (CreatedStateType, CreatedStatusType) {
970 match category {
971 StatusCategory::Backlog | StatusCategory::Draft => {
972 (CreatedStateType::Backlog, CreatedStatusType::Backlog)
973 }
974 StatusCategory::Todo | StatusCategory::Queued => {
975 (CreatedStateType::Unstarted, CreatedStatusType::Planned)
976 }
977 StatusCategory::InProgress | StatusCategory::Unknown => {
978 (CreatedStateType::Started, CreatedStatusType::Started)
979 }
980 StatusCategory::Done => (CreatedStateType::Completed, CreatedStatusType::Completed),
981 StatusCategory::Cancelled => (CreatedStateType::Canceled, CreatedStatusType::Canceled),
982 }
983}
984
985/// The type, as Linear spells it, a name of `kind` that `sources fields --apply` creates for
986/// `category` is given.
987const fn created_type(category: StatusCategory, kind: ItemKind) -> &'static str {
988 let (state, status) = created_types(category);
989 match kind {
990 ItemKind::Task => state.as_str(),
991 ItemKind::Project => status.as_str(),
992 }
993}
994
995/// The colour every workflow state and project status `sources fields --apply` creates is
996/// given: Linear's own neutral grey, because both create inputs require one and nothing in a
997/// status mapping says which colour a name should be. A person recolours it in Linear.
998const CREATED_COLOR: &str = "#95a2b3";
999
1000/// One workflow state of the configured team, or one project status of the workspace.
1001#[derive(Debug, Clone)]
1002struct Held {
1003 id: NativeId,
1004 name: StatusName,
1005 /// Its `WorkflowState.type` or `ProjectStatusType`, verbatim: Linear adds types this
1006 /// source must not refuse (`duplicate` among them).
1007 // llmlint: ignore[invalid_states_unrepresentable] Linear's own open `String!` vocabulary, held verbatim for a report; an enum here would refuse a type Linear adds.
1008 kind: String,
1009}
1010
1011impl Held {
1012 /// One workflow state or project status, as Linear answered it.
1013 fn read(node: &Value) -> Result<Self, SourceError> {
1014 Ok(Self {
1015 id: NativeId(backend_id(node, "id")?.into()),
1016 name: held_name(node)?,
1017 kind: str_at(node, "type")?.to_owned(),
1018 })
1019 }
1020}
1021
1022/// A project status's place in the workspace's project flow, as Linear answered it.
1023fn position_of(node: &Value) -> Result<f64, SourceError> {
1024 node.get("position")
1025 .and_then(Value::as_f64)
1026 .ok_or_else(|| SourceError::Malformed {
1027 message: "missing number field position".into(),
1028 })
1029}
1030
1031/// One name `sources fields --apply` created, as Linear answered its create.
1032#[derive(Debug, Clone)]
1033enum Created {
1034 /// A workflow state of the team.
1035 State(Held),
1036 /// A project status of the workspace, at its place in the workspace's project flow.
1037 Status { held: Held, position: f64 },
1038}
1039
1040impl Created {
1041 fn held(&self) -> &Held {
1042 match self {
1043 Self::State(held) | Self::Status { held, .. } => held,
1044 }
1045 }
1046}
1047
1048/// The name of one workflow state or project status Linear answered with, which no name in
1049/// Linear is blank.
1050fn held_name(node: &Value) -> Result<StatusName, SourceError> {
1051 StatusName::try_from(str_at(node, "name")?.to_owned()).map_err(|refused| {
1052 SourceError::Malformed {
1053 message: format!("Linear answered a status name this source cannot hold: {refused}"),
1054 }
1055 })
1056}
1057
1058/// What a status write resolves: the configured team's id, its workflow states and the
1059/// workspace's project statuses.
1060///
1061/// Read in one request and held by one source instance for its own lifetime — never by the
1062/// process, never shared between sources, and kept across [`TaskSource::end_command`] — so
1063/// every status write after the first sends its mutation alone. A name not found in it is
1064/// looked for once more in a fresh read, so a name added in Linear after it was read is found;
1065/// nothing a failed call answered is held.
1066#[derive(Debug, Clone)]
1067struct Vocabulary {
1068 /// The workspace the credential reached, which is what makes a declaration of this
1069 /// source as private one that has been verified rather than taken on trust — `None` when
1070 /// the answer named none, which verifies nothing and resolves everything else as before.
1071 workspace: Option<NativeId>,
1072 team: NativeId,
1073 states: Vec<Held>,
1074 statuses: Vec<Held>,
1075 /// Where the workspace's project flow ends: the greatest `position` among its project
1076 /// statuses, zero when it holds none. A project status `sources fields --apply` creates is
1077 /// placed after it.
1078 last_position: f64,
1079}
1080
1081impl Vocabulary {
1082 fn read(data: &Value, source: &SourceName, team: &str) -> Result<Self, SourceError> {
1083 let teams = data
1084 .pointer("/teams/nodes")
1085 .and_then(Value::as_array)
1086 .ok_or_else(|| SourceError::Malformed {
1087 message: "missing teams.nodes".into(),
1088 })?;
1089 let found = match teams.as_slice() {
1090 [found] => found,
1091 other => {
1092 return Err(SourceError::Refused {
1093 message: format!(
1094 "source {source} cannot resolve the configured team {team:?}: found {} \
1095 matches; next: set team to the key of exactly one team this \
1096 credential can see",
1097 other.len()
1098 ),
1099 });
1100 }
1101 };
1102 let nodes = |pointer: &str| {
1103 data.pointer(pointer)
1104 .and_then(Value::as_array)
1105 .ok_or_else(|| SourceError::Malformed {
1106 message: format!(
1107 "missing {}",
1108 pointer.trim_start_matches('/').replace('/', ".")
1109 ),
1110 })
1111 };
1112 let team = NativeId(backend_id(found, "id")?.into());
1113 let workspace = data
1114 .get("organization")
1115 .filter(|organization| !organization.is_null())
1116 .map(|organization| backend_id(organization, "id").map(|id| NativeId(id.into())))
1117 .transpose()?;
1118 // Read whole or not at all: a name on a page this did not read would be refused as
1119 // missing, or a second name of that spelling would go unseen.
1120 for (more, held) in [
1121 (
1122 "/teams/nodes/0/states/pageInfo/hasNextPage",
1123 "workflow states of its team",
1124 ),
1125 (
1126 "/projectStatuses/pageInfo/hasNextPage",
1127 "project statuses of its workspace",
1128 ),
1129 ] {
1130 let more = data.pointer(more).and_then(Value::as_bool).ok_or_else(|| {
1131 SourceError::Malformed {
1132 message: format!(
1133 "missing boolean {}",
1134 more.trim_start_matches('/').replace('/', ".")
1135 ),
1136 }
1137 })?;
1138 if more {
1139 return Err(SourceError::Refused {
1140 message: format!(
1141 "source {source} reads the {held} in one page of 250, and Linear holds \
1142 more; next: archive the ones no longer used, so the rest fit one page"
1143 ),
1144 });
1145 }
1146 }
1147 let statuses = nodes("/projectStatuses/nodes")?;
1148 Ok(Self {
1149 workspace,
1150 team,
1151 states: nodes("/teams/nodes/0/states/nodes")?
1152 .iter()
1153 .map(Held::read)
1154 .collect::<Result<_, _>>()?,
1155 statuses: statuses.iter().map(Held::read).collect::<Result<_, _>>()?,
1156 last_position: statuses
1157 .iter()
1158 .map(position_of)
1159 .try_fold(0.0_f64, |last, position| Ok(last.max(position?)))?,
1160 })
1161 }
1162
1163 /// Hold one name `sources fields --apply` created beside the ones read.
1164 fn add(&mut self, created: Created) {
1165 match created {
1166 Created::State(held) => self.states.push(held),
1167 Created::Status { held, position } => {
1168 self.statuses.push(held);
1169 self.last_position = self.last_position.max(position);
1170 }
1171 }
1172 }
1173
1174 fn of(&self, kind: ItemKind) -> &[Held] {
1175 match kind {
1176 ItemKind::Task => &self.states,
1177 ItemKind::Project => &self.statuses,
1178 }
1179 }
1180
1181 /// The one name of `kind` matching `name` ignoring case, `None` when there is none, and a
1182 /// refusal naming every id when there are several: a write must not guess between them.
1183 fn find(
1184 &self,
1185 kind: ItemKind,
1186 name: &str,
1187 source: &SourceName,
1188 ) -> Result<Option<&Held>, SourceError> {
1189 let matched = self
1190 .of(kind)
1191 .iter()
1192 .filter(|held| held.name.matches(name))
1193 .collect::<Vec<_>>();
1194 match matched.as_slice() {
1195 [] => Ok(None),
1196 [held] => Ok(Some(held)),
1197 several => Err(SourceError::Refused {
1198 message: format!(
1199 "source {source} cannot resolve {} {name:?}: found {} matches with ids {:?}",
1200 vocabulary_word(kind),
1201 several.len(),
1202 several
1203 .iter()
1204 .map(|held| held.id.0.as_str())
1205 .collect::<Vec<_>>()
1206 ),
1207 }),
1208 }
1209 }
1210}
1211
1212/// The Linear plugin factory.
1213#[derive(Debug, Clone, Copy, Default)]
1214pub struct Plugin;
1215
1216impl SourcePlugin for Plugin {
1217 fn kind(&self) -> &'static str {
1218 KIND
1219 }
1220 /// The endpoint and the team: what names the Linear work a source reads.
1221 fn remote_identity(&self, config: &serde_json::Value) -> Option<String> {
1222 let config: LinearConfig = serde_json::from_value(config.clone()).ok()?;
1223 Some(format!(
1224 "{}\n{}",
1225 config.endpoint.0,
1226 config.team.map(|team| team.0).unwrap_or_default()
1227 ))
1228 }
1229 fn config_schema(&self) -> Schema {
1230 schema_for!(LinearConfig)
1231 }
1232 fn build(
1233 &self,
1234 name: &SourceName,
1235 config: &Value,
1236 secrets: &dyn SecretResolver,
1237 ) -> Result<Box<dyn TaskSource>, SourceError> {
1238 self.build_with_clock(
1239 name,
1240 config,
1241 secrets,
1242 onetaskgraph_plugin_api::system_clock(),
1243 )
1244 }
1245 fn build_with_clock(
1246 &self,
1247 name: &SourceName,
1248 config: &Value,
1249 secrets: &dyn SecretResolver,
1250 clock: onetaskgraph_plugin_api::SharedClock,
1251 ) -> Result<Box<dyn TaskSource>, SourceError> {
1252 let config: LinearConfig =
1253 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1254 message: format!("source {name}: {e}"),
1255 })?;
1256 let mut source = LinearSource::new(name, config, secrets)?;
1257 source.clock = clock;
1258 Ok(Box::new(source))
1259 }
1260}
1261
1262/// What `onetaskgraph sources fields` reports for a `linear` source: every name its
1263/// `status_mapping` gives a task, checked against the configured team's workflow states, and
1264/// every name it gives a project, checked against the workspace's project statuses.
1265///
1266/// With `--apply`, each name a kind's vocabulary lacks is created first — a workflow state on
1267/// the team, a project status in the workspace — of the type its category derives (see the
1268/// crate's ruling on `sources fields --apply`), and the report names what it created. Nothing that exists is renamed,
1269/// retyped or deleted. A create Linear refuses stops the run: [`Self::refused`] names it, and
1270/// every name created before it is reported created.
1271#[derive(Debug, Clone, PartialEq, serde::Serialize, schemars::JsonSchema)]
1272pub struct StatusNamesReport {
1273 /// The configured source name.
1274 pub source: SourceName,
1275 /// The configured team, as `team` names it.
1276 team: Team,
1277 /// Every name `status_mapping` gives a task, then every name it gives a project, each in
1278 /// category order. A bare name is reported once for each kind. Empty when it names none.
1279 pub names: Vec<MappedStatusName>,
1280 /// The create Linear refused, which stopped an `--apply`; absent when nothing was refused.
1281 #[serde(skip_serializing_if = "Option::is_none")]
1282 pub refused: Option<RefusedCreate>,
1283}
1284
1285impl StatusNamesReport {
1286 /// The configured team, as `team` names it.
1287 #[must_use]
1288 pub fn team(&self) -> &str {
1289 &self.team.0
1290 }
1291}
1292
1293/// A name `sources fields --apply` could not create, and why.
1294#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
1295pub struct RefusedCreate {
1296 /// Whether it was a workflow state (`task`) or a project status (`project`).
1297 pub kind: ItemKind,
1298 /// The name it would have created.
1299 pub name: StatusName,
1300 /// What Linear said.
1301 pub message: String,
1302}
1303
1304/// One name a `status_mapping` gives one kind, and whether that kind's vocabulary has it.
1305#[derive(Debug, Clone, PartialEq)]
1306pub struct MappedStatusName {
1307 kind: ItemKind,
1308 category: StatusCategory,
1309 name: StatusName,
1310 found: Found,
1311}
1312
1313/// Whether a kind's vocabulary has a name of the one the mapping gives, and whether this run
1314/// put it there.
1315#[derive(Debug, Clone, PartialEq, Eq)]
1316pub enum Found {
1317 /// It has it, of this type — a `WorkflowState.type` (`backlog`, `unstarted`, `started`,
1318 /// `completed`, `canceled`, `triage`, …) or a `ProjectStatusType` (`backlog`, `planned`,
1319 /// `started`, `paused`, `completed`, `canceled`) — reported verbatim because Linear adds
1320 /// types this report must not refuse.
1321 // llmlint: ignore[invalid_states_unrepresentable] Linear's own open `String!` vocabulary, reported verbatim; an enum here would refuse a type Linear adds, which a report must not.
1322 Present(String),
1323 /// It has no name of that spelling.
1324 Missing,
1325 /// It had none, and `sources fields --apply` created it, of this type.
1326 // llmlint: ignore[invalid_states_unrepresentable] As `Present`: Linear's own open `String!` vocabulary, reported verbatim as Linear answered the create.
1327 Created(String),
1328}
1329
1330impl MappedStatusName {
1331 /// Whether the name is a task's workflow state or a project's project status.
1332 #[must_use]
1333 pub fn kind(&self) -> ItemKind {
1334 self.kind
1335 }
1336
1337 /// The category the mapping sends to the name.
1338 #[must_use]
1339 pub fn category(&self) -> StatusCategory {
1340 self.category
1341 }
1342
1343 /// The name, as the mapping spells it.
1344 #[must_use]
1345 pub fn name(&self) -> &str {
1346 self.name.as_str()
1347 }
1348
1349 /// Whether the vocabulary has it, and of which type.
1350 #[must_use]
1351 pub fn found(&self) -> &Found {
1352 &self.found
1353 }
1354
1355 /// Whether this run created it.
1356 #[must_use]
1357 pub fn created(&self) -> bool {
1358 matches!(self.found, Found::Created(_))
1359 }
1360
1361 /// The type a name of this category is created as — what a present name of another type
1362 /// differs from.
1363 #[must_use]
1364 pub fn expected_type(&self) -> &'static str {
1365 created_type(self.category, self.kind)
1366 }
1367}
1368
1369/// [`MappedStatusName`] as it is written: `present`, and the name's `type` where it is.
1370///
1371/// The wire shape of the report, spelled once for its serialization and its schema, so the
1372/// public type can hold only the combinations [`Found`] allows.
1373#[derive(serde::Serialize, schemars::JsonSchema)]
1374#[schemars(rename = "MappedStatusName")]
1375struct MappedStatusNameWire<'a> {
1376 /// `task` for a workflow state of the team, `project` for a project status of the workspace.
1377 kind: ItemKind,
1378 /// The category the mapping sends to the name.
1379 category: StatusCategory,
1380 /// The name, as the mapping spells it.
1381 name: &'a StatusName,
1382 /// Whether that kind's vocabulary has a name of that spelling, ignoring case.
1383 present: bool,
1384 /// The name's type in Linear — a `WorkflowState.type` for a task, a `ProjectStatusType`
1385 /// for a project — absent when it is missing.
1386 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1387 found_type: Option<&'a str>,
1388 /// The type `--apply` creates a missing name of this category and kind as; a present name
1389 /// of another type is reported and left as it is.
1390 expected_type: &'static str,
1391 /// Whether this run created the name; absent when it did not.
1392 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1393 // Kept in the schema as `"default": false` although the JSON leaves `false` out, so both
1394 // SDKs model an absent `created` as `false` rather than as a member that must be there.
1395 #[schemars(!skip_serializing_if)]
1396 created: bool,
1397}
1398
1399impl<'a> From<&'a MappedStatusName> for MappedStatusNameWire<'a> {
1400 fn from(mapped: &'a MappedStatusName) -> Self {
1401 let found_type = match &mapped.found {
1402 Found::Present(kind) | Found::Created(kind) => Some(kind.as_str()),
1403 Found::Missing => None,
1404 };
1405 Self {
1406 kind: mapped.kind,
1407 category: mapped.category,
1408 name: &mapped.name,
1409 present: found_type.is_some(),
1410 found_type,
1411 expected_type: mapped.expected_type(),
1412 created: mapped.created(),
1413 }
1414 }
1415}
1416
1417impl serde::Serialize for MappedStatusName {
1418 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1419 MappedStatusNameWire::from(self).serialize(serializer)
1420 }
1421}
1422
1423impl schemars::JsonSchema for MappedStatusName {
1424 fn schema_name() -> std::borrow::Cow<'static, str> {
1425 MappedStatusNameWire::schema_name()
1426 }
1427
1428 fn json_schema(generator: &mut schemars::SchemaGenerator) -> Schema {
1429 MappedStatusNameWire::json_schema(generator)
1430 }
1431}
1432
1433/// Report every name one `linear` source's `status_mapping` gives each kind, as present in
1434/// that kind's vocabulary with its type or missing from it — and, with `apply`, create every
1435/// missing one first.
1436///
1437/// # Errors
1438///
1439/// [`SourceError::Config`] or [`SourceError::Auth`] for a source that cannot be built, a
1440/// refusal for one with no `team`, and whatever else Linear could not answer. A create Linear
1441/// refuses is not an error here: it is [`StatusNamesReport::refused`], beside what was created.
1442pub async fn status_names(
1443 name: &SourceName,
1444 config: LinearConfig,
1445 secrets: &dyn SecretResolver,
1446 apply: bool,
1447) -> Result<StatusNamesReport, SourceError> {
1448 LinearSource::new(name, config, secrets)?
1449 .status_names(apply)
1450 .await
1451}
1452
1453struct LinearSource {
1454 clock: onetaskgraph_plugin_api::SharedClock,
1455 client: reqwest::Client,
1456 endpoint: Endpoint,
1457 key: SecretString,
1458 team: Option<Team>,
1459 /// This source's configured name, kept for one comparison: a far end recorded as
1460 /// `<this name>:<native>` is a Linear item Linear itself relates, so the reserved key
1461 /// is refused for it exactly as a bare id of the same kind is.
1462 name: SourceName,
1463 /// Which name each status category is, for a task and for a project.
1464 statuses: StatusMapping,
1465 /// The one Linear project this source is scoped to, when it is.
1466 project: Option<ProjectScope>,
1467 /// What a status write resolves, once it has been read; see [`Vocabulary`].
1468 vocabulary: std::sync::Mutex<Option<std::sync::Arc<Vocabulary>>>,
1469 /// Whether the store's cache stands in front of this source — [`TaskSource::attach_cache`]
1470 /// — so its issue reads carry the relations page a `lin1` token's third part is read from.
1471 attached: std::sync::atomic::AtomicBool,
1472 /// The token the response answering the last dependency read of each item in each
1473 /// direction spelled, while that walk's pages agree on it; see
1474 /// [`TaskSource::edges_version`].
1475 edges_versions: std::sync::Mutex<std::collections::HashMap<EdgesRead, String>>,
1476 /// What the answer to this instance's latest long-form write of each item said Linear now
1477 /// stores as its content, until [`TaskSource::stored_content`] takes it; see the ruling on
1478 /// what Linear does to Markdown on write.
1479 stored: std::sync::Mutex<std::collections::HashMap<(MetadataRecord, NativeId), String>>,
1480}
1481
1482/// One item's dependency read in one direction, as [`LinearSource::edges_versions`] keys it.
1483type EdgesRead = (DependencyRoot, Direction, NativeId);
1484
1485impl LinearSource {
1486 fn new(
1487 name: &SourceName,
1488 config: LinearConfig,
1489 secrets: &dyn SecretResolver,
1490 ) -> Result<Self, SourceError> {
1491 for kind in [ItemKind::Task, ItemKind::Project] {
1492 StatusMapping::distinct(
1493 name,
1494 kind,
1495 config
1496 .status_mapping
1497 .names(kind)
1498 .map(|(category, mapped)| (category, mapped.as_str())),
1499 )?;
1500 }
1501 let key = secrets
1502 .get(&config.api_key_env.0)
1503 .filter(|v| !v.expose_secret().trim().is_empty())
1504 .ok_or_else(|| SourceError::Auth {
1505 message: format!("set environment variable {}", config.api_key_env.0),
1506 })?;
1507 Ok(Self {
1508 clock: onetaskgraph_plugin_api::system_clock(),
1509 client: reqwest::Client::new(),
1510 endpoint: config.endpoint,
1511 key,
1512 team: config.team,
1513 name: name.clone(),
1514 statuses: config.status_mapping,
1515 project: config.project,
1516 vocabulary: std::sync::Mutex::new(None),
1517 attached: std::sync::atomic::AtomicBool::new(false),
1518 edges_versions: std::sync::Mutex::new(std::collections::HashMap::new()),
1519 stored: std::sync::Mutex::new(std::collections::HashMap::new()),
1520 })
1521 }
1522}
1523#[derive(Clone, Copy)]
1524enum WriteKind {
1525 Task,
1526 Project,
1527}
1528/// What a whole write already knows of the relations its item holds.
1529enum HeldRelations<'a> {
1530 /// None: the item was just created.
1531 None,
1532 /// The first page of them, as the rewrite's own answer reported it.
1533 Page(&'a Value),
1534 /// Nothing yet, so they are read.
1535 Unread,
1536}
1537enum Lookup<'a> {
1538 IssueLabel(&'a str),
1539 ProjectLabel(&'a str),
1540}
1541impl Lookup<'_> {
1542 fn query(&self) -> &'static str {
1543 match self {
1544 Self::IssueLabel(_) => graphql::ISSUE_LABEL,
1545 Self::ProjectLabel(_) => graphql::PROJECT_LABEL,
1546 }
1547 }
1548 fn connection(&self) -> &'static str {
1549 match self {
1550 Self::IssueLabel(_) => "issueLabels",
1551 Self::ProjectLabel(_) => "projectLabels",
1552 }
1553 }
1554 fn diagnostic(&self) -> String {
1555 match self {
1556 Self::IssueLabel(name) | Self::ProjectLabel(name) => format!("label {name:?}"),
1557 }
1558 }
1559 fn variables(&self) -> Value {
1560 match self {
1561 Self::IssueLabel(name) | Self::ProjectLabel(name) => json!({"name":name}),
1562 }
1563 }
1564}
1565#[derive(Clone, Copy)]
1566enum MutationRoot {
1567 IssueCreate,
1568 IssueUpdate,
1569 ProjectCreate,
1570 ProjectUpdate,
1571 IssueRelationCreate,
1572 ProjectRelationCreate,
1573 IssueRelationDelete,
1574 ProjectRelationDelete,
1575 IssueDelete,
1576 ProjectDelete,
1577 DocumentCreate,
1578 DocumentUpdate,
1579 DocumentDelete,
1580 CommentCreate,
1581 CommentUpdate,
1582 CommentDelete,
1583 WorkflowStateCreate,
1584 ProjectStatusCreate,
1585}
1586impl MutationRoot {
1587 fn as_str(self) -> &'static str {
1588 match self {
1589 Self::IssueCreate => "issueCreate",
1590 Self::IssueUpdate => "issueUpdate",
1591 Self::ProjectCreate => "projectCreate",
1592 Self::ProjectUpdate => "projectUpdate",
1593 Self::IssueRelationCreate => "issueRelationCreate",
1594 Self::ProjectRelationCreate => "projectRelationCreate",
1595 Self::IssueRelationDelete => "issueRelationDelete",
1596 Self::ProjectRelationDelete => "projectRelationDelete",
1597 Self::IssueDelete => "issueDelete",
1598 Self::ProjectDelete => "projectDelete",
1599 Self::DocumentCreate => "documentCreate",
1600 Self::DocumentUpdate => "documentUpdate",
1601 Self::DocumentDelete => "documentDelete",
1602 Self::CommentCreate => "commentCreate",
1603 Self::CommentUpdate => "commentUpdate",
1604 Self::CommentDelete => "commentDelete",
1605 Self::WorkflowStateCreate => "workflowStateCreate",
1606 Self::ProjectStatusCreate => "projectStatusCreate",
1607 }
1608 }
1609}
1610
1611#[derive(Deserialize)]
1612struct Envelope {
1613 // 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.
1614 data: Option<Value>,
1615 #[serde(default)]
1616 errors: Vec<GqlError>,
1617}
1618#[derive(Deserialize)]
1619struct GqlError {
1620 message: String,
1621 // Held raw rather than typed, for two reasons. Linear puts the whole of *why* it
1622 // refused in here — `message` is a category name like `Argument Validation Error`,
1623 // which named neither the field nor the value when the live project-relation write
1624 // was refused by it — so a refusal carries this verbatim and a reader diagnoses from
1625 // it. And a typed shape with a required `code` fails the whole envelope's
1626 // deserialization when Linear sends extensions without one, turning a refusal this
1627 // source could explain into an unexplained malformed response.
1628 extensions: Option<Value>,
1629}
1630#[derive(Deserialize)]
1631#[serde(rename_all = "camelCase")]
1632struct GqlExtensions {
1633 code: GqlErrorCode,
1634 retry_after: Option<u64>,
1635}
1636impl GqlError {
1637 fn rate_limited(&self, retry: Option<u64>, status: reqwest::StatusCode) -> Option<SourceError> {
1638 let extensions = self
1639 .coded()
1640 .filter(|extensions| matches!(extensions.code, GqlErrorCode::RateLimited))?;
1641 Some(SourceError::RateLimited {
1642 retry_after_seconds: retry.or(extensions.retry_after),
1643 message: Some(format!("Linear returned HTTP {status}")),
1644 })
1645 }
1646
1647 /// The rate-limit shape of [`Self::extensions`], when it has one.
1648 fn coded(&self) -> Option<GqlExtensions> {
1649 self.extensions
1650 .as_ref()
1651 .and_then(|value| serde_json::from_value(value.clone()).ok())
1652 }
1653 /// Everything Linear said about this refusal, on one line and cut to [`SAID_LIMIT`].
1654 ///
1655 /// Linear's own sentence comes first, then the raw envelope, because only the first
1656 /// of those two is short enough to survive [`SAID_LIMIT`] on its merits. `message` is
1657 /// a category name — `Argument Validation Error` — and the sentence naming the field
1658 /// and the values it would have taken is `extensions.userPresentableMessage`, one of
1659 /// several keys in an envelope whose `validationErrors` echoes the whole rejected
1660 /// input back. Observed against the real API on 2026-09-04, a `projectRelationCreate`
1661 /// refusal rendered past the cut, and the echo is what got cut.
1662 ///
1663 /// That the sentence itself did not was luck: this build of `serde_json` renders an
1664 /// object's keys sorted, and `userPresentableMessage` happens to sort ahead of
1665 /// `validationErrors`. Nobody chose that — Linear sends the echo first — and any key
1666 /// Linear adds sorting between the two would move the sentence behind an echo longer
1667 /// than the whole limit, as would turning `preserve_order` on. Leading with it makes
1668 /// what a reader diagnoses from independent of both.
1669 fn said(&self) -> String {
1670 let Some(extensions) = &self.extensions else {
1671 return elided(&self.message);
1672 };
1673 match extensions
1674 .get("userPresentableMessage")
1675 .and_then(Value::as_str)
1676 .filter(|sentence| !sentence.is_empty())
1677 {
1678 Some(sentence) => elided(&format!("{}: {sentence} {extensions}", self.message)),
1679 None => elided(&format!("{}: {extensions}", self.message)),
1680 }
1681 }
1682}
1683#[derive(Deserialize)]
1684enum GqlErrorCode {
1685 #[serde(rename = "RATELIMITED", alias = "RATE_LIMITED")]
1686 RateLimited,
1687 #[serde(other)]
1688 Other,
1689}
1690
1691/// One GraphQL error Linear answered a request with, before it is made a [`SourceError`].
1692struct Refusal(GqlError);
1693
1694impl Refusal {
1695 /// Whether Linear refused because the issue the request addressed is not there.
1696 ///
1697 /// Linear answers a mutation addressing an id it does not hold with an errored response —
1698 /// `Entity not found: Issue`, whose `userPresentableMessage` reads `Could not find
1699 /// referenced Issue.` — rather than a null payload. Both spellings are recognised, so a
1700 /// rewording of either one alone still reads as what it is; an entity of any other kind,
1701 /// and anything else, is a refusal. The live capability journey sends this mutation
1702 /// to a nonexistent issue and requires `None`, so changed service wording fails the lane.
1703 fn entity_missing(&self) -> bool {
1704 self.missing(
1705 graphql::ISSUE_NOT_FOUND_MESSAGE,
1706 graphql::ISSUE_NOT_FOUND_PRESENTABLE,
1707 )
1708 }
1709
1710 /// Whether Linear refused because the project the request addressed is not there, on the
1711 /// terms of [`Self::entity_missing`].
1712 fn project_missing(&self) -> bool {
1713 self.missing(
1714 graphql::PROJECT_NOT_FOUND_MESSAGE,
1715 graphql::PROJECT_NOT_FOUND_PRESENTABLE,
1716 )
1717 }
1718
1719 /// Whether this refusal is Linear's `message` — or its `presentable` alternative — for the
1720 /// one entity the request addressed.
1721 fn missing(&self, message: &str, presentable: &str) -> bool {
1722 let lowered = self.0.message.to_ascii_lowercase();
1723 // The entity the mutation addressed, and nothing it merely refers to: a state the input
1724 // names that Linear does not hold is a refusal of the write, never no such item.
1725 lowered.trim_end() == message.to_ascii_lowercase()
1726 || lowered.starts_with(&format!("{} ", message.to_ascii_lowercase()))
1727 || self
1728 .0
1729 .extensions
1730 .as_ref()
1731 .and_then(|extensions| extensions.get("userPresentableMessage"))
1732 .and_then(Value::as_str)
1733 .is_some_and(|said| {
1734 said.to_ascii_lowercase()
1735 .starts_with(&presentable.trim_end_matches('.').to_ascii_lowercase())
1736 })
1737 }
1738
1739 fn into_error(self) -> SourceError {
1740 SourceError::Refused {
1741 message: self.0.said(),
1742 }
1743 }
1744}
1745
1746/// How much of a failed response's body a refusal carries.
1747///
1748/// Enough for Linear's own error envelope, which is one or two sentences naming the field
1749/// or argument it would not accept, and short enough that a proxy's HTML error page does
1750/// not become the whole message.
1751const SAID_LIMIT: usize = 400;
1752
1753/// `said` made safe to put in a message: one line of printable text, cut to [`SAID_LIMIT`].
1754///
1755/// A failed response's body is whatever answered — Linear's error envelope, or an HTML
1756/// page from a proxy in front of it — and this message is written to a terminal. So every
1757/// control character goes, escape sequences with them, and each run of whitespace becomes
1758/// one space: a body cannot move the cursor, repaint the line or hide the rest of the
1759/// diagnostic behind itself. Cut by characters rather than bytes, because slicing UTF-8
1760/// mid-codepoint would panic inside the path that exists to explain a failure.
1761fn elided(said: &str) -> String {
1762 let mut printable = String::new();
1763 let mut spaced = true;
1764 for character in said.chars() {
1765 if character.is_control() || character.is_whitespace() {
1766 if !spaced {
1767 printable.push(' ');
1768 spaced = true;
1769 }
1770 continue;
1771 }
1772 printable.push(character);
1773 spaced = false;
1774 }
1775 let printable = printable.trim_end();
1776 if printable.chars().count() <= SAID_LIMIT {
1777 return printable.to_owned();
1778 }
1779 let kept: String = printable.chars().take(SAID_LIMIT).collect();
1780 format!("{kept}…")
1781}
1782
1783impl LinearSource {
1784 async fn send(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
1785 self.answer(query, variables)
1786 .await?
1787 .map_err(|refusal| refusal.into_error())
1788 }
1789
1790 /// Send a mutation addressing one item by id, answering `None` when Linear refuses it
1791 /// because there is no such item.
1792 ///
1793 /// What lets a status write answer "no such task" without reading the issue first: Linear
1794 /// reports an id naming nothing as an errored response rather than a null payload, and
1795 /// [`Refusal::entity_missing`] is how that refusal is told apart from every other.
1796 async fn send_to_held(
1797 &self,
1798 query: &str,
1799 variables: Value,
1800 ) -> Result<Option<Value>, SourceError> {
1801 match self.answer(query, variables).await? {
1802 Ok(data) => Ok(Some(data)),
1803 Err(refusal) if refusal.entity_missing() => Ok(None),
1804 Err(refusal) => Err(refusal.into_error()),
1805 }
1806 }
1807
1808 // 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.
1809 async fn answer(
1810 &self,
1811 query: &str,
1812 variables: Value,
1813 ) -> Result<Result<Value, Refusal>, SourceError> {
1814 match self.answer_using(&self.client, query, variables).await {
1815 Err(SourceError::RateLimited {
1816 retry_after_seconds,
1817 ..
1818 }) => Err(SourceError::RateLimited {
1819 retry_after_seconds,
1820 message: None,
1821 }),
1822 result => result,
1823 }
1824 }
1825
1826 async fn answer_using(
1827 &self,
1828 client: &reqwest::Client,
1829 query: &str,
1830 variables: Value,
1831 ) -> Result<Result<Value, Refusal>, SourceError> {
1832 let response = client
1833 .post(&self.endpoint.0)
1834 .header("Authorization", self.key.expose_secret())
1835 .json(&json!({"query": query, "variables": variables}))
1836 .send()
1837 .await
1838 .map_err(|e| SourceError::Unavailable {
1839 message: e.to_string(),
1840 })?;
1841 let status = response.status();
1842 let retry = reset_wait(response.headers()).map(|wait| {
1843 wait.as_secs()
1844 .saturating_add(u64::from(wait.subsec_nanos() > 0))
1845 });
1846 if status.as_u16() == 429 {
1847 return Err(SourceError::RateLimited {
1848 retry_after_seconds: retry,
1849 message: Some(format!("Linear returned HTTP {status}")),
1850 });
1851 }
1852 if status.as_u16() == 401 || status.as_u16() == 403 {
1853 return Err(SourceError::Auth {
1854 message: format!("Linear rejected the configured credential: HTTP {status}"),
1855 });
1856 }
1857 if !status.is_success() {
1858 // Linear puts its GraphQL error envelope in the *body* of a 400, so the status
1859 // alone names the whole call and nothing about what Linear objected to. The
1860 // body is Linear's answer to this request and holds no credential; it is cut
1861 // because a proxy in front of Linear can answer with a page.
1862 let text = response.text().await.unwrap_or_default();
1863 if let Some(limited) = serde_json::from_str::<Envelope>(&text)
1864 .ok()
1865 .and_then(|body| body.errors.into_iter().next())
1866 .and_then(|error| error.rate_limited(retry, status))
1867 {
1868 return Err(limited);
1869 }
1870 // An id naming nothing is the one refusal a caller acts on rather than reports,
1871 // so it reads the same whichever status Linear sends it under.
1872 if let Some(missing) = serde_json::from_str::<Envelope>(&text)
1873 .ok()
1874 .and_then(|body| body.errors.into_iter().next())
1875 .map(Refusal)
1876 .filter(|refusal| refusal.entity_missing() || refusal.project_missing())
1877 {
1878 return Ok(Err(missing));
1879 }
1880 let said = elided(&text);
1881 return Err(SourceError::Unavailable {
1882 message: if said.is_empty() {
1883 format!("Linear returned HTTP {status}")
1884 } else {
1885 format!("Linear returned HTTP {status}: {said}")
1886 },
1887 });
1888 }
1889 let body: Envelope = response.json().await.map_err(|e| SourceError::Malformed {
1890 message: format!("HTTP {status}: {e}"),
1891 })?;
1892 if let Some(error) = body.errors.into_iter().next() {
1893 if let Some(limited) = error.rate_limited(retry, status) {
1894 return Err(limited);
1895 }
1896 return Ok(Err(Refusal(error)));
1897 }
1898 body.data.map(Ok).ok_or_else(|| SourceError::Malformed {
1899 message: "GraphQL response has no data".into(),
1900 })
1901 }
1902
1903 // 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.
1904 /// The label predicates, which really are spelled the same at both levels.
1905 ///
1906 /// `IssueFilter.labels` is an `IssueLabelCollectionFilter` and `ProjectFilter.labels`
1907 /// is a `ProjectLabelCollectionFilter` — two types — but `some`, `every` and a `name`
1908 /// of `StringComparator` are members of both, so one spelling satisfies each. That is
1909 /// the whole of what the two filters have in common, and everything else about them is
1910 /// built separately for the reason recorded on the two builders below.
1911 ///
1912 /// "At least one of these" is a disjunction of `eqIgnoreCase` rather than one
1913 /// case-insensitive list operator, because Linear has no such operator. This source
1914 /// sent `labels:{some:{name:{inIgnoreCase:[…]}}}` until Linear refused it outright,
1915 /// HTTP 400, on the first read of the live lane that ever reached a label filter:
1916 ///
1917 /// ```text
1918 /// Variable "$filter" got invalid value { inIgnoreCase: […] } at
1919 /// "filter.and[1].labels.some.name"; Field "inIgnoreCase" is not defined by
1920 /// type "StringComparator". Did you mean "eqIgnoreCase" or "neqIgnoreCase"?
1921 /// ```
1922 ///
1923 /// That refusal is also the evidence for the replacement: Linear named the two members
1924 /// of `StringComparator` closest to what it was sent, and `eqIgnoreCase` is one of
1925 /// them — the same operator `all_of` below has always sent and the live lane has always
1926 /// exercised. `in` exists there too and would need no `or`, but it is case-sensitive,
1927 /// so `any_of` would stop agreeing with `all_of` and `none_of` and with what the table
1928 /// at the top of this file says this source does.
1929 fn label_parts(labels: &onetaskgraph_plugin_api::LabelFilter) -> Vec<Value> {
1930 let mut parts = Vec::new();
1931 if !labels.any_of.is_empty() {
1932 parts.push(json!({"or": labels
1933 .any_of
1934 .iter()
1935 .map(|name| json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}))
1936 .collect::<Vec<_>>()}));
1937 }
1938 for name in &labels.all_of {
1939 parts.push(json!({"labels": {"some": {"name": {"eqIgnoreCase": name}}}}));
1940 }
1941 for name in &labels.none_of {
1942 parts.push(json!({"labels": {"every": {"name": {"neqIgnoreCase": name}}}}));
1943 }
1944 parts
1945 }
1946 fn narrowed(mut parts: Vec<Value>) -> Value {
1947 if parts.len() == 1 {
1948 parts.pop().unwrap()
1949 } else {
1950 json!({"and": parts})
1951 }
1952 }
1953 /// The filter this source sends to `issues(filter:)`.
1954 ///
1955 /// **`IssueFilter` and `ProjectFilter` are different input types, and one builder for
1956 /// both is what put two wrong fields on the wire.** They read as though they were the
1957 /// same filter over different rows — the label member really is spelled alike, and the
1958 /// `and`/`or` are identical — and a single builder producing one object for both
1959 /// connections had shipped `team` and the issue's `state` shape into `projects(filter:)`
1960 /// since long before this branch. Linear refused the first outright:
1961 ///
1962 /// ```text
1963 /// Variable "$filter" got invalid value { team: { key: [Object] } };
1964 /// Field "team" is not defined by type "ProjectFilter". Did you mean "lead"?
1965 /// ```
1966 ///
1967 /// So there are two builders, and each names its own type's members. Adding a predicate
1968 /// means deciding twice, on purpose, rather than once by accident.
1969 fn issue_filter(&self, query: &TaskQuery) -> Result<Value, SourceError> {
1970 let mut parts = self.issue_scope();
1971 parts.extend(Self::label_parts(&query.labels));
1972 if !query.statuses.is_empty() {
1973 parts.extend(self.status_narrowing(ItemKind::Task, &query.statuses));
1974 }
1975 match &query.project {
1976 ProjectFilter::Orphans => parts.push(json!({"project": {"null": true}})),
1977 ProjectFilter::Is(id) => parts.push(json!({"project": {"id": {"eq": id.0}}})),
1978 ProjectFilter::Any => {}
1979 }
1980 // The narrowings below are each confirmed in process by `confirms` before a row is
1981 // returned: Linear's comparators are a candidate set, and the contract's predicate is
1982 // what decides. None of them can drop a row the predicate keeps — see the module
1983 // documentation's ruling on the follow-up searches for what each one rests on.
1984 if !query.priorities.is_empty() {
1985 parts.push(json!({"priority": {"in": query
1986 .priorities
1987 .iter()
1988 .map(|priority| linear_priority(*priority))
1989 .collect::<Vec<_>>()}}));
1990 }
1991 if let Some(since) = query.commented_since {
1992 let since = since.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true);
1993 parts.push(json!({"comments": {"some": {"or": [
1994 {"createdAt": {"gte": since}},
1995 {"updatedAt": {"gte": since}},
1996 ]}}}));
1997 }
1998 for wanted in &query.metadata {
1999 parts.extend(slot_phrase(wanted.value()));
2000 }
2001 if let Some(origin) = &query.origin {
2002 parts.extend(slot_phrase(origin));
2003 }
2004 if let Some(text) = &query.text {
2005 let title = json!({"title": {"containsIgnoreCase": text.terms}});
2006 let content = json!({"description": {"containsIgnoreCase": text.terms}});
2007 parts.push(match text.fields {
2008 TextFields::Title => title,
2009 TextFields::Content => content,
2010 TextFields::TitleOrContent => json!({"or": [title, content]}),
2011 });
2012 }
2013 Ok(Self::narrowed(parts))
2014 }
2015
2016 /// What every issue read of this source is narrowed to before any predicate: the
2017 /// configured team, and the project this source is scoped to, when it is.
2018 fn issue_scope(&self) -> Vec<Value> {
2019 let mut parts = Vec::new();
2020 if let Some(team) = &self.team {
2021 parts.push(json!({"team": {"key": {"eqIgnoreCase": team.0}}}));
2022 }
2023 if let Some(project) = &self.project {
2024 parts.push(json!({"project": {"id": {"eq": project.0}}}));
2025 }
2026 parts
2027 }
2028
2029 /// The status narrowing for `statuses` over one kind's items, through this instance's
2030 /// mapping — `state` on an issue, `status` on a project, each compared by name.
2031 ///
2032 /// Each category asks for exactly what reads as it: the items at the name its kind maps it
2033 /// to, and — for `unknown` — every item at a name that kind's mapping does not name at
2034 /// all. A category the kind has no name for asks for nothing, because nothing reads as it.
2035 /// `None` is the one narrowing that is everything: `unknown` over a kind whose mapping
2036 /// names nothing, where every item reads as `unknown`.
2037 ///
2038 /// Compared ignoring case, as a read matches a name: `eqIgnoreCase` for a mapped name and
2039 /// `neqIgnoreCase` for every name an `unknown` excludes, because `in` and `nin` are the
2040 /// case-sensitive list operators and a state spelled `TODO` reads as a mapping's `Todo`.
2041 fn status_narrowing(&self, kind: ItemKind, statuses: &[StatusCategory]) -> Option<Value> {
2042 let member = match kind {
2043 ItemKind::Task => "state",
2044 ItemKind::Project => "status",
2045 };
2046 let named = |name: &str, operator: &str| json!({ (member): {"name": {(operator): name}}});
2047 let mut alternatives = Vec::new();
2048 for category in statuses {
2049 if let Ok(name) = self.statuses.name_for(*category, kind) {
2050 alternatives.push(named(name.as_str(), "eqIgnoreCase"));
2051 }
2052 if *category == StatusCategory::Unknown {
2053 let unmapped: Vec<Value> = self
2054 .statuses
2055 .names(kind)
2056 .map(|(_, name)| named(name.as_str(), "neqIgnoreCase"))
2057 .collect();
2058 if unmapped.is_empty() {
2059 return None;
2060 }
2061 alternatives.push(Self::narrowed(unmapped));
2062 }
2063 }
2064 Some(match alternatives.len() {
2065 // Nothing asked for has a name of this kind: a name no item is at, which matches
2066 // nothing and is refused by nothing.
2067 0 => json!({ (member): {"name": {"in": Vec::<&str>::new()}}}),
2068 1 => alternatives.pop().expect("one alternative"),
2069 _ => json!({ "or": alternatives }),
2070 })
2071 }
2072
2073 /// Whether one task this source read satisfies every predicate of `query` that a
2074 /// narrowing above only approximates — the metadata matches, the origin, the text and the
2075 /// priorities — by the contract's own statement of each.
2076 fn confirms(query: &TaskQuery, task: &Task) -> bool {
2077 (query.statuses.is_empty() || query.statuses.contains(&task.status.category))
2078 && query.metadata_matches(&task.metadata)
2079 && query.origin_matches(&task.metadata)
2080 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
2081 && query
2082 .text
2083 .as_ref()
2084 .is_none_or(|text| text_holds(&task.title, task.content.as_deref(), text))
2085 }
2086
2087 /// The filter this source sends to `projects(filter:)`.
2088 ///
2089 /// Two members differ from [`Self::issue_filter`] and both are Linear's doing; see that
2090 /// builder for why they are written out twice rather than shared.
2091 ///
2092 /// **A project has no `team`.** It has the teams it is accessible from, and
2093 /// `ProjectFilter.accessibleTeams` is a `TeamCollectionFilter`, so the same team key
2094 /// reaches it under `some:`. `leadTeam` is the other team-shaped member and is a
2095 /// different set — one designated team rather than every team the project is in — so
2096 /// narrowing by it would drop projects the configured team really does hold.
2097 ///
2098 /// **A project's status is not an issue's state.** An issue's is a `WorkflowState` of the
2099 /// team, reached through `IssueFilter.state`; a project's is a `ProjectStatus` of the
2100 /// workspace, reached through `ProjectFilter.status` — `ProjectFilter.state` exists and is
2101 /// *not* it: that member is a bare `StringComparator` over a different thing. The two are
2102 /// different vocabularies of names, which is why `status_mapping` names each kind's
2103 /// separately, and why each is narrowed by its own kind's names
2104 /// ([`Self::status_narrowing`]).
2105 fn project_filter(
2106 &self,
2107 labels: &onetaskgraph_plugin_api::LabelFilter,
2108 statuses: &[StatusCategory],
2109 ) -> Value {
2110 let mut parts = self.project_scope();
2111 parts.extend(Self::label_parts(labels));
2112 if !statuses.is_empty() {
2113 parts.extend(self.status_narrowing(ItemKind::Project, statuses));
2114 }
2115 Self::narrowed(parts)
2116 }
2117
2118 /// What every project read of this source is narrowed to: the projects the configured
2119 /// team can reach, and the one project this source is scoped to, when it is.
2120 fn project_scope(&self) -> Vec<Value> {
2121 let mut parts = Vec::new();
2122 if let Some(team) = &self.team {
2123 parts.push(json!({"accessibleTeams": {"some": {"key": {"eqIgnoreCase": team.0}}}}));
2124 }
2125 if let Some(project) = &self.project {
2126 parts.push(json!({"id": {"eq": project.0}}));
2127 }
2128 parts
2129 }
2130
2131 /// Whether an item filed under `project` is one this source holds: always, unless it is
2132 /// scoped to one project and this is not filed under it.
2133 fn in_scope(&self, project: Option<&NativeId>) -> bool {
2134 self.project
2135 .as_ref()
2136 .is_none_or(|scope| project.is_some_and(|project| project.0 == scope.0))
2137 }
2138
2139 /// The project a task or a document written with `project` is filed under: that one, or
2140 /// the scope when it names none — and a refusal naming both when it names another.
2141 fn filed_in(
2142 &self,
2143 project: Option<&NativeId>,
2144 what: &str,
2145 ) -> Result<Option<String>, SourceError> {
2146 match (&self.project, project) {
2147 (None, project) => Ok(project.map(|id| id.0.clone())),
2148 (Some(scope), None) => Ok(Some(scope.0.clone())),
2149 (Some(scope), Some(project)) if project.0 == scope.0 => Ok(Some(scope.0.clone())),
2150 (Some(scope), Some(project)) => Err(SourceError::Refused {
2151 message: format!(
2152 "source {} is scoped to the Linear project {} and cannot hold a {what} in the project {}; next: write it with no project, or with {}, or to a source scoped to {}",
2153 self.name, scope.0, project.0, scope.0, project.0
2154 ),
2155 }),
2156 }
2157 }
2158 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2159
2160 /// The refusal of a project write to `target` — `None` for a new project — when this
2161 /// source is scoped to another: it holds that project and no other, so it writes no other,
2162 /// because a project it created would be one none of its reads could find. Asked before
2163 /// any request is sent.
2164 fn project_out_of_scope(&self, target: Option<&NativeId>) -> Option<SourceError> {
2165 let scope = self.project.as_ref()?;
2166 if target.is_some_and(|target| target.0 == scope.0) {
2167 return None;
2168 }
2169 Some(SourceError::Refused {
2170 message: format!(
2171 "source {} is scoped to the Linear project {} and holds no other project, so it \
2172 cannot write {}; next: copy the project to a source with no `project`, or copy \
2173 its tasks here",
2174 self.name,
2175 scope.0,
2176 target.map_or_else(
2177 || "a new one".to_owned(),
2178 |target| format!("the project {}", target.0)
2179 ),
2180 ),
2181 })
2182 }
2183
2184 async fn one_id(&self, lookup: Lookup<'_>) -> Result<NativeId, SourceError> {
2185 let data = self.send(lookup.query(), lookup.variables()).await?;
2186 let connection = lookup.connection();
2187 let nodes = data
2188 .get(connection)
2189 .and_then(|v| v.get("nodes"))
2190 .and_then(Value::as_array)
2191 .ok_or_else(|| SourceError::Malformed {
2192 message: format!("missing {connection}.nodes"),
2193 })?;
2194 match nodes.as_slice() {
2195 [] => Err(SourceError::Refused {
2196 message: format!(
2197 "source {} cannot resolve {}: found 0 matches",
2198 self.name,
2199 lookup.diagnostic()
2200 ),
2201 }),
2202 [node] => Ok(NativeId(backend_id(node, "id")?.to_owned())),
2203 nodes => {
2204 let ids = nodes
2205 .iter()
2206 .map(|node| backend_id(node, "id"))
2207 .collect::<Result<Vec<_>, _>>()?;
2208 Err(SourceError::Refused {
2209 message: format!(
2210 "source {} cannot resolve {}: found {} matches with ids {ids:?}",
2211 self.name,
2212 lookup.diagnostic(),
2213 nodes.len()
2214 ),
2215 })
2216 }
2217 }
2218 }
2219
2220 /// What a status write resolves, as this instance holds it — read in one request the first
2221 /// time anything needs it, or again when `fresh` asks — and whether this call read it.
2222 ///
2223 /// Only an answer that read completely is held, so a failed or malformed read leaves the
2224 /// next call reading again rather than resolving against nothing.
2225 async fn vocabulary(
2226 &self,
2227 fresh: bool,
2228 ) -> Result<(std::sync::Arc<Vocabulary>, bool), SourceError> {
2229 if !fresh && let Some(held) = self.held_vocabulary() {
2230 return Ok((held, false));
2231 }
2232 let team = self.team.as_ref().ok_or_else(|| SourceError::Refused {
2233 message: format!(
2234 "source {} needs config.team before it can write a Linear item or a status",
2235 self.name
2236 ),
2237 })?;
2238 let data = self
2239 .send(graphql::RESOLUTION, json!({"key": team.0}))
2240 .await?;
2241 let read = std::sync::Arc::new(Vocabulary::read(&data, &self.name, &team.0)?);
2242 *self
2243 .vocabulary
2244 .lock()
2245 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(read.clone());
2246 Ok((read, true))
2247 }
2248
2249 fn held_vocabulary(&self) -> Option<std::sync::Arc<Vocabulary>> {
2250 self.vocabulary
2251 .lock()
2252 .unwrap_or_else(std::sync::PoisonError::into_inner)
2253 .clone()
2254 }
2255
2256 /// Drop what this instance holds, after a write carrying one of its ids failed: the id may
2257 /// be what Linear refused, and the next write reads afresh rather than sending it again.
2258 fn forget_vocabulary(&self) {
2259 *self
2260 .vocabulary
2261 .lock()
2262 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2263 }
2264
2265 /// Hold one name `sources fields --apply` created beside the ones read, so the writes after
2266 /// it resolve it without another read.
2267 fn remember(&self, created: &Created) {
2268 let mut guard = self
2269 .vocabulary
2270 .lock()
2271 .unwrap_or_else(std::sync::PoisonError::into_inner);
2272 if let Some(vocabulary) = guard.as_mut() {
2273 std::sync::Arc::make_mut(vocabulary).add(created.clone());
2274 }
2275 }
2276
2277 async fn team_id(&self) -> Result<NativeId, SourceError> {
2278 Ok(self.vocabulary(false).await?.0.team.clone())
2279 }
2280
2281 /// The id and the name Linear holds of the name `category` is for `kind` — refused before
2282 /// any request when the mapping gives that kind no name for it, and refused naming the name
2283 /// when the kind's vocabulary lacks it even after one fresh read.
2284 async fn status_id(
2285 &self,
2286 category: StatusCategory,
2287 kind: ItemKind,
2288 ) -> Result<(NativeId, String), SourceError> {
2289 let name = self
2290 .statuses
2291 .name_for(category, kind)
2292 .map_err(|why| why.refusal(&self.name, category, kind))?;
2293 let (vocabulary, fresh) = self.vocabulary(false).await?;
2294 if let Some(held) = vocabulary.find(kind, name.as_str(), &self.name)? {
2295 return Ok((held.id.clone(), held.name.as_str().to_owned()));
2296 }
2297 if !fresh {
2298 let (vocabulary, _) = self.vocabulary(true).await?;
2299 if let Some(held) = vocabulary.find(kind, name.as_str(), &self.name)? {
2300 return Ok((held.id.clone(), held.name.as_str().to_owned()));
2301 }
2302 }
2303 let category_key = category_word(category);
2304 let kind_key = kind_word(kind);
2305 let held_by = match kind {
2306 ItemKind::Task => format!(
2307 "team {}",
2308 self.team.as_ref().map_or("(none)", |team| team.0.as_str())
2309 ),
2310 ItemKind::Project => "this workspace".to_owned(),
2311 };
2312 Err(SourceError::Refused {
2313 message: format!(
2314 "source {} maps the {kind_key} status {category_key} to the {} {:?}, which {held_by} \
2315 does not have; next: run `onetaskgraph sources fields {} --apply` to create it, \
2316 or point status_mapping.{category_key}.{kind_key} of this source at a {} \
2317 {held_by} has",
2318 self.name,
2319 vocabulary_word(kind),
2320 name.as_str(),
2321 self.name,
2322 vocabulary_word(kind),
2323 ),
2324 })
2325 }
2326
2327 async fn label_ids(
2328 &self,
2329 labels: &[Label],
2330 kind: WriteKind,
2331 ) -> Result<Vec<NativeId>, SourceError> {
2332 let mut ids = Vec::with_capacity(labels.len());
2333 for label in labels {
2334 ids.push(
2335 self.one_id(if matches!(kind, WriteKind::Project) {
2336 Lookup::ProjectLabel(&label.name)
2337 } else {
2338 Lookup::IssueLabel(&label.name)
2339 })
2340 .await?,
2341 );
2342 }
2343 Ok(ids)
2344 }
2345 fn write_description(
2346 &self,
2347 content: Option<&str>,
2348 metadata: &std::collections::BTreeMap<String, Value>,
2349 repositories: &[Repository],
2350 classification: Classification,
2351 edges: &[DependencyEdge],
2352 kind: WriteKind,
2353 ) -> Result<Option<String>, SourceError> {
2354 Self::long_form(
2355 content,
2356 metadata,
2357 repositories,
2358 classification,
2359 self.recorded_ends(edges, kind),
2360 )
2361 }
2362
2363 /// The far ends of `edges` no relation of this workspace can name — another level, or
2364 /// another source — as the reserved key records them.
2365 fn recorded_ends(&self, edges: &[DependencyEdge], kind: WriteKind) -> Vec<Value> {
2366 edges
2367 .iter()
2368 .filter(|edge| {
2369 edge.to.kind
2370 != match kind {
2371 WriteKind::Task => ItemKind::Task,
2372 WriteKind::Project => ItemKind::Project,
2373 }
2374 || edge.to.designator().is_some()
2375 || edge
2376 .to
2377 .id()
2378 .split_once(':')
2379 .is_some_and(|(source, _)| source != self.name.as_str())
2380 })
2381 .map(|edge| serde_json::to_value(&edge.to).expect("an endpoint renders as JSON"))
2382 .collect()
2383 }
2384
2385 /// Every forward edge `id` holds, relations and recorded far ends alike, walked to
2386 /// exhaustion.
2387 async fn forward_edges(&self, id: &NativeId) -> Result<Vec<DependencyEdge>, SourceError> {
2388 let mut edges = Vec::new();
2389 let mut cursor = None;
2390 loop {
2391 // The plain document even with the cache attached: a write's own read of the edges
2392 // it replaces fills no entry.
2393 let page = self
2394 .dependencies(
2395 graphql::ISSUE_DEPENDS_ON,
2396 DependencyRoot::Issue,
2397 id,
2398 Direction::DependsOn,
2399 &PageRequest {
2400 cursor,
2401 limit: MAX_PAGE_SIZE,
2402 },
2403 )
2404 .await?;
2405 edges.extend(page.items);
2406 match page.next {
2407 Some(next) => cursor = Some(next),
2408 None => return Ok(edges),
2409 }
2410 }
2411 }
2412
2413 /// Apply one targeted update to one issue; see [`TaskSource::update_task`].
2414 ///
2415 /// **An update naming a status and nothing else reads nothing first:** it is one
2416 /// `issueUpdate` carrying the `stateId` its category's task name resolves to, whose own
2417 /// selection answers the task — see [`Self::status_written`]. Writing a category the issue
2418 /// already reads as therefore sends the same state again, and an issue at a name the
2419 /// mapping does not name, which reads as `unknown`, moves to the mapped `unknown` name.
2420 ///
2421 /// **Any other update reads the issue once**, then sends one `issueUpdate` carrying only
2422 /// the members that differ from it — `title`, `description` (content and metadata slot
2423 /// together), `stateId`, `priority` — and, when the named edges differ from the ones the
2424 /// issue holds, its relations replaced. The read is what the description is merged from:
2425 /// Linear has no conditional update, so writing the metadata slot without it would
2426 /// overwrite whatever a person wrote there since. Nothing is sent for a field already
2427 /// holding the requested value, and nothing at all when nothing differs. `delivers` lands
2428 /// in the metadata slot under its reserved key, as a copy writes it.
2429 ///
2430 /// Either way the task answered is the one the mutation's own selection reports, so its
2431 /// status is Linear's, and nothing is read after the write.
2432 async fn targeted_update(
2433 &self,
2434 id: &NativeId,
2435 update: &TaskUpdate,
2436 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
2437 update.consistent()?;
2438 if let Some(delivers) = &update.delivers {
2439 TaskRef::listed(
2440 TaskRef::DELIVERS_KEY,
2441 id,
2442 Some(&self.name),
2443 delivers.clone(),
2444 )
2445 .map_err(|message| SourceError::Refused { message })?;
2446 }
2447 // Before any request: a category the mapping gives a task no name for is not one
2448 // Linear could answer differently for another issue.
2449 if let Some(status) = &update.status {
2450 self.statuses
2451 .name_for(status.category, ItemKind::Task)
2452 .map_err(|why| why.refusal(&self.name, status.category, ItemKind::Task))?;
2453 }
2454 if let Some(status) = update.status.as_ref().filter(|_| {
2455 TaskUpdate {
2456 status: None,
2457 ..update.clone()
2458 }
2459 .is_empty()
2460 }) {
2461 let Some(task) = self.status_written(id, status.category).await? else {
2462 return Ok(None);
2463 };
2464 return Ok(Some(TaskUpdateOutcome {
2465 delivers_before: task.delivers.clone(),
2466 written: std::iter::once(UpdatedField::Status).collect(),
2467 task,
2468 }));
2469 }
2470 let held = if update.status.is_some() && self.held_vocabulary().is_none() {
2471 self.issue_resolving(id).await?
2472 } else {
2473 self.issue_held(id, Read::ForWrite).await?
2474 };
2475 let Some((before, description)) = held else {
2476 return Ok(None);
2477 };
2478 let (visible, held) = metadata_description(description)?;
2479 let mut slot = held.clone();
2480 for (key, value) in &update.metadata_set {
2481 slot.insert(key.as_str().to_owned(), value.clone());
2482 }
2483 for key in &update.metadata_remove {
2484 slot.remove(key.as_str());
2485 }
2486 if let Some(delivers) = &update.delivers {
2487 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
2488 }
2489 let mut relations = None;
2490 if let Some(wanted) = &update.depends_on {
2491 let current = self.forward_edges(&before.id).await?;
2492 let ends = |edges: &[DependencyEdge]| {
2493 let mut ends: Vec<(String, String)> = edges
2494 .iter()
2495 .map(|edge| {
2496 (
2497 edge.to.id().to_owned(),
2498 format!("{:?}{:?}", edge.to.kind, edge.kind),
2499 )
2500 })
2501 .collect();
2502 ends.sort();
2503 ends
2504 };
2505 if ends(¤t) != ends(wanted) {
2506 let prepared = self.prepare_edges(wanted, WriteKind::Task).await?;
2507 let recorded = self.recorded_ends(&prepared, WriteKind::Task);
2508 if recorded.is_empty() {
2509 slot.remove(DependencyEdge::RECORDED_KEY);
2510 } else {
2511 slot.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
2512 }
2513 relations = Some(prepared);
2514 }
2515 }
2516 let mut input = serde_json::Map::new();
2517 if let Some(title) = update
2518 .title
2519 .as_ref()
2520 .filter(|title| **title != before.title)
2521 {
2522 input.insert("title".into(), json!(title));
2523 }
2524 let content = update.content.as_deref().or(visible.as_deref());
2525 if content != visible.as_deref() || slot != held {
2526 let written = Self::described(content, &slot)?;
2527 // Checked before anything is sent: content ending in what this source reads as
2528 // its own slot would read back as metadata rather than as the content it was.
2529 let (reads, read) = metadata_description(written.clone())?;
2530 if reads.as_deref().unwrap_or_default() != content.unwrap_or_default() || read != slot {
2531 return Err(SourceError::Refused {
2532 message: format!(
2533 "this content would read back from source {} as something other than \
2534 itself, or ends in what it reads as its own metadata slot; next: change \
2535 how the content ends",
2536 self.name
2537 ),
2538 });
2539 }
2540 input.insert("description".into(), json!(written));
2541 }
2542 if let Some(status) = update
2543 .status
2544 .as_ref()
2545 .filter(|status| status.category != before.status.category)
2546 {
2547 let (state, _) = self.status_id(status.category, ItemKind::Task).await?;
2548 input.insert("stateId".into(), json!(state.0));
2549 }
2550 if let Some(priority) = update
2551 .priority
2552 .filter(|priority| *priority != before.priority)
2553 {
2554 input.insert("priority".into(), json!(linear_priority(priority)));
2555 }
2556 let carries_state = input.contains_key("stateId");
2557 let task = if input.is_empty() {
2558 before.clone()
2559 } else {
2560 let answered = self
2561 .send(
2562 graphql::ISSUE_UPDATE_READ,
2563 json!({"id":before.id.0,"input":Value::Object(input)}),
2564 )
2565 .await
2566 .and_then(|data| self.updated_issue(&data, &before.id));
2567 match answered {
2568 Ok(Some(task)) => task,
2569 Ok(None) => {
2570 return Err(SourceError::Malformed {
2571 message: format!("task {id} was updated and then answered as no issue"),
2572 });
2573 }
2574 Err(error) => {
2575 if carries_state {
2576 self.forget_vocabulary();
2577 }
2578 return Err(error);
2579 }
2580 }
2581 };
2582 if let Some(prepared) = &relations {
2583 self.write_relations(&before.id, prepared, WriteKind::Task, HeldRelations::Unread)
2584 .await?;
2585 }
2586 let mut written = update.changed(&before, &task);
2587 if relations.is_some() {
2588 written.insert(UpdatedField::DependsOn);
2589 }
2590 Ok(Some(TaskUpdateOutcome {
2591 task,
2592 written,
2593 delivers_before: before.delivers,
2594 }))
2595 }
2596
2597 /// Set one issue's state to the one `category`'s task name resolves to, in one
2598 /// `issueUpdate` whose selection answers the task as Linear now holds it — or `None` when
2599 /// Linear holds no such issue, which it says by refusing the mutation as naming nothing.
2600 ///
2601 /// No read before it, so this is the whole of a status write's cost once the resolution is
2602 /// held: one request — for a source scoped to one project as well. A status write goes to
2603 /// the issue it names wherever that issue is filed, because the item is named outright and
2604 /// the scope governs what this source reads, lists and creates, not where a status it is
2605 /// asked to set may land.
2606 async fn status_written(
2607 &self,
2608 id: &NativeId,
2609 category: StatusCategory,
2610 ) -> Result<Option<Task>, SourceError> {
2611 let (state, _) = self.status_id(category, ItemKind::Task).await?;
2612 let target = id.clone();
2613 // `stateId` alone, so nothing else about the issue can move: Linear's
2614 // `IssueUpdateInput` makes every member optional and leaves an absent one as it was.
2615 let answered = self
2616 .send_to_held(
2617 graphql::ISSUE_UPDATE_READ,
2618 json!({"id":target.0,"input":{"stateId":state.0}}),
2619 )
2620 .await;
2621 match answered {
2622 Ok(Some(data)) => self.updated_issue(&data, &target),
2623 Ok(None) => Ok(None),
2624 Err(error) => {
2625 self.forget_vocabulary();
2626 Err(error)
2627 }
2628 }
2629 }
2630
2631 /// The task an `issueUpdate` sent as [`graphql::ISSUE_UPDATE_READ`] answered with — `None`
2632 /// for an issue in the trash, on the terms a read by id answers — refusing an answer about
2633 /// another issue than the one asked for.
2634 ///
2635 /// Not narrowed to this source's scope: the issue was named outright and has been written,
2636 /// so it is answered wherever it is filed.
2637 ///
2638 /// `asked` may be the backend id or the identifier (`ENG-1`), because Linear takes either.
2639 fn updated_issue(&self, data: &Value, asked: &NativeId) -> Result<Option<Task>, SourceError> {
2640 let issue = mutation_payload(data, MutationRoot::IssueUpdate)?
2641 .get("issue")
2642 .filter(|issue| !issue.is_null())
2643 .ok_or_else(|| SourceError::Malformed {
2644 message: "missing issueUpdate.issue".into(),
2645 })?;
2646 if optional_str(issue, "identifier")? != Some(asked.0.as_str()) {
2647 written_is(issue, asked)?;
2648 }
2649 optional(&json!({ "issue": issue }), "issue", |v| {
2650 map_task(v, &self.name, &self.statuses, self.spelling())
2651 })
2652 }
2653
2654 /// The one long-form field a Linear item has, with this source's own slot at the end.
2655 ///
2656 /// Shared by every kind this source writes rather than reimplemented per kind: a
2657 /// document keeps caller metadata in exactly the slot an issue and a project do, which
2658 /// is what lets the same read side take it back out.
2659 fn long_form(
2660 content: Option<&str>,
2661 metadata: &std::collections::BTreeMap<String, Value>,
2662 repositories: &[Repository],
2663 classification: Classification,
2664 recorded: Vec<Value>,
2665 ) -> Result<Option<String>, SourceError> {
2666 let mut metadata = metadata.clone();
2667 classification.record(&mut metadata);
2668 if repositories.is_empty() {
2669 metadata.remove(Repository::METADATA_KEY);
2670 } else {
2671 metadata.insert(Repository::METADATA_KEY.into(), json!(repositories));
2672 }
2673 if recorded.is_empty() {
2674 metadata.remove(DependencyEdge::RECORDED_KEY);
2675 } else {
2676 metadata.insert(DependencyEdge::RECORDED_KEY.into(), Value::Array(recorded));
2677 }
2678 Self::described(content, &metadata)
2679 }
2680
2681 /// The long-form field holding `content` and a slot of exactly `metadata`, in the one
2682 /// encoding [`long_form`](Self::long_form) writes: the content alone when there is no
2683 /// metadata, and otherwise the slot after one blank line.
2684 fn described(
2685 content: Option<&str>,
2686 metadata: &std::collections::BTreeMap<String, Value>,
2687 ) -> Result<Option<String>, SourceError> {
2688 let visible = content.unwrap_or_default();
2689 if metadata.is_empty() {
2690 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2691 }
2692 let slot = slot_text(metadata)?;
2693 Ok(Some(if visible.is_empty() {
2694 slot
2695 } else {
2696 format!("{visible}\n\n{slot}")
2697 }))
2698 }
2699 /// What this source says when asked for a project edge carrying no ordering.
2700 ///
2701 /// Linear's project relations have exactly one type and it is an ordering. Asked on
2702 /// 2026-09-04 to create one typed `related` — and separately `blocks` and `dependsOn`
2703 /// — the real API refused each with `Argument Validation Error` and
2704 /// `constraints: {"isEnum": "type must be one of the following values: dependency"}`.
2705 /// That is Linear's own enumeration of the field, from the validator behind GraphQL
2706 /// where introspection cannot reach it, and it has one member. An issue relation is a
2707 /// different relation with a different set, which does include `related`, so this
2708 /// reaches projects alone.
2709 fn unordered_project_relation(&self, near: &NativeId, far: &str) -> SourceError {
2710 SourceError::Refused {
2711 message: format!(
2712 "source {} cannot carry an unordered dependency between projects, because \
2713 Linear types every project relation `dependency` and that is an ordering; \
2714 record {near} to {far} as a dependency, or between tasks",
2715 self.name,
2716 near = near.0,
2717 ),
2718 }
2719 }
2720 /// The one edge [`Self::unordered_project_relation`] refuses, if there is one here.
2721 fn unordered_project_edge(edges: &[DependencyEdge]) -> Option<&DependencyEdge> {
2722 edges
2723 .iter()
2724 .find(|edge| edge.to.kind == ItemKind::Project && edge.kind == DependencyKind::Related)
2725 }
2726 /// Replace every relation `near` holds of `kind` with exactly `edges`: the ones it holds
2727 /// are deleted, then each edge is created.
2728 ///
2729 /// `held` is what is already known of the ones it holds: none, for an item the write this
2730 /// follows created; the first page of them, for one a rewrite's own answer reported; and
2731 /// otherwise nothing, so they are read. Only a page that says there are more is followed.
2732 async fn write_relations(
2733 &self,
2734 near: &NativeId,
2735 edges: &[DependencyEdge],
2736 kind: WriteKind,
2737 held: HeldRelations<'_>,
2738 ) -> Result<(), SourceError> {
2739 let mut cursor: Option<Cursor> = None;
2740 let mut answered = match held {
2741 HeldRelations::None => None,
2742 HeldRelations::Page(page) => Some(page.clone()),
2743 HeldRelations::Unread => Some(Value::Null),
2744 };
2745 while let Some(page) = answered.take() {
2746 let relations = if page.is_null() {
2747 let data = self
2748 .send(
2749 // The plain documents even with the cache attached: this read neither fills
2750 // an entry nor pins a read, so the token's parts would only add complexity.
2751 if matches!(kind, WriteKind::Project) {
2752 PROJECT_RELATIONS
2753 } else {
2754 ISSUE_RELATIONS
2755 },
2756 json!({"id":near.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor|&cursor.0)}),
2757 )
2758 .await?;
2759 let root = data
2760 .get(if matches!(kind, WriteKind::Project) {
2761 "project"
2762 } else {
2763 "issue"
2764 })
2765 .ok_or_else(|| SourceError::Malformed {
2766 message: "missing relation item".into(),
2767 })?;
2768 root.get("relations")
2769 .cloned()
2770 .ok_or_else(|| SourceError::Malformed {
2771 message: "missing relations".into(),
2772 })?
2773 } else {
2774 page
2775 };
2776 for relation in relations
2777 .get("nodes")
2778 .and_then(Value::as_array)
2779 .ok_or_else(|| SourceError::Malformed {
2780 message: "missing relations.nodes".into(),
2781 })?
2782 {
2783 let id = backend_id(relation, "id")?;
2784 let (query, mutation) = if matches!(kind, WriteKind::Project) {
2785 (
2786 graphql::PROJECT_RELATION_DELETE,
2787 MutationRoot::ProjectRelationDelete,
2788 )
2789 } else {
2790 (
2791 graphql::ISSUE_RELATION_DELETE,
2792 MutationRoot::IssueRelationDelete,
2793 )
2794 };
2795 let deleted = self.send(query, json!({"id":id})).await?;
2796 mutation_payload(&deleted, mutation)?;
2797 }
2798 if let Some(next) = page_next(&relations)? {
2799 cursor = Some(next);
2800 answered = Some(Value::Null);
2801 }
2802 }
2803 // Linear requires an anchor at each end of a project relation and validates both
2804 // against an enum GraphQL cannot see: `ProjectRelationCreateInput` declares them
2805 // `String!` and enumerates nothing, and the field descriptions read as a choice
2806 // between the project and a milestone, which is not what they are. Linear's own
2807 // refusal enumerates them — sent `project` in both, it answered `anchorType must
2808 // be one of the following values: start, end, milestone` — and `milestone` needs
2809 // an id this source never sends, so the two whole-project anchors are the whole of
2810 // what it can send.
2811 //
2812 // **Which of them goes where carries the direction, and the two id slots do not.**
2813 // Linear stores whatever pair it is given and reads a backwards dependency as
2814 // readily as the right one, so acceptance settles nothing; what does is Linear's
2815 // own reading of a stored relation, published as the computed `ProjectFilter`
2816 // members `hasBlockingRelations` ("projects which are blocking") and
2817 // `hasBlockedByRelations` ("projects which are blocked"). Three relations between
2818 // two scratch projects, read back through them on 2026-09-04:
2819 //
2820 // | `projectId` | `anchorType` | `relatedProjectId` | `relatedAnchorType` | blocked | blocking |
2821 // | ----------- | ------------ | ------------------ | ------------------- | ------- | -------- |
2822 // | A | `start` | B | `end` | A | B |
2823 // | A | `end` | B | `start` | B | A |
2824 // | B | `end` | A | `start` | A | B |
2825 //
2826 // Rows one and three exchange the ids and the anchors together and read alike;
2827 // rows one and two exchange only the anchors and the reading flips. So the project
2828 // anchored `start` is the one that waits, whichever slot it sits in, and row one is
2829 // what this source sends — `near`, the item that depends, in `projectId`. Linear's
2830 // own callers put the blocker there instead, so copying their `end`/`start` pair
2831 // across by position would state every dependency backwards in the workspace, and
2832 // nothing would refuse it.
2833 const NEAR_ANCHOR: &str = "start";
2834 const FAR_ANCHOR: &str = "end";
2835 for edge in edges {
2836 if edge.to.kind
2837 != match kind {
2838 WriteKind::Task => ItemKind::Task,
2839 WriteKind::Project => ItemKind::Project,
2840 }
2841 // An opaque far end names another tracker, which no relation here can hold.
2842 || edge.to.designator().is_some()
2843 {
2844 continue;
2845 }
2846 let far = match edge.to.id().split_once(':') {
2847 Some((source, native)) if source == self.name.as_str() => native,
2848 Some(_) => continue,
2849 None => edge.to.id(),
2850 };
2851 // A project relation is not spelled the way an issue relation is, and this is
2852 // the whole of what a project's `type` may say.
2853 //
2854 // `blocks` there is what the live journey's project write was refused for
2855 // once the two anchors above stopped being missing: Linear answered HTTP 200
2856 // with `Argument Validation Error`, the message class its input validator
2857 // raises for a value outside an accepted set, having already accepted every
2858 // field of the same input by name — which is what tells that refusal apart
2859 // from the missing-field one before it, and what says the anchors were not the
2860 // cause.
2861 //
2862 // Which field, and what it takes, was measured against the real API on
2863 // 2026-09-04 rather than inferred. Each of `blocks`, `dependsOn`, `related`
2864 // and `DEPENDENCY` was refused with `property: "type"` and
2865 // `constraints: {"isEnum": "type must be one of the following values:
2866 // dependency"}`; `dependency` was accepted. That enumeration, like the
2867 // anchors' above, reaches this source through the validator's `extensions`;
2868 // see `GqlError::said`.
2869 //
2870 // A `Related` project edge is refused at the top of this function by that same
2871 // enumeration: it has one member and it is an ordering. An issue relation is a
2872 // different relation with a different set, which does include `related`.
2873 let relation_type = match (kind, edge.kind) {
2874 (WriteKind::Project, DependencyKind::Blocks) => "dependency",
2875 (WriteKind::Task, DependencyKind::Blocks) => "blocks",
2876 (WriteKind::Task, DependencyKind::Related) => "related",
2877 // Unreachable past `write_project`'s guard, and an error rather than a
2878 // skip so it stays that way: an edge dropped here would be a copy
2879 // reporting success for a dependency the destination does not hold.
2880 (WriteKind::Project, DependencyKind::Related) => {
2881 return Err(self.unordered_project_relation(near, edge.to.id()));
2882 }
2883 };
2884 let (query, input) = if matches!(kind, WriteKind::Project) {
2885 (
2886 graphql::PROJECT_RELATION_CREATE,
2887 json!({"projectId":near.0,"relatedProjectId":far,"type":relation_type,"anchorType":NEAR_ANCHOR,"relatedAnchorType":FAR_ANCHOR}),
2888 )
2889 } else {
2890 (
2891 graphql::ISSUE_RELATION_CREATE,
2892 json!({"issueId":near.0,"relatedIssueId":far,"type":relation_type}),
2893 )
2894 };
2895 let data = self.send(query, json!({"input":input})).await?;
2896 let mutation = if matches!(kind, WriteKind::Project) {
2897 MutationRoot::ProjectRelationCreate
2898 } else {
2899 MutationRoot::IssueRelationCreate
2900 };
2901 let payload = mutation_payload(&data, mutation)?;
2902 let relation = payload
2903 .get(if matches!(kind, WriteKind::Project) {
2904 "projectRelation"
2905 } else {
2906 "issueRelation"
2907 })
2908 .ok_or_else(|| SourceError::Malformed {
2909 message: format!("missing {} relation", mutation.as_str()),
2910 })?;
2911 backend_id(relation, "id")?;
2912 }
2913 Ok(())
2914 }
2915
2916 async fn prepare_edges(
2917 &self,
2918 edges: &[DependencyEdge],
2919 kind: WriteKind,
2920 ) -> Result<Vec<DependencyEdge>, SourceError> {
2921 let mut prepared = Vec::with_capacity(edges.len());
2922 for edge in edges {
2923 let mut edge = edge.clone();
2924 if edge.to.kind
2925 == match kind {
2926 WriteKind::Task => ItemKind::Task,
2927 WriteKind::Project => ItemKind::Project,
2928 }
2929 && edge
2930 .to
2931 .id()
2932 .split_once(':')
2933 .is_some_and(|(source, _)| source != self.name.as_str())
2934 {
2935 // Narrowed to what this source holds — its team, and its project when it is
2936 // scoped to one — and, for an issue, to the ones whose description carries
2937 // the far end as its origin: a copy of the far end is an item of this source,
2938 // and an item of another team or project carrying the same origin is not one
2939 // this source could relate. The origin is confirmed over the parsed slot below.
2940 let filter = match kind {
2941 WriteKind::Project => Self::narrowed(self.project_scope()),
2942 WriteKind::Task => {
2943 let mut parts = self.issue_scope();
2944 parts.extend(slot_phrase(edge.to.id()));
2945 Self::narrowed(parts)
2946 }
2947 };
2948 let mut cursor: Option<Cursor> = None;
2949 loop {
2950 // The plain document even with the cache attached: this scan neither fills
2951 // an entry nor pins a read, so the relations page would only add complexity.
2952 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":filter})).await?;
2953 let (items, next) = if matches!(kind, WriteKind::Project) {
2954 let page = connection(&data, "projects", |v| {
2955 map_project(v, &self.statuses, self.spelling())
2956 })?;
2957 (
2958 page.items
2959 .into_iter()
2960 .map(|item| (item.id, item.metadata))
2961 .collect::<Vec<_>>(),
2962 page.next,
2963 )
2964 } else {
2965 let page = connection(&data, "issues", |v| {
2966 map_task(v, &self.name, &self.statuses, self.spelling())
2967 })?;
2968 (
2969 page.items
2970 .into_iter()
2971 .map(|item| (item.id, item.metadata))
2972 .collect::<Vec<_>>(),
2973 page.next,
2974 )
2975 };
2976 if let Some((id, _)) = items.into_iter().find(|(_, metadata)| {
2977 metadata.get("onetaskgraph.origin").and_then(Value::as_str)
2978 == Some(edge.to.id())
2979 }) {
2980 edge.to = DependencyEndpoint::from_native(id, edge.to.kind);
2981 break;
2982 }
2983 let Some(next) = next else { break };
2984 cursor = Some(next);
2985 }
2986 }
2987 prepared.push(edge);
2988 }
2989 Ok(prepared)
2990 }
2991}
2992
2993/// Why a Linear task's token does not vouch for its comments while no cache is attached.
2994const COMMENTS_UNATTACHED: &str = "a linear issue's updatedAt moved for none of a comment added, \
2995 edited or deleted (0 of 3 in spike-linear-coverage), so its token vouches for its comments \
2996 only once the cache attaches the comments page its comments part is read from";
2997
2998/// Why a Linear task's token does not vouch for its forward edges while no cache is attached.
2999const EDGES_UNATTACHED: &str = "a linear issue's relations moved no part of its token \
3000 (spike-linear-coverage) until the cache attaches the relations page its token is read from";
3001
3002/// Why a Linear task's token does not vouch for its reverse edges while no cache is attached.
3003const REVERSE_EDGES_UNATTACHED: &str = "a relation another linear issue records with this one \
3004 moves no part of this issue's token until the cache attaches the inverse relations page its \
3005 inverse part is read from";
3006
3007/// Why a Linear project's token does not vouch for its reverse edges while no cache is attached.
3008const PROJECT_REVERSE_EDGES_UNATTACHED: &str = "a linear project's updatedAt moved for 0 of 2 \
3009 relation changes another project made with it (spike-linear-coverage), so its token vouches \
3010 for its reverse edges only once the cache attaches the inverse relations page its inverse \
3011 part is read from";
3012
3013/// Why a Linear project's token does not vouch for its forward edges while no cache is attached.
3014const PROJECT_EDGES_UNATTACHED: &str = "a linear project's updatedAt moved for 0 of 2 relation \
3015 changes (spike-linear-coverage), so its token vouches for its forward edges only once the \
3016 cache attaches the relations page its relations part is read from";
3017
3018/// Why a Linear document's token does not vouch for its assets.
3019const DOCUMENT_ASSETS_UNCOVERED: &str =
3020 "linear's asset reads send no request, so there is nothing for a cache to save";
3021
3022#[async_trait::async_trait]
3023impl TaskSource for LinearSource {
3024 fn kind(&self) -> &'static str {
3025 KIND
3026 }
3027 /// A task's fields always, and its comments and forward edges once the cache is attached.
3028 /// §4.13d of `docs/plugin-protocol.md` states each with what `spike-linear-coverage`
3029 /// measured.
3030 fn version_coverage(&self) -> onetaskgraph_plugin_api::VersionCoverage {
3031 onetaskgraph_plugin_api::VersionCoverage {
3032 fields: onetaskgraph_plugin_api::Coverage::Covered,
3033 comments: self.attached_coverage(COMMENTS_UNATTACHED),
3034 forward_edges: self.attached_coverage(EDGES_UNATTACHED),
3035 }
3036 }
3037 /// A project's fields always, and its forward edges once the cache is attached.
3038 fn project_version_coverage(&self) -> onetaskgraph_plugin_api::ProjectVersionCoverage {
3039 onetaskgraph_plugin_api::ProjectVersionCoverage {
3040 fields: onetaskgraph_plugin_api::Coverage::Covered,
3041 forward_edges: self.attached_coverage(PROJECT_EDGES_UNATTACHED),
3042 }
3043 }
3044 /// A task's and a project's reverse edges once the cache is attached: the token then carries
3045 /// the digest of its first page of inverse relations, which moves for every relation another
3046 /// item adds or removes with it.
3047 fn reverse_edges_coverage(&self, kind: ItemKind) -> onetaskgraph_plugin_api::Coverage {
3048 self.attached_coverage(match kind {
3049 ItemKind::Task => REVERSE_EDGES_UNATTACHED,
3050 ItemKind::Project => PROJECT_REVERSE_EDGES_UNATTACHED,
3051 })
3052 }
3053 /// A document's fields; never its asset manifest.
3054 fn document_version_coverage(&self) -> onetaskgraph_plugin_api::DocumentVersionCoverage {
3055 onetaskgraph_plugin_api::DocumentVersionCoverage {
3056 fields: onetaskgraph_plugin_api::Coverage::Covered,
3057 assets: onetaskgraph_plugin_api::Coverage::Uncovered {
3058 reason: DOCUMENT_ASSETS_UNCOVERED.to_owned(),
3059 },
3060 }
3061 }
3062 fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
3063 change_token::order(a, b)
3064 }
3065 /// From here on, every issue read carries its first page of comments, relations and inverse
3066 /// relations, every project read its first page of relations, and a dependency read the
3067 /// token's other parts — see [`graphql::ISSUES_CACHED`] and the documents beside it
3068 /// — so a task's token covers its comments and its edges both ways, and a project's its
3069 /// forward edges.
3070 fn attach_cache(&self) {
3071 self.attached
3072 .store(true, std::sync::atomic::Ordering::Relaxed);
3073 }
3074 fn forward_edges_version(&self, id: &NativeId) -> Option<String> {
3075 self.edges_version(ItemKind::Task, id, Direction::DependsOn)
3076 }
3077 fn edges_version(&self, kind: ItemKind, id: &NativeId, direction: Direction) -> Option<String> {
3078 let root = match kind {
3079 ItemKind::Task => DependencyRoot::Issue,
3080 ItemKind::Project => DependencyRoot::Project,
3081 };
3082 self.edges_versions()
3083 .get(&(root, direction, id.clone()))
3084 .cloned()
3085 }
3086 fn capabilities(&self) -> Capabilities {
3087 Capabilities {
3088 projects: Support::Native,
3089 documents: Support::Native,
3090 comments: Support::Native,
3091 assets: Support::Native,
3092 priority: Support::Native,
3093 filter_by_priority: Support::Native,
3094 filter_by_comment_activity: Support::Native,
3095 filter_by_metadata: Support::Native,
3096 filter_by_origin: Support::Native,
3097 reconcile_status: Support::Unsupported,
3098 complete_by_issue: Support::Unsupported,
3099 orphan_tasks: Support::Native,
3100 filter_by_label: Support::Native,
3101 filter_by_status: Support::Native,
3102 search_title: Support::Native,
3103 search_content: Support::Native,
3104 task_dependencies: DependencySupport::BothDirections,
3105 project_dependencies: DependencySupport::BothDirections,
3106 max_page_size: MAX_PAGE_SIZE,
3107 }
3108 }
3109 fn writes(&self) -> WriteSupport {
3110 WriteSupport::Supported
3111 }
3112 /// The rewrite the ruling on what Linear does to Markdown on write declares.
3113 async fn stored_form(&self, content: &str) -> Result<String, SourceError> {
3114 Ok(stored_form::stored_form(content))
3115 }
3116 /// The name `status_mapping` gives the kind and the category, which is the whole of what a
3117 /// write sends; see the ruling on what a status is. A category it gives no name is left as
3118 /// it is, for the write to refuse.
3119 async fn stored_status(&self, kind: ItemKind, status: &Status) -> Result<Status, SourceError> {
3120 Ok(match self.statuses.name_for(status.category, kind) {
3121 Ok(name) => Status {
3122 category: status.category,
3123 name: name.as_str().to_owned(),
3124 },
3125 Err(_) => status.clone(),
3126 })
3127 }
3128 fn stored_content(&self, record: MetadataRecord, id: &NativeId) -> Option<String> {
3129 self.stored
3130 .lock()
3131 .unwrap_or_else(std::sync::PoisonError::into_inner)
3132 .remove(&(record, id.clone()))
3133 }
3134 /// Private, once this instance's credential has reached its workspace: a Linear workspace
3135 /// is readable by its members alone and no setting of Linear's makes it readable by
3136 /// anybody else, so what is verified is that the workspace is really there to write into.
3137 ///
3138 /// Read inside the resolution a cold write sends anyway and held with it for this
3139 /// instance's lifetime, so a source declared private spends no request on it: a cold write
3140 /// sends what it always sent, and a warm one sends nothing more. A source with no `team`
3141 /// cannot make that read, and refuses rather than answering on trust.
3142 async fn visibility(
3143 &self,
3144 _target: &onetaskgraph_plugin_api::WriteTarget<'_>,
3145 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
3146 let (vocabulary, _) = self.vocabulary(false).await.map_err(|error| match error {
3147 SourceError::Refused { message } => SourceError::Refused {
3148 message: format!(
3149 "source {} cannot verify that it is private: {message}",
3150 self.name
3151 ),
3152 },
3153 other => other,
3154 })?;
3155 if vocabulary.workspace.is_none() {
3156 return Err(SourceError::Malformed {
3157 message: format!(
3158 "source {} cannot verify that it is private: Linear named no workspace for \
3159 its credential",
3160 self.name
3161 ),
3162 });
3163 }
3164 Ok(onetaskgraph_plugin_api::Visibility::Private)
3165 }
3166 async fn health(&self) -> Result<Health, SourceError> {
3167 let data = self.send(VIEWER, json!({})).await?;
3168 str_at(
3169 data.get("viewer").ok_or_else(|| SourceError::Malformed {
3170 message: "missing viewer".into(),
3171 })?,
3172 "id",
3173 )?;
3174 Ok(Health {
3175 reachable: true,
3176 detail: None,
3177 })
3178 }
3179 /// The workspace's organization id, digested: one request. A team or a project scope
3180 /// narrows what this source reads, never which workspace it is, so every source of one
3181 /// workspace designates the same tracker.
3182 async fn designator(&self) -> Result<Option<Designator>, SourceError> {
3183 let data = self.send(graphql::ORGANIZATION, json!({})).await?;
3184 let organization = str_at(
3185 data.get("organization")
3186 .ok_or_else(|| SourceError::Malformed {
3187 message: "missing organization".into(),
3188 })?,
3189 "id",
3190 )?;
3191 // An empty id names no workspace, and its digest would name one all the same.
3192 if organization.trim().is_empty() {
3193 return Err(SourceError::Malformed {
3194 message: "Linear answered an organization with an empty id".into(),
3195 });
3196 }
3197 Designator::of_backend(KIND, organization)
3198 .map(Some)
3199 .map_err(|message| SourceError::Malformed { message })
3200 }
3201 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
3202 Ok(self.issue_held(id, Read::Load).await?.map(|(task, _)| task))
3203 }
3204 /// Each issue with the page of its comments `comments` names in one request,
3205 /// [`graphql::ISSUE_DETAIL`]; an issue alone, as [`get_task`](Self::get_task) reads it,
3206 /// when no page is asked for.
3207 async fn get_task_details(
3208 &self,
3209 ids: &[NativeId],
3210 comments: Option<&PageRequest>,
3211 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
3212 let mut read = Vec::with_capacity(ids.len());
3213 for id in ids {
3214 read.push(match comments {
3215 Some(page) => self.issue_detail(id, page).await,
3216 None => self.issue_held(id, Read::Load).await.map(|held| {
3217 held.map(|(task, _)| TaskDetailRead {
3218 task,
3219 comments: None,
3220 })
3221 }),
3222 });
3223 }
3224 read
3225 }
3226 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
3227 Ok(self
3228 .project_held(id, Read::Load)
3229 .await?
3230 .map(|(project, _)| project))
3231 }
3232 async fn query_tasks(
3233 &self,
3234 query: &TaskQuery,
3235 page: &PageRequest,
3236 ) -> Result<Page<Task>, SourceError> {
3237 let d=self.send(self.read_document(ISSUES),json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.issue_filter(query)?})).await?;
3238 let page = connection(&d, "issues", |v| {
3239 map_task(v, &self.name, &self.statuses, self.spelling())
3240 })?;
3241 Ok(Page {
3242 items: page
3243 .items
3244 .into_iter()
3245 .filter(|task| Self::confirms(query, task))
3246 .collect(),
3247 next: page.next,
3248 })
3249 }
3250 async fn query_projects(
3251 &self,
3252 query: &ProjectQuery,
3253 page: &PageRequest,
3254 ) -> Result<Page<Project>, SourceError> {
3255 // 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; `item_reads_and_transport_error_boundaries_are_exercised` separately asserts the serialized `status:{name:{eqIgnoreCase:…}}` predicate the mapping names.
3256 let d=self.send(self.read_document(PROJECTS),json!({"first":page.limit.min(self.projects_page()),"after":page.cursor.as_ref().map(|c|&c.0),"filter":self.project_filter(&query.labels,&query.statuses)})).await?;
3257 let page = connection(&d, "projects", |v| {
3258 map_project(v, &self.statuses, self.spelling())
3259 })?;
3260 // A project's text is applied here, over the page Linear answered: `search_title` and
3261 // `search_content` are declared for every level, and `ProjectFilter` is not asked for
3262 // a content match — so the rule decides, over every row of the page.
3263 Ok(Page {
3264 items: page
3265 .items
3266 .into_iter()
3267 .filter(|project| {
3268 (query.statuses.is_empty() || query.statuses.contains(&project.status.category))
3269 && query.text.as_ref().is_none_or(|text| {
3270 text_holds(&project.title, project.content.as_deref(), text)
3271 })
3272 })
3273 .collect(),
3274 next: page.next,
3275 })
3276 }
3277 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
3278 let d = self
3279 .send(
3280 LABELS,
3281 json!({"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c|&c.0)}),
3282 )
3283 .await?;
3284 connection(&d, "issueLabels", map_label)
3285 }
3286 async fn task_dependencies(
3287 &self,
3288 id: &NativeId,
3289 direction: Direction,
3290 page: &PageRequest,
3291 ) -> Result<Page<DependencyEdge>, SourceError> {
3292 let plain = match direction {
3293 Direction::DependsOn => graphql::ISSUE_DEPENDS_ON,
3294 Direction::DependedOnBy => graphql::ISSUE_DEPENDED_ON_BY,
3295 };
3296 self.dependencies(
3297 self.read_document(plain),
3298 DependencyRoot::Issue,
3299 id,
3300 direction,
3301 page,
3302 )
3303 .await
3304 }
3305 async fn project_dependencies(
3306 &self,
3307 id: &NativeId,
3308 direction: Direction,
3309 page: &PageRequest,
3310 ) -> Result<Page<DependencyEdge>, SourceError> {
3311 let plain = match direction {
3312 Direction::DependsOn => graphql::PROJECT_DEPENDS_ON,
3313 Direction::DependedOnBy => graphql::PROJECT_DEPENDED_ON_BY,
3314 };
3315 self.dependencies(
3316 self.read_document(plain),
3317 DependencyRoot::Project,
3318 id,
3319 direction,
3320 page,
3321 )
3322 .await
3323 }
3324 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
3325 // Before anything is read or written, because nothing Linear could answer changes
3326 // either: neither list may name the task itself or name one task twice, a category
3327 // this source has disabled is refused in the words a status write is refused with,
3328 // and a project other than the one this source is scoped to is refused naming both.
3329 let near = write.target.as_ref().unwrap_or(&write.item.id);
3330 for (key, entries) in [
3331 (TaskRef::DELIVERS_KEY, &write.item.delivers),
3332 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
3333 ] {
3334 TaskRef::listed(key, near, Some(&self.name), entries.clone())
3335 .map_err(|message| SourceError::Refused { message })?;
3336 }
3337 let project = self.filed_in(write.item.project.as_ref(), "task")?;
3338 // The status is resolved before anything else is sent: a category the mapping gives a
3339 // task no name for is refused before any request, and a name the team lacks before any
3340 // mutation. The mapping decides alone; the status's own name plays no part.
3341 let (state, _) = self
3342 .status_id(write.item.status.category, ItemKind::Task)
3343 .await?;
3344 let edges = self
3345 .prepare_edges(&write.depends_on, WriteKind::Task)
3346 .await?;
3347 let team = self.team_id().await?;
3348 let labels = self.label_ids(&write.item.labels, WriteKind::Task).await?;
3349 // The typed lists are what land, whatever the caller's own metadata held under their
3350 // keys: a key of either name travelling beside the field would be a second answer to
3351 // the same question, and the field is the one the contract names.
3352 let mut metadata = write.item.metadata.clone();
3353 set_task_list(&mut metadata, TaskRef::DELIVERS_KEY, &write.item.delivers);
3354 set_task_list(
3355 &mut metadata,
3356 TaskRef::DELIVERED_BY_KEY,
3357 &write.item.delivered_by,
3358 );
3359 let description = self.write_description(
3360 write.item.content.as_deref(),
3361 &metadata,
3362 &write.item.repositories,
3363 write.item.classification,
3364 &edges,
3365 WriteKind::Task,
3366 )?;
3367 // `priority` on a create and on an update alike, `0` included: an update that left
3368 // it out would keep whatever the destination held, so a copy moving an issue back to
3369 // no priority would report success for a priority the destination still carries.
3370 let input = json!({"title":write.item.title,"description":description,"stateId":state,"priority":linear_priority(write.item.priority),"labelIds":labels,"projectId":project});
3371 let (query, variables, root) = match &write.target {
3372 // Its relations read back in the same request, so a rewrite needs no read of them.
3373 Some(id) => (
3374 graphql::ISSUE_REWRITE,
3375 json!({"id":id.0,"input":input,"first":MAX_PAGE_SIZE}),
3376 MutationRoot::IssueUpdate,
3377 ),
3378 None => (
3379 graphql::ISSUE_CREATE,
3380 {
3381 let mut input = input;
3382 input["teamId"] = Value::String(team.0);
3383 json!({"input":input})
3384 },
3385 MutationRoot::IssueCreate,
3386 ),
3387 };
3388 let data = self.send(query, variables).await.inspect_err(|_| {
3389 self.forget_vocabulary();
3390 })?;
3391 let issue =
3392 mutation_payload(&data, root)?
3393 .get("issue")
3394 .ok_or_else(|| SourceError::Malformed {
3395 message: format!("missing {}.issue", root.as_str()),
3396 })?;
3397 let id = NativeId(backend_id(issue, "id")?.into());
3398 self.hold_stored(MetadataRecord::Task, &id, issue)?;
3399 let held = held_relations(write.target.as_ref(), issue)?;
3400 self.write_relations(&id, &edges, WriteKind::Task, held)
3401 .await?;
3402 Ok(id)
3403 }
3404 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
3405 // Before anything is read or written, and before the item's own description
3406 // records these edges: an edge Linear will never accept has to refuse the whole
3407 // write, or a copy would create the project and then fail relating it, leaving the
3408 // undo to clean up a write that could have been refused without a call at all.
3409 if let Some(edge) = Self::unordered_project_edge(&write.depends_on) {
3410 return Err(self.unordered_project_relation(&write.item.id, edge.to.id()));
3411 }
3412 if let Some(key) = delivery_key_in(&write.item.metadata) {
3413 return Err(self.undeliverable(key, "project"));
3414 }
3415 if let Some(refused) = self.project_out_of_scope(write.target.as_ref()) {
3416 return Err(refused);
3417 }
3418 // Through the project side of the mapping, before anything else is sent: the item's
3419 // own status name plays no part, because a project's statuses are a vocabulary of
3420 // their own that the name a status was read under elsewhere says nothing about.
3421 let (status, _) = self
3422 .status_id(write.item.status.category, ItemKind::Project)
3423 .await?;
3424 let edges = self
3425 .prepare_edges(&write.depends_on, WriteKind::Project)
3426 .await?;
3427 let team = self.team_id().await?;
3428 let labels = self
3429 .label_ids(&write.item.labels, WriteKind::Project)
3430 .await?;
3431 let description = self.write_description(
3432 write.item.content.as_deref(),
3433 &write.item.metadata,
3434 &write.item.repositories,
3435 write.item.classification,
3436 &edges,
3437 WriteKind::Project,
3438 )?;
3439 // A project's long form is its `content`, not its `description`: Linear documents the
3440 // second as "the short description of the project", and the first — the project's
3441 // Markdown body — is the one with room for a description and a metadata slot.
3442 let input = json!({"name":write.item.title,"content":description,"statusId":status,"labelIds":labels});
3443 let (query, variables, root) = match &write.target {
3444 // Its relations read back in the same request, as an issue's are.
3445 Some(id) => (
3446 graphql::PROJECT_REWRITE,
3447 json!({"id":id.0,"input":input,"first":MAX_PAGE_SIZE}),
3448 MutationRoot::ProjectUpdate,
3449 ),
3450 None => (
3451 graphql::PROJECT_CREATE,
3452 {
3453 let mut input = input;
3454 input["teamIds"] = json!([team]);
3455 json!({"input":input})
3456 },
3457 MutationRoot::ProjectCreate,
3458 ),
3459 };
3460 let data = self.send(query, variables).await.inspect_err(|_| {
3461 self.forget_vocabulary();
3462 })?;
3463 let project = mutation_payload(&data, root)?
3464 .get("project")
3465 .ok_or_else(|| SourceError::Malformed {
3466 message: format!("missing {}.project", root.as_str()),
3467 })?;
3468 let id = NativeId(backend_id(project, "id")?.into());
3469 self.hold_stored(MetadataRecord::Project, &id, project)?;
3470 let held = held_relations(write.target.as_ref(), project)?;
3471 self.write_relations(&id, &edges, WriteKind::Project, held)
3472 .await?;
3473 Ok(id)
3474 }
3475 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
3476 // An id naming nothing is the state this asks for, not an error — Linear reports
3477 // an unknown issue as an errored response rather than an unsuccessful payload, and
3478 // `get_task` answering `None` is what says the item is already gone.
3479 if self.get_task(id).await?.is_none() {
3480 return Ok(());
3481 }
3482 let data = self.send(graphql::ISSUE_DELETE, json!({"id":id.0})).await?;
3483 mutation_payload(&data, MutationRoot::IssueDelete)?;
3484 Ok(())
3485 }
3486 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
3487 // An id naming nothing is the state this asks for, on exactly the terms
3488 // `delete_task` reads it on.
3489 if self.get_project(id).await?.is_none() {
3490 return Ok(());
3491 }
3492 let data = self
3493 .send(graphql::PROJECT_DELETE, json!({"id":id.0}))
3494 .await?;
3495 mutation_payload(&data, MutationRoot::ProjectDelete)?;
3496 Ok(())
3497 }
3498 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
3499 // Read as an optional although the pinned `document(id:)` returns `Document!`, for
3500 // the reason `delete_task` records: Linear answers an id naming nothing with an
3501 // errored response rather than a null, and reading the null defensively is what
3502 // keeps a responder that does answer one from being a malformed-response failure.
3503 Ok(self.document_held(id).await?.map(|(document, _)| document))
3504 }
3505 async fn query_documents(
3506 &self,
3507 query: &DocumentQuery,
3508 page: &PageRequest,
3509 ) -> Result<Page<Document>, SourceError> {
3510 // A document's text is applied over each fetched page with the labels and the
3511 // orphans, by the contract's own rule: both searches are declared for every level.
3512 let want = page.limit.min(MAX_PAGE_SIZE) as usize;
3513 let mut filter = serde_json::Map::new();
3514 if let ProjectFilter::Is(id) = &query.project {
3515 if !self.in_scope(Some(id)) {
3516 return Ok(Page::last(Vec::new()));
3517 }
3518 filter.insert("project".into(), json!({"id": {"eq": id.0}}));
3519 } else if let Some(scope) = &self.project {
3520 filter.insert("project".into(), json!({"id": {"eq": scope.0}}));
3521 }
3522 let filter = Value::Object(filter);
3523 let mut items = Vec::new();
3524 let mut cursor = page.cursor.clone();
3525 loop {
3526 // Only what is still owed, so the predicates applied here can never make this
3527 // return more than the caller asked for, and never drop what it fetched.
3528 let first = want.saturating_sub(items.len()).max(1);
3529 let d = self
3530 .send(
3531 DOCUMENTS,
3532 json!({"first":first,"after":cursor.as_ref().map(|cursor|&cursor.0),"filter":filter}),
3533 )
3534 .await?;
3535 let fetched = connection(&d, "documents", map_document)?;
3536 items.extend(fetched.items.into_iter().filter(|document| {
3537 document_matches(document, &query.project, &query.labels)
3538 && self.in_scope(document.project.as_ref())
3539 && query.text.as_ref().is_none_or(|text| {
3540 text_holds(&document.title, document.content.as_deref(), text)
3541 })
3542 }));
3543 cursor = fetched.next;
3544 if cursor.is_none() || items.len() >= want {
3545 return Ok(Page {
3546 items,
3547 next: cursor,
3548 });
3549 }
3550 }
3551 }
3552 async fn write_task_with_assets(
3553 &self,
3554 write: &ItemWrite<Task>,
3555 _answers: Option<&std::collections::BTreeMap<String, Value>>,
3556 assets: &onetaskgraph_plugin_api::AssetWrite,
3557 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
3558 let mut write = write.clone();
3559 let uploads = self.upload_assets(assets).await?;
3560 let content = onetaskgraph_plugin_api::serve_asset_references(
3561 write.item.content.as_deref().unwrap_or_default(),
3562 &mut write.item.metadata,
3563 &uploads,
3564 );
3565 write.item.content = write.item.content.as_ref().map(|_| content);
3566 let id = self.write_task(&write).await?;
3567 Ok(onetaskgraph_plugin_api::AssetsWritten {
3568 id,
3569 content: write.item.content,
3570 })
3571 }
3572 async fn write_document_with_assets(
3573 &self,
3574 write: &ItemWrite<Document>,
3575 _answers: Option<&std::collections::BTreeMap<String, Value>>,
3576 assets: &onetaskgraph_plugin_api::AssetWrite,
3577 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
3578 let mut write = write.clone();
3579 let uploads = self.upload_assets(assets).await?;
3580 let content = onetaskgraph_plugin_api::serve_asset_references(
3581 write.item.content.as_deref().unwrap_or_default(),
3582 &mut write.item.metadata,
3583 &uploads,
3584 );
3585 write.item.content = write.item.content.as_ref().map(|_| content);
3586 let id = self.write_document(&write).await?;
3587 Ok(onetaskgraph_plugin_api::AssetsWritten {
3588 id,
3589 content: write.item.content,
3590 })
3591 }
3592 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
3593 // Two refusals by name rather than two silent drops. Linear's own document type
3594 // has no labels and a document is not work, so neither a label nor a dependency
3595 // has anywhere here to land — and a copy that dropped one would report success for
3596 // an item the destination does not hold.
3597 if !write.item.labels.is_empty() {
3598 let named = write
3599 .item
3600 .labels
3601 .iter()
3602 .map(|label| label.name.as_str())
3603 .collect::<Vec<_>>()
3604 .join(", ");
3605 return Err(SourceError::Refused {
3606 message: format!(
3607 "source {} cannot carry a document's labels, because Linear's own \
3608 document type has none: {named}",
3609 self.name
3610 ),
3611 });
3612 }
3613 if !write.depends_on.is_empty()
3614 || write
3615 .item
3616 .metadata
3617 .contains_key(DependencyEdge::RECORDED_KEY)
3618 {
3619 return Err(SourceError::Refused {
3620 message: format!(
3621 "source {} cannot carry {} on a document, because a document is not \
3622 work and nothing may depend on one",
3623 self.name,
3624 DependencyEdge::RECORDED_KEY
3625 ),
3626 });
3627 }
3628 if let Some(key) = delivery_key_in(&write.item.metadata) {
3629 return Err(self.undeliverable(key, "document"));
3630 }
3631 let content = Self::long_form(
3632 write.item.content.as_deref(),
3633 &write.item.metadata,
3634 &write.item.repositories,
3635 write.item.classification,
3636 Vec::new(),
3637 )?;
3638 let project = self.filed_in(write.item.project.as_ref(), "document")?;
3639 let (query, variables, root) = match &write.target {
3640 Some(id) => {
3641 // A target this workspace does not hold is refused rather than created:
3642 // the engine established that id before asking, so an absent one is a race
3643 // this destination must not paper over by writing a second document.
3644 if self.get_document(id).await?.is_none() {
3645 return Err(SourceError::Refused {
3646 message: format!("source {} holds no document {}", self.name, id.0),
3647 });
3648 }
3649 (
3650 graphql::DOCUMENT_UPDATE,
3651 json!({"id":id.0,"input":{"title":write.item.title,"content":content,"projectId":project}}),
3652 MutationRoot::DocumentUpdate,
3653 )
3654 }
3655 None => {
3656 let mut input = json!({"title":write.item.title,"content":content});
3657 // A Linear document lives in a project, an initiative, an issue or a team.
3658 // One filed under no project needs the configured team to be its home, and
3659 // one filed under a project already has one — so the team is asked for
3660 // only where it is the answer, rather than made a condition of every write.
3661 //
3662 // **`projectId` is left out rather than sent as null, and that is Linear's
3663 // rule rather than tidiness.** `documentCreate` refuses an input that names
3664 // more than one home — `Exactly one of initiativeId, teamId, issueId,
3665 // releaseId, cycleId or projectId must be defined.` — and it counts a
3666 // *present* key, observed on 2026-09-04: `{projectId: null, teamId: …}` is
3667 // refused where `{teamId: …}` is accepted. So a document filed under no
3668 // project must carry no `projectId` at all. `documentUpdate` is the
3669 // opposite and keeps its explicit null, because there the null is the
3670 // instruction — it is how a document is moved out of a project, and
3671 // omitting the key would leave it where it was.
3672 match &project {
3673 Some(project) => input["projectId"] = Value::String(project.clone()),
3674 None => input["teamId"] = Value::String(self.team_id().await?.0),
3675 }
3676 (
3677 graphql::DOCUMENT_CREATE,
3678 json!({ "input": input }),
3679 MutationRoot::DocumentCreate,
3680 )
3681 }
3682 };
3683 let data = self.send(query, variables).await?;
3684 let document = mutation_payload(&data, root)?
3685 .get("document")
3686 .ok_or_else(|| SourceError::Malformed {
3687 message: format!("missing {}.document", root.as_str()),
3688 })?;
3689 let id = NativeId(backend_id(document, "id")?.into());
3690 self.hold_stored(MetadataRecord::Document, &id, document)?;
3691 Ok(id)
3692 }
3693 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
3694 // An id naming nothing is the state this asks for, on exactly the terms
3695 // `delete_task` reads it on.
3696 if self.get_document(id).await?.is_none() {
3697 return Ok(());
3698 }
3699 let data = self
3700 .send(graphql::DOCUMENT_DELETE, json!({"id":id.0}))
3701 .await?;
3702 mutation_payload(&data, MutationRoot::DocumentDelete)?;
3703 Ok(())
3704 }
3705 async fn task_comments(
3706 &self,
3707 task: &NativeId,
3708 page: &PageRequest,
3709 ) -> Result<Option<Page<Comment>>, SourceError> {
3710 // A page of no rows is not a page: refused here rather than sent as `last: 0`, which
3711 // would answer an empty page that reads as a task with no comments.
3712 if page.limit == 0 {
3713 return Err(SourceError::Config {
3714 message: "a page limit of 0 is not a page; ask for at least 1 comment".to_owned(),
3715 });
3716 }
3717 // One request rather than a task lookup and then a read: the issue the comments
3718 // hang off answers "no such task" by itself, on exactly the terms `get_task` reads
3719 // it — null, or trashed.
3720 let d = self
3721 .send(
3722 graphql::ISSUE_COMMENTS,
3723 json!({"id":task.0,"last":page.limit.min(MAX_PAGE_SIZE),"before":page.cursor.as_ref().map(|c|&c.0)}),
3724 )
3725 .await?;
3726 optional(&d, "issue", comment_page)
3727 }
3728 async fn add_comment(
3729 &self,
3730 task: &NativeId,
3731 comment: &NewComment,
3732 ) -> Result<Option<Comment>, SourceError> {
3733 // Before anything is sent, because nothing Linear could answer changes it: see the
3734 // ruling on the author in this crate's module documentation.
3735 if let Some(author) = &comment.author {
3736 return Err(SourceError::Refused {
3737 message: format!(
3738 "source {} cannot post a comment as {author:?}, because Linear records the \
3739 user whose API key makes the request as the author of every comment; \
3740 leave --author out to post as that user",
3741 self.name
3742 ),
3743 });
3744 }
3745 let Some(issue) = self.commented_issue(task).await? else {
3746 return Ok(None);
3747 };
3748 let data = self
3749 .send(
3750 graphql::COMMENT_CREATE,
3751 json!({"input":{"issueId":issue.0,"body":comment.body.as_str()}}),
3752 )
3753 .await?;
3754 written_comment(&data, MutationRoot::CommentCreate).map(Some)
3755 }
3756 async fn edit_comment(
3757 &self,
3758 task: &NativeId,
3759 comment: &NativeId,
3760 body: &CommentBody,
3761 ) -> Result<Option<Comment>, SourceError> {
3762 let Some(held) = self.comment_on(task, comment).await? else {
3763 return Ok(None);
3764 };
3765 // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
3766 let body = CommentOrigin::kept(&held, body)?;
3767 // `body` alone: the id, the author and the time it was written are the comment's
3768 // own, so nothing else is sent that Linear could move.
3769 let data = self
3770 .send(
3771 graphql::COMMENT_UPDATE,
3772 json!({"id":comment.0,"input":{"body":body.as_str()}}),
3773 )
3774 .await?;
3775 written_comment(&data, MutationRoot::CommentUpdate).map(Some)
3776 }
3777 async fn delete_comment(
3778 &self,
3779 task: &NativeId,
3780 comment: &NativeId,
3781 ) -> Result<Option<NativeId>, SourceError> {
3782 if !self.comment_is_on(task, comment).await? {
3783 return Ok(None);
3784 }
3785 let data = self
3786 .send(graphql::COMMENT_DELETE, json!({"id":comment.0}))
3787 .await?;
3788 mutation_payload(&data, MutationRoot::CommentDelete)?;
3789 Ok(Some(comment.clone()))
3790 }
3791 /// Resolved exactly as the write resolves it, through the instance's held vocabulary, so
3792 /// the write that follows sends no read this did not — and a project write the scope
3793 /// refuses is refused here first, before the resolution is read for it.
3794 async fn check_status_write(
3795 &self,
3796 kind: ItemKind,
3797 category: StatusCategory,
3798 target: Option<&NativeId>,
3799 ) -> Result<(), SourceError> {
3800 if kind == ItemKind::Project
3801 && let Some(refused) = self.project_out_of_scope(target)
3802 {
3803 return Err(refused);
3804 }
3805 self.status_id(category, kind).await.map(|_| ())
3806 }
3807 async fn set_task_status(
3808 &self,
3809 id: &NativeId,
3810 category: StatusCategory,
3811 ) -> Result<Option<Status>, SourceError> {
3812 Ok(self
3813 .status_written(id, category)
3814 .await?
3815 .map(|task| task.status))
3816 }
3817 /// One `issueUpdate` and nothing before it — see `status_written` — answering the task
3818 /// its own selection reports.
3819 async fn set_task_status_reading(
3820 &self,
3821 id: &NativeId,
3822 category: StatusCategory,
3823 ) -> Result<Option<Task>, SourceError> {
3824 self.status_written(id, category).await
3825 }
3826 /// One `projectUpdate` carrying only `statusId` — the project status this source's mapping
3827 /// gives the category — and nothing before it but the vocabulary it resolves that status
3828 /// against, answering the status the update's own selection reports.
3829 async fn set_project_status(
3830 &self,
3831 id: &NativeId,
3832 category: StatusCategory,
3833 ) -> Result<Option<Status>, SourceError> {
3834 if let Some(refused) = self.project_out_of_scope(Some(id)) {
3835 return Err(refused);
3836 }
3837 let (status, _) = self.status_id(category, ItemKind::Project).await?;
3838 let answered = self
3839 .answer(
3840 graphql::PROJECT_STATUS_UPDATE,
3841 json!({"id":id.0,"input":{"statusId":status.0}}),
3842 )
3843 .await;
3844 let data = match answered {
3845 Ok(Ok(data)) => data,
3846 // A project Linear does not hold is no such project, never a refusal of the write.
3847 Ok(Err(refusal)) if refusal.project_missing() => return Ok(None),
3848 Ok(Err(refusal)) => {
3849 self.forget_vocabulary();
3850 return Err(refusal.into_error());
3851 }
3852 Err(error) => {
3853 self.forget_vocabulary();
3854 return Err(error);
3855 }
3856 };
3857 let project = mutation_payload(&data, MutationRoot::ProjectUpdate)?
3858 .get("project")
3859 .filter(|project| !project.is_null())
3860 .ok_or_else(|| SourceError::Malformed {
3861 message: "missing projectUpdate.project".into(),
3862 })?;
3863 acknowledged(project, id, MutationRoot::ProjectUpdate)?;
3864 mapped_status(
3865 project
3866 .get("status")
3867 .ok_or_else(|| SourceError::Malformed {
3868 message: "missing projectUpdate.project.status".into(),
3869 })?,
3870 &self.statuses,
3871 ItemKind::Project,
3872 )
3873 .map(Some)
3874 }
3875 async fn set_task_priority(
3876 &self,
3877 id: &NativeId,
3878 priority: Priority,
3879 ) -> Result<Option<Priority>, SourceError> {
3880 // Read first: a trashed issue is not one this source holds, so it is never written
3881 // to, and the read is what tells "no such task" from a refusal here — unlike a status
3882 // write, whose budget the read is not worth.
3883 let Some(task) = self.get_task(id).await? else {
3884 return Ok(None);
3885 };
3886 // `priority` alone: every member of `IssueUpdateInput` is optional and Linear leaves
3887 // an absent one as the issue holds it, so nothing else about the issue can move.
3888 let data = self
3889 .send(
3890 graphql::ISSUE_PRIORITY_UPDATE,
3891 json!({"id":task.id.0,"input":{"priority":linear_priority(priority)}}),
3892 )
3893 .await?;
3894 let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
3895 .get("issue")
3896 .filter(|issue| !issue.is_null())
3897 .ok_or_else(|| SourceError::Malformed {
3898 message: "missing issueUpdate.issue".into(),
3899 })?;
3900 written_is(issue, &task.id)?;
3901 issue_priority(issue).map(Some)
3902 }
3903 async fn set_task_content(
3904 &self,
3905 id: &NativeId,
3906 content: &str,
3907 ) -> Result<Option<()>, SourceError> {
3908 // The raw description, because the metadata slot lives in that same field and has to
3909 // go back byte for byte: re-encoding it would be a metadata write nobody asked for.
3910 // The whole issue is read as `get_task` reads it first, so an issue this source could
3911 // not read is refused before anything is written rather than overwritten blind.
3912 let Some((task, description)) = self.issue_held(id, Read::ForWrite).await? else {
3913 return Ok(None);
3914 };
3915 let issue = task.id;
3916 let slot = match description.as_deref() {
3917 Some(description) => metadata_slot(description)?.map(str::to_owned),
3918 None => None,
3919 };
3920 let description = match &slot {
3921 None => content.to_owned(),
3922 Some(slot) if content.is_empty() => slot.clone(),
3923 // The separator `long_form` writes, so a content write and a copy leave one shape.
3924 Some(slot) => format!("{content}\n\n{slot}"),
3925 };
3926 // Checked before anything is sent: content ending in what this source reads as its own
3927 // metadata slot would read back as metadata rather than as the content it was.
3928 if metadata_slot(&description)? != slot.as_deref() {
3929 return Err(SourceError::Refused {
3930 message: format!(
3931 "this content ends in what source {} reads as its own metadata slot, so part \
3932 of it would read back as metadata rather than as content; next: remove that \
3933 trailing block from the content",
3934 self.name
3935 ),
3936 });
3937 }
3938 // And what a read will report is exactly what was asked for, or nothing is sent.
3939 let (reads, _) = metadata_description(Some(description.clone()))?;
3940 if reads.as_deref().unwrap_or_default() != content {
3941 return Err(SourceError::Refused {
3942 message: format!(
3943 "this content would read back from source {} as {:?} rather than as itself; \
3944 next: change how the content ends",
3945 self.name,
3946 reads.as_deref().unwrap_or_default()
3947 ),
3948 });
3949 }
3950 // `description` alone, for the reason `set_task_priority` sends `priority` alone.
3951 let data = self
3952 .send(
3953 graphql::ISSUE_UPDATE,
3954 json!({"id":issue.0,"input":{"description":description}}),
3955 )
3956 .await?;
3957 let written = mutation_payload(&data, MutationRoot::IssueUpdate)?
3958 .get("issue")
3959 .filter(|issue| !issue.is_null())
3960 .ok_or_else(|| SourceError::Malformed {
3961 message: "missing issueUpdate.issue".into(),
3962 })?;
3963 written_is(written, &issue)?;
3964 self.hold_stored(MetadataRecord::Task, &issue, written)?;
3965 Ok(Some(()))
3966 }
3967 async fn set_delivered_by(
3968 &self,
3969 id: &NativeId,
3970 delivered_by: &[TaskRef],
3971 ) -> Result<Option<()>, SourceError> {
3972 let entries = TaskRef::listed(
3973 TaskRef::DELIVERED_BY_KEY,
3974 id,
3975 Some(&self.name),
3976 delivered_by.to_vec(),
3977 )
3978 .map_err(|message| SourceError::Refused { message })?;
3979 let Some((task, description)) = self.issue_held(id, Read::ForWrite).await? else {
3980 return Ok(None);
3981 };
3982 let (_, held) = metadata_description(description.clone())?;
3983 let mut slot = held.clone();
3984 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
3985 // Compared as JSON rather than as the field's bytes, so a slot already holding the
3986 // list is not rewritten for its spelling alone.
3987 if slot != held {
3988 let rewritten = reslotted(description.as_deref(), &slot)?;
3989 self.write_description_alone(&task.id, rewritten.as_deref())
3990 .await?;
3991 }
3992 Ok(Some(()))
3993 }
3994 /// One read of the issue and, unless the key already holds the value, one `issueUpdate`
3995 /// carrying the description alone, whose metadata slot is the only part that moved.
3996 async fn set_task_metadata(
3997 &self,
3998 id: &NativeId,
3999 key: &MetadataKey,
4000 value: &Value,
4001 ) -> Result<Option<Task>, SourceError> {
4002 let Some((task, description)) = self.issue_held(id, Read::ForWrite).await? else {
4003 return Ok(None);
4004 };
4005 let (_, mut slot) = metadata_description(description.clone())?;
4006 if slot.get(key.as_str()) == Some(value) {
4007 return Ok(Some(task));
4008 }
4009 slot.insert(key.as_str().to_owned(), value.clone());
4010 let rewritten = reslotted(description.as_deref(), &slot)?;
4011 self.write_description_alone(&task.id, rewritten.as_deref())
4012 .await?;
4013 self.get_task(&task.id)
4014 .await?
4015 .map(Some)
4016 .ok_or_else(|| SourceError::Malformed {
4017 message: format!(
4018 "task {} was written and then could not be read back",
4019 task.id
4020 ),
4021 })
4022 }
4023 /// One metadata key of a project, on the terms of `set_task_metadata`: one read, and one
4024 /// `projectUpdate` carrying the content alone.
4025 async fn set_project_metadata(
4026 &self,
4027 id: &NativeId,
4028 key: &MetadataKey,
4029 value: &Value,
4030 ) -> Result<Option<Project>, SourceError> {
4031 let Some((project, description)) = self.project_held(id, Read::ForWrite).await? else {
4032 return Ok(None);
4033 };
4034 let (_, mut slot) = metadata_description(description.clone())?;
4035 if slot.get(key.as_str()) == Some(value) {
4036 return Ok(Some(project));
4037 }
4038 slot.insert(key.as_str().to_owned(), value.clone());
4039 let rewritten = reslotted(description.as_deref(), &slot)?;
4040 self.write_project_content(&project.id, rewritten.as_deref())
4041 .await?;
4042 self.get_project(&project.id)
4043 .await?
4044 .map(Some)
4045 .ok_or_else(|| SourceError::Malformed {
4046 message: format!(
4047 "project {} was written and then could not be read back",
4048 project.id
4049 ),
4050 })
4051 }
4052 /// One metadata key of a document, on the terms of `set_task_metadata`: one read, and one
4053 /// `documentUpdate` carrying the content alone.
4054 async fn set_document_metadata(
4055 &self,
4056 id: &NativeId,
4057 key: &MetadataKey,
4058 value: &Value,
4059 ) -> Result<Option<Document>, SourceError> {
4060 let Some((document, content)) = self.document_held(id).await? else {
4061 return Ok(None);
4062 };
4063 let (_, mut slot) = metadata_description(content.clone())?;
4064 if slot.get(key.as_str()) == Some(value) {
4065 return Ok(Some(document));
4066 }
4067 slot.insert(key.as_str().to_owned(), value.clone());
4068 let rewritten = reslotted(content.as_deref(), &slot)?;
4069 self.write_document_content(&document.id, rewritten.as_deref())
4070 .await?;
4071 self.get_document(&document.id)
4072 .await?
4073 .map(Some)
4074 .ok_or_else(|| SourceError::Malformed {
4075 message: format!(
4076 "document {} was written and then could not be read back",
4077 document.id
4078 ),
4079 })
4080 }
4081 /// One read of the issue and one `issueUpdate` carrying the description alone: the new
4082 /// content, and a slot whose `onetaskgraph.template` entry is `provenance` and whose every
4083 /// other entry is as it was. This source keeps no template answers — an issue has no room
4084 /// beside its description that is not the description, and answers written there would
4085 /// repeat what the content already says — so `answers` reaches nothing here.
4086 async fn set_task_rendering(
4087 &self,
4088 id: &NativeId,
4089 content: &str,
4090 provenance: &Value,
4091 _answers: &std::collections::BTreeMap<String, Value>,
4092 ) -> Result<Option<()>, SourceError> {
4093 let Some((task, description)) = self.issue_held(id, Read::ForWrite).await? else {
4094 return Ok(None);
4095 };
4096 let (_, mut slot) = metadata_description(description.clone())?;
4097 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
4098 // No read-back check, unlike a content write: this slot is never empty, it is written
4099 // after the content, and a read takes the last slot off first, so the content reads
4100 // back as itself whatever it ends in.
4101 let rewritten = Self::described(Some(content), &slot)?;
4102 if rewritten != description {
4103 self.write_description_alone(&task.id, rewritten.as_deref())
4104 .await?;
4105 }
4106 Ok(Some(()))
4107 }
4108 /// One document's rendering, on the terms of `set_task_rendering`, through one
4109 /// `documentUpdate` carrying the content alone.
4110 async fn set_document_rendering(
4111 &self,
4112 id: &NativeId,
4113 content: &str,
4114 provenance: &Value,
4115 _answers: &std::collections::BTreeMap<String, Value>,
4116 ) -> Result<Option<()>, SourceError> {
4117 let Some((document, held)) = self.document_held(id).await? else {
4118 return Ok(None);
4119 };
4120 let (_, mut slot) = metadata_description(held.clone())?;
4121 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
4122 let rewritten = Self::described(Some(content), &slot)?;
4123 if rewritten != held {
4124 self.write_document_content(&document.id, rewritten.as_deref())
4125 .await?;
4126 }
4127 Ok(Some(()))
4128 }
4129 async fn set_task_rendering_with_assets(
4130 &self,
4131 id: &NativeId,
4132 content: &str,
4133 provenance: &Value,
4134 _answers: &std::collections::BTreeMap<String, Value>,
4135 assets: &onetaskgraph_plugin_api::AssetWrite,
4136 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
4137 let Some((item, held)) = self.issue_held(id, Read::ForWrite).await? else {
4138 return Ok(None);
4139 };
4140 let (_, mut metadata) = metadata_description(held)?;
4141 metadata.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
4142 let uploads = self.upload_assets(assets).await?;
4143 let content =
4144 onetaskgraph_plugin_api::serve_asset_references(content, &mut metadata, &uploads);
4145 let written = Self::described(Some(&content), &metadata)?;
4146 self.write_description_alone(&item.id, written.as_deref())
4147 .await?;
4148 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
4149 id: item.id,
4150 content: Some(content),
4151 }))
4152 }
4153 async fn set_document_rendering_with_assets(
4154 &self,
4155 id: &NativeId,
4156 content: &str,
4157 provenance: &Value,
4158 _answers: &std::collections::BTreeMap<String, Value>,
4159 assets: &onetaskgraph_plugin_api::AssetWrite,
4160 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
4161 let Some((item, held)) = self.document_held(id).await? else {
4162 return Ok(None);
4163 };
4164 let (_, mut metadata) = metadata_description(held)?;
4165 metadata.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
4166 let uploads = self.upload_assets(assets).await?;
4167 let content =
4168 onetaskgraph_plugin_api::serve_asset_references(content, &mut metadata, &uploads);
4169 let written = Self::described(Some(&content), &metadata)?;
4170 self.write_document_content(&item.id, written.as_deref())
4171 .await?;
4172 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
4173 id: item.id,
4174 content: Some(content),
4175 }))
4176 }
4177 /// One project's rendering, on the terms of `set_task_rendering`, through one
4178 /// `projectUpdate` carrying the content alone.
4179 async fn set_project_rendering(
4180 &self,
4181 id: &NativeId,
4182 content: &str,
4183 provenance: &Value,
4184 _answers: &std::collections::BTreeMap<String, Value>,
4185 ) -> Result<Option<()>, SourceError> {
4186 let Some((project, description)) = self.project_held(id, Read::ForWrite).await? else {
4187 return Ok(None);
4188 };
4189 let (_, mut slot) = metadata_description(description.clone())?;
4190 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
4191 let rewritten = Self::described(Some(content), &slot)?;
4192 if rewritten != description {
4193 self.write_project_content(&project.id, rewritten.as_deref())
4194 .await?;
4195 }
4196 Ok(Some(()))
4197 }
4198 /// One read of the issue and one `issueUpdate` carrying only what differs; see
4199 /// `targeted_update`.
4200 async fn update_task(
4201 &self,
4202 id: &NativeId,
4203 update: &TaskUpdate,
4204 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
4205 self.targeted_update(id, update).await
4206 }
4207
4208 /// The tokens its dependency walks spelled, and nothing else: what this source holds
4209 /// across a command is its [`Vocabulary`] — the team's id, its workflow states and the
4210 /// workspace's project statuses — which stays valid in normal use and is read afresh when a
4211 /// name misses. No issue, project, document or search answer outlives the call that read it,
4212 /// so the next command already reads each item as a person left it.
4213 async fn end_command(&self) -> Result<(), SourceError> {
4214 self.edges_versions().clear();
4215 Ok(())
4216 }
4217}
4218
4219/// What a whole write's own answer says of the relations its item holds: none for an item it
4220/// created, and for one it rewrote the first page its selection read back.
4221fn held_relations<'a>(
4222 target: Option<&NativeId>,
4223 written: &'a Value,
4224) -> Result<HeldRelations<'a>, SourceError> {
4225 if target.is_none() {
4226 return Ok(HeldRelations::None);
4227 }
4228 written
4229 .get("relations")
4230 .filter(|relations| !relations.is_null())
4231 .map(HeldRelations::Page)
4232 .ok_or_else(|| SourceError::Malformed {
4233 message: "a rewrite answered without the relations it was asked for".into(),
4234 })
4235}
4236
4237/// Refuse a narrow project or document write whose payload names another item than the one it
4238/// was sent for, on the terms [`written_is`] refuses an issue's.
4239fn acknowledged(item: &Value, asked: &NativeId, root: MutationRoot) -> Result<(), SourceError> {
4240 let written = backend_id(item, "id")?;
4241 if written == asked.0 {
4242 return Ok(());
4243 }
4244 Err(SourceError::Malformed {
4245 message: format!(
4246 "{} for {asked} answered with the item {written}",
4247 root.as_str()
4248 ),
4249 })
4250}
4251
4252/// Refuse a narrow write's payload naming an issue other than the one it was sent for.
4253///
4254/// An `issueUpdate` answering with another issue is not this write landing, so it is reported
4255/// as the malformed answer it is rather than as the task having been written.
4256fn written_is(issue: &Value, asked: &NativeId) -> Result<(), SourceError> {
4257 let written = backend_id(issue, "id")?;
4258 if written == asked.0 {
4259 return Ok(());
4260 }
4261 Err(SourceError::Malformed {
4262 message: format!("issueUpdate for {asked} answered with the issue {written}"),
4263 })
4264}
4265
4266/// Why a project and a document carry neither [`Task::delivers`] nor [`Task::delivered_by`].
4267///
4268/// Only a task delivers or is delivered, so the two reserved keys name nothing a project or a
4269/// document has. A task keeps both in its description's metadata slot; anything else naming
4270/// one is refused by name rather than written.
4271const NO_DELIVERY: &str = "only a task delivers or is delivered, so neither list has a place \
4272 on anything else";
4273
4274/// The reserved delivery key `metadata` carries, if it carries one.
4275fn delivery_key_in(metadata: &std::collections::BTreeMap<String, Value>) -> Option<&'static str> {
4276 [TaskRef::DELIVERS_KEY, TaskRef::DELIVERED_BY_KEY]
4277 .into_iter()
4278 .find(|key| metadata.contains_key(*key))
4279}
4280
4281/// A category as the wire spells it — `in-progress`, `queued` — for a message.
4282fn category_word(category: StatusCategory) -> String {
4283 serde_json::to_value(category)
4284 .ok()
4285 .and_then(|value| value.as_str().map(str::to_owned))
4286 .unwrap_or_else(|| format!("{category:?}"))
4287}
4288
4289impl LinearSource {
4290 /// The refusal a write naming `named` — a field or a reserved key — on a `what` gets.
4291 fn undeliverable(&self, named: &str, what: &str) -> SourceError {
4292 SourceError::Refused {
4293 message: format!(
4294 "source {} cannot carry {named} on a {what}: {NO_DELIVERY}; write the {what} \
4295 without it",
4296 self.name
4297 ),
4298 }
4299 }
4300
4301 /// The backend id of the issue `task` names, or `None` when this source holds no such
4302 /// task — resolved by `get_task` itself, so a comment call and a task read cannot
4303 /// disagree about whether a task is there.
4304 ///
4305 /// The id Linear answers with rather than the one asked for, because `issue(id:)` also
4306 /// takes an identifier such as `ENG-1`, and the comment's own `issue{id}` is compared
4307 /// against — and a comment is created on — the backend id.
4308 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
4309 Ok(self.get_task(task).await?.map(|task| task.id))
4310 }
4311
4312 /// Whether `comment` is a comment on the issue `task` names.
4313 ///
4314 /// Asked before any edit or removal, so an id belonging to another issue — or to no
4315 /// issue, or to nothing — is answered as no such comment without a mutation reaching
4316 /// Linear. `commentUpdate` and `commentDelete` address a comment by its id alone, so
4317 /// without this a task named in error would edit or remove somebody else's comment.
4318 async fn comment_is_on(
4319 &self,
4320 task: &NativeId,
4321 comment: &NativeId,
4322 ) -> Result<bool, SourceError> {
4323 Ok(self.comment_on(task, comment).await?.is_some())
4324 }
4325
4326 /// The body the comment `comment` holds, exactly as Linear holds it, when it is one of
4327 /// `task`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
4328 async fn comment_on(
4329 &self,
4330 task: &NativeId,
4331 comment: &NativeId,
4332 ) -> Result<Option<String>, SourceError> {
4333 let Some(issue) = self.commented_issue(task).await? else {
4334 return Ok(None);
4335 };
4336 let data = self.send(graphql::COMMENT, json!({"id":comment.0})).await?;
4337 let held = optional(&data, "comment", |v| {
4338 Ok((comment_issue(v)?, str_at(v, "body")?.to_owned()))
4339 })?;
4340 Ok(held
4341 .filter(|(on, _)| on.as_ref() == Some(&issue))
4342 .map(|(_, body)| body))
4343 }
4344}
4345
4346impl LinearSource {
4347 /// The spelling this source's tokens take: [`change_token::Spelling::Attached`] while the
4348 /// store's cache stands in front of it.
4349 fn spelling(&self) -> change_token::Spelling {
4350 if self.attached() {
4351 change_token::Spelling::Attached
4352 } else {
4353 change_token::Spelling::Plain
4354 }
4355 }
4356
4357 /// Whether the store's cache stands in front of this source.
4358 fn attached(&self) -> bool {
4359 self.attached.load(std::sync::atomic::Ordering::Relaxed)
4360 }
4361
4362 /// The document a read sends: `plain`, or — while the cache stands in front of this source
4363 /// — its counterpart carrying what a `lin1` token's parts are read from.
4364 fn read_document(&self, plain: &'static str) -> &'static str {
4365 if !self.attached() {
4366 return plain;
4367 }
4368 match plain {
4369 ISSUES => graphql::ISSUES_CACHED,
4370 ISSUE => graphql::ISSUE_CACHED,
4371 graphql::ISSUE_DETAIL => graphql::ISSUE_DETAIL_CACHED,
4372 ISSUE_RELATIONS | graphql::ISSUE_DEPENDS_ON | graphql::ISSUE_DEPENDED_ON_BY => {
4373 graphql::ISSUE_RELATIONS_CACHED
4374 }
4375 PROJECTS => graphql::PROJECTS_CACHED,
4376 PROJECT => graphql::PROJECT_CACHED,
4377 PROJECT_RELATIONS | graphql::PROJECT_DEPENDS_ON | graphql::PROJECT_DEPENDED_ON_BY => {
4378 graphql::PROJECT_RELATIONS_CACHED
4379 }
4380 other => other,
4381 }
4382 }
4383
4384 /// The most rows a projects listing asks for: [`PROJECTS_CACHED_PAGE`] while the cache
4385 /// stands in front of this source, whose relations page prices each row higher, and
4386 /// [`MAX_PAGE_SIZE`] otherwise.
4387 fn projects_page(&self) -> u32 {
4388 if self.attached() {
4389 PROJECTS_CACHED_PAGE
4390 } else {
4391 MAX_PAGE_SIZE
4392 }
4393 }
4394
4395 /// [`Coverage::Covered`](onetaskgraph_plugin_api::Coverage::Covered) while the cache stands
4396 /// in front of this source, and uncovered for `reason` otherwise.
4397 fn attached_coverage(&self, reason: &str) -> onetaskgraph_plugin_api::Coverage {
4398 if self.attached() {
4399 onetaskgraph_plugin_api::Coverage::Covered
4400 } else {
4401 onetaskgraph_plugin_api::Coverage::Uncovered {
4402 reason: reason.to_owned(),
4403 }
4404 }
4405 }
4406
4407 /// Hold what `payload` — a long-form write's own answer — says Linear now stores as the
4408 /// content of `record` `id`, its slot taken off exactly as a read takes it off.
4409 ///
4410 /// # Errors
4411 ///
4412 /// [`SourceError::Malformed`] naming the field when the answer leaves out the long form the
4413 /// write selected, gives it as anything but a string or a null, or ends it in a slot no read
4414 /// could take a content out of: the write landed, and what it stored cannot be vouched for.
4415 fn hold_stored(
4416 &self,
4417 record: MetadataRecord,
4418 id: &NativeId,
4419 payload: &Value,
4420 ) -> Result<(), SourceError> {
4421 let field = match record {
4422 MetadataRecord::Task => "description",
4423 MetadataRecord::Project | MetadataRecord::Document => "content",
4424 };
4425 let malformed = |problem: &str| SourceError::Malformed {
4426 message: format!(
4427 "the answer to a write of {} {} {problem} `{field}`",
4428 record.noun(),
4429 id.0
4430 ),
4431 };
4432 let long_form = match payload.get(field) {
4433 Some(Value::String(long_form)) => Some(long_form.clone()),
4434 Some(Value::Null) => None,
4435 Some(_) => return Err(malformed("gave a non-string")),
4436 None => return Err(malformed("left out the selected")),
4437 };
4438 let (content, _) = metadata_description(long_form)?;
4439 self.stored
4440 .lock()
4441 .unwrap_or_else(std::sync::PoisonError::into_inner)
4442 .insert((record, id.clone()), content.unwrap_or_default());
4443 Ok(())
4444 }
4445
4446 fn edges_versions(
4447 &self,
4448 ) -> std::sync::MutexGuard<'_, std::collections::HashMap<EdgesRead, String>> {
4449 // A memo of what responses spelled: one a panic left behind is read as it stands.
4450 self.edges_versions
4451 .lock()
4452 .unwrap_or_else(std::sync::PoisonError::into_inner)
4453 }
4454
4455 /// Note what one page of a dependency walk of the `root` item `id` in `direction` answered,
4456 /// `data` being the response: its token, for the walk's first page; and, for a later one,
4457 /// whether it still agrees with the token the first page spelled — the recorded far ends'
4458 /// page, read from a long form a change of which moves the time the token carries, keeps it
4459 /// only while the rest of the token before its relations part is the same, and a page of
4460 /// relations past the first ends it.
4461 fn note_edges_version(
4462 &self,
4463 root: DependencyRoot,
4464 direction: Direction,
4465 id: &NativeId,
4466 data: &Value,
4467 page: WalkPage,
4468 ) {
4469 if !self.attached() {
4470 return;
4471 }
4472 let item = data.get(root.as_str()).unwrap_or(&Value::Null);
4473 let (whole, before_relations) = match root {
4474 DependencyRoot::Issue => (
4475 change_token::task(item, change_token::Spelling::Attached),
4476 change_token::task_before_relations(item),
4477 ),
4478 DependencyRoot::Project => (
4479 change_token::project_token(item, change_token::Spelling::Attached),
4480 change_token::project_before_relations(item),
4481 ),
4482 };
4483 let read = (root, direction, id.clone());
4484 let mut versions = self.edges_versions();
4485 match page {
4486 WalkPage::First => match whole {
4487 Some(token) => {
4488 versions.insert(read, token);
4489 }
4490 None => {
4491 versions.remove(&read);
4492 }
4493 },
4494 WalkPage::Recorded => {
4495 let agrees = versions.get(&read).is_some_and(|held| {
4496 before_relations.as_deref() == Some(change_token::without_relations(held))
4497 });
4498 if !agrees {
4499 versions.remove(&read);
4500 }
4501 }
4502 WalkPage::Later => {
4503 versions.remove(&read);
4504 }
4505 }
4506 }
4507}
4508
4509/// What one read of an item by id is for, which decides whether an attached cache widens it.
4510#[derive(Clone, Copy)]
4511enum Read {
4512 /// A load a caller asked for, whose answer the cache fills an entry from.
4513 Load,
4514 /// A write's own read of its target, which fills nothing.
4515 ForWrite,
4516}
4517
4518/// Which page of a forward-edge walk a response answered.
4519#[derive(Clone, Copy)]
4520enum WalkPage {
4521 /// The first, with the first page of relations.
4522 First,
4523 /// The recorded far ends, after every relation.
4524 Recorded,
4525 /// A page of relations past the first.
4526 Later,
4527}
4528
4529impl LinearSource {
4530 /// One issue as this source reads it, beside its raw `description` — or `None` for an
4531 /// issue Linear does not hold, has trashed, or that is outside the project this source is
4532 /// scoped to.
4533 ///
4534 /// Every read of one issue goes through here, so a status, a content, a metadata and a
4535 /// rendering write all answer "no such task" on exactly the terms `get_task` does. Only a
4536 /// [`Read::Load`] sends the document an attached cache widens: a write's own read of its
4537 /// target fills no entry, so the token's parts would only add complexity, and the task it
4538 /// answers carries no version while the cache is attached.
4539 async fn issue_held(
4540 &self,
4541 id: &NativeId,
4542 read: Read,
4543 ) -> Result<Option<(Task, Option<String>)>, SourceError> {
4544 let document = match read {
4545 Read::Load => self.read_document(ISSUE),
4546 Read::ForWrite => ISSUE,
4547 };
4548 let data = self.send(document, json!({"id":id.0})).await?;
4549 Ok(optional(&data, "issue", |v| {
4550 Ok((
4551 map_task(v, &self.name, &self.statuses, self.spelling())?,
4552 optional_string(v, "description")?,
4553 ))
4554 })?
4555 .filter(|(task, _)| self.in_scope(task.project.as_ref())))
4556 }
4557
4558 /// [`Self::issue_held`] for a write, with the resolution a status write needs read in the
4559 /// same request — [`graphql::ISSUE_RESOLVED`] — and held as [`Self::vocabulary`] holds it,
4560 /// only when it read completely. A source with no team configured reads the issue alone,
4561 /// and its status write is refused as it always was.
4562 async fn issue_resolving(
4563 &self,
4564 id: &NativeId,
4565 ) -> Result<Option<(Task, Option<String>)>, SourceError> {
4566 let Some(team) = self.team.as_ref() else {
4567 return self.issue_held(id, Read::ForWrite).await;
4568 };
4569 let data = self
4570 .send(graphql::ISSUE_RESOLVED, json!({"id":id.0,"key":team.0}))
4571 .await?;
4572 let read = std::sync::Arc::new(Vocabulary::read(&data, &self.name, &team.0)?);
4573 *self
4574 .vocabulary
4575 .lock()
4576 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(read);
4577 Ok(optional(&data, "issue", |v| {
4578 Ok((
4579 map_task(v, &self.name, &self.statuses, self.spelling())?,
4580 optional_string(v, "description")?,
4581 ))
4582 })?
4583 .filter(|(task, _)| self.in_scope(task.project.as_ref())))
4584 }
4585
4586 /// One issue and the page of its comments `page` names, in one request, on the terms of
4587 /// [`Self::issue_held`] and [`TaskSource::task_comments`] both.
4588 async fn issue_detail(
4589 &self,
4590 id: &NativeId,
4591 page: &PageRequest,
4592 ) -> Result<Option<TaskDetailRead>, SourceError> {
4593 if page.limit == 0 {
4594 return Err(SourceError::Config {
4595 message: "a page limit of 0 is not a page; ask for at least 1 comment".to_owned(),
4596 });
4597 }
4598 let data = self
4599 .send(
4600 self.read_document(graphql::ISSUE_DETAIL),
4601 json!({"id":id.0,"last":page.limit.min(MAX_PAGE_SIZE),"before":page.cursor.as_ref().map(|c|&c.0)}),
4602 )
4603 .await?;
4604 Ok(optional(&data, "issue", |v| {
4605 let mut task = map_task(v, &self.name, &self.statuses, self.spelling())?;
4606 // A page resumed from a cursor is not the whole of the comments, so with the cache
4607 // attached — whose token carries their digest — it vouches for no version.
4608 if self.attached() && page.cursor.is_some() {
4609 task.version = None;
4610 }
4611 Ok((task, comment_page(v)?))
4612 })?
4613 .filter(|(task, _)| self.in_scope(task.project.as_ref()))
4614 .map(|(task, comments)| TaskDetailRead {
4615 task,
4616 comments: Some(Ok(Some(comments))),
4617 }))
4618 }
4619
4620 /// One project and its raw `content`, on the terms of [`Self::issue_held`]: a source
4621 /// scoped to one project holds that one alone.
4622 async fn project_held(
4623 &self,
4624 id: &NativeId,
4625 read: Read,
4626 ) -> Result<Option<(Project, Option<String>)>, SourceError> {
4627 let document = match read {
4628 Read::Load => self.read_document(PROJECT),
4629 Read::ForWrite => PROJECT,
4630 };
4631 let data = self.send(document, json!({"id":id.0})).await?;
4632 Ok(optional(&data, "project", |v| {
4633 Ok((
4634 map_project(v, &self.statuses, self.spelling())?,
4635 optional_string(v, "content")?,
4636 ))
4637 })?
4638 .filter(|(project, _)| self.in_scope(Some(&project.id))))
4639 }
4640
4641 /// One document and its raw `content`, on the terms of [`Self::issue_held`].
4642 async fn document_held(
4643 &self,
4644 id: &NativeId,
4645 ) -> Result<Option<(Document, Option<String>)>, SourceError> {
4646 // Read as an optional although the pinned `document(id:)` returns `Document!`, for
4647 // the reason `delete_task` records: Linear answers an id naming nothing with an
4648 // errored response rather than a null, and reading the null defensively is what
4649 // keeps a responder that does answer one from being a malformed-response failure.
4650 let data = self.send(DOCUMENT, json!({"id":id.0})).await?;
4651 Ok(optional(&data, "document", |v| {
4652 Ok((map_document(v)?, optional_string(v, "content")?))
4653 })?
4654 .filter(|(document, _)| self.in_scope(document.project.as_ref())))
4655 }
4656
4657 /// Send one issue's new `description` alone, and nothing else about it.
4658 async fn write_description_alone(
4659 &self,
4660 id: &NativeId,
4661 description: Option<&str>,
4662 ) -> Result<(), SourceError> {
4663 let data = self
4664 .send(
4665 graphql::ISSUE_UPDATE,
4666 json!({"id":id.0,"input":{"description":description}}),
4667 )
4668 .await?;
4669 let issue = mutation_payload(&data, MutationRoot::IssueUpdate)?
4670 .get("issue")
4671 .filter(|issue| !issue.is_null())
4672 .ok_or_else(|| SourceError::Malformed {
4673 message: "missing issueUpdate.issue".into(),
4674 })?;
4675 written_is(issue, id)?;
4676 self.hold_stored(MetadataRecord::Task, id, issue)
4677 }
4678
4679 /// Send one project's new `content` alone.
4680 async fn write_project_content(
4681 &self,
4682 id: &NativeId,
4683 content: Option<&str>,
4684 ) -> Result<(), SourceError> {
4685 let data = self
4686 .send(
4687 graphql::PROJECT_UPDATE,
4688 json!({"id":id.0,"input":{"content":content}}),
4689 )
4690 .await?;
4691 let project = mutation_payload(&data, MutationRoot::ProjectUpdate)?
4692 .get("project")
4693 .filter(|project| !project.is_null())
4694 .ok_or_else(|| SourceError::Malformed {
4695 message: "missing projectUpdate.project".into(),
4696 })?;
4697 acknowledged(project, id, MutationRoot::ProjectUpdate)?;
4698 self.hold_stored(MetadataRecord::Project, id, project)
4699 }
4700
4701 /// Send one document's new `content` alone.
4702 async fn write_document_content(
4703 &self,
4704 id: &NativeId,
4705 content: Option<&str>,
4706 ) -> Result<(), SourceError> {
4707 let data = self
4708 .send(
4709 graphql::DOCUMENT_UPDATE,
4710 json!({"id":id.0,"input":{"content":content}}),
4711 )
4712 .await?;
4713 let document = mutation_payload(&data, MutationRoot::DocumentUpdate)?
4714 .get("document")
4715 .filter(|document| !document.is_null())
4716 .ok_or_else(|| SourceError::Malformed {
4717 message: "missing documentUpdate.document".into(),
4718 })?;
4719 acknowledged(document, id, MutationRoot::DocumentUpdate)?;
4720 self.hold_stored(MetadataRecord::Document, id, document)
4721 }
4722
4723 /// What `sources fields` reports — and, with `apply`, does first: every name the mapping
4724 /// gives each kind, checked against that kind's vocabulary, each missing one created when
4725 /// `apply` asks.
4726 ///
4727 /// Created one at a time, tasks' then projects', each in category order, so a create Linear
4728 /// refuses stops the run with every name before it created and reported so. A name present
4729 /// under another type is reported with its type and left exactly as it is: nothing here
4730 /// renames, retypes or deletes.
4731 async fn status_names(&self, apply: bool) -> Result<StatusNamesReport, SourceError> {
4732 let team = self.team.clone().ok_or_else(|| SourceError::Refused {
4733 message: format!(
4734 "source {} needs config.team to report its status names",
4735 self.name
4736 ),
4737 })?;
4738 let (mut vocabulary, _) = self.vocabulary(false).await?;
4739 let mut names = Vec::new();
4740 let mut refused = None;
4741 for kind in [ItemKind::Task, ItemKind::Project] {
4742 for (category, name) in self.statuses.names(kind) {
4743 let found = vocabulary
4744 .find(kind, name.as_str(), &self.name)?
4745 .map(|held| held.kind.clone());
4746 let mut mapped = MappedStatusName {
4747 kind,
4748 category,
4749 name: name.clone(),
4750 found: found.map_or(Found::Missing, Found::Present),
4751 };
4752 if apply && refused.is_none() && mapped.found == Found::Missing {
4753 match self
4754 .create_status_name(kind, category, name.as_str(), &vocabulary)
4755 .await
4756 {
4757 Ok(created) => {
4758 mapped.found = Found::Created(created.held().kind.clone());
4759 self.remember(&created);
4760 // Here too, so the next project status goes after this one even
4761 // when nothing is held.
4762 std::sync::Arc::make_mut(&mut vocabulary).add(created);
4763 }
4764 Err(error) => {
4765 refused = Some(RefusedCreate {
4766 kind,
4767 name: name.clone(),
4768 message: error.to_string(),
4769 });
4770 }
4771 }
4772 }
4773 names.push(mapped);
4774 }
4775 }
4776 Ok(StatusNamesReport {
4777 source: self.name.clone(),
4778 team,
4779 names,
4780 refused,
4781 })
4782 }
4783
4784 /// Create one name of `kind` — a workflow state on the vocabulary's team, or a project
4785 /// status of the workspace placed after its last — of the type its category derives, in
4786 /// the fixed colour every created name takes.
4787 async fn create_status_name(
4788 &self,
4789 kind: ItemKind,
4790 category: StatusCategory,
4791 name: &str,
4792 vocabulary: &Vocabulary,
4793 ) -> Result<Created, SourceError> {
4794 let kind_of = created_type(category, kind);
4795 let (query, input, root, payload) = match kind {
4796 ItemKind::Task => (
4797 graphql::WORKFLOW_STATE_CREATE,
4798 json!({"teamId": vocabulary.team.0, "name": name, "type": kind_of,
4799 "color": CREATED_COLOR}),
4800 MutationRoot::WorkflowStateCreate,
4801 "workflowState",
4802 ),
4803 ItemKind::Project => (
4804 graphql::PROJECT_STATUS_CREATE,
4805 json!({"name": name, "type": kind_of, "color": CREATED_COLOR,
4806 "position": vocabulary.last_position + 1.0}),
4807 MutationRoot::ProjectStatusCreate,
4808 "status",
4809 ),
4810 };
4811 let data = self.send(query, json!({ "input": input })).await?;
4812 let created = mutation_payload(&data, root)?
4813 .get(payload)
4814 .filter(|created| !created.is_null())
4815 .ok_or_else(|| SourceError::Malformed {
4816 message: format!("missing {}.{payload}", root.as_str()),
4817 })?;
4818 let held = Held::read(created)?;
4819 // Held only as what was asked for: an answer naming another name or type would be
4820 // remembered as this mapping's name, and reported created, when it is not.
4821 if held.name.as_str() != name || held.kind != kind_of {
4822 return Err(SourceError::Malformed {
4823 message: format!(
4824 "Linear answered the create of {} {name:?} of type {kind_of} with {:?} of \
4825 type {}",
4826 vocabulary_word(kind),
4827 held.name.as_str(),
4828 held.kind
4829 ),
4830 });
4831 }
4832 Ok(match kind {
4833 ItemKind::Task => Created::State(held),
4834 // Placed where Linear says it put it, so the next one goes after.
4835 ItemKind::Project => Created::Status {
4836 position: position_of(created)?,
4837 held,
4838 },
4839 })
4840 }
4841}
4842
4843/// `held` with its trailing metadata slot replaced by one holding exactly `slot`, and every
4844/// byte above the slot exactly as it was — or with a slot appended after one blank line where
4845/// it had none, and the slot taken off, with the one blank line that set it off, where `slot`
4846/// is empty.
4847///
4848/// The one place a narrow metadata write composes a long-form field, so a metadata set, a
4849/// `delivered_by` write and a copy-link record move nothing a person wrote.
4850fn reslotted(
4851 held: Option<&str>,
4852 slot: &std::collections::BTreeMap<String, Value>,
4853) -> Result<Option<String>, SourceError> {
4854 let held = held.unwrap_or_default();
4855 let Some((start, _, _)) = slot_bounds(held)? else {
4856 return LinearSource::described((!held.is_empty()).then_some(held), slot);
4857 };
4858 let above = &held[..start];
4859 if slot.is_empty() {
4860 let visible = above
4861 .strip_suffix("\n\n")
4862 .or_else(|| above.strip_suffix('\n'))
4863 .unwrap_or(above);
4864 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
4865 }
4866 Ok(Some(format!("{above}{}", slot_text(slot)?)))
4867}
4868
4869/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
4870fn set_task_list(
4871 metadata: &mut std::collections::BTreeMap<String, Value>,
4872 key: &str,
4873 entries: &[TaskRef],
4874) {
4875 if entries.is_empty() {
4876 metadata.remove(key);
4877 } else {
4878 metadata.insert(
4879 key.to_owned(),
4880 Value::Array(
4881 entries
4882 .iter()
4883 .map(|entry| Value::String(entry.as_str().to_owned()))
4884 .collect(),
4885 ),
4886 );
4887 }
4888}
4889
4890/// One page of an issue's comments, oldest first.
4891///
4892/// Linear answered newest first, walking backwards from `before`, so the page is reversed
4893/// and the next cursor is the one *behind* it; see the ruling on comments in this crate's
4894/// module documentation for why the walk runs that way.
4895fn comment_page(v: &Value) -> Result<Page<Comment>, SourceError> {
4896 let c = v.get("comments").ok_or_else(|| SourceError::Malformed {
4897 message: "missing comments connection".into(),
4898 })?;
4899 let mut items = c
4900 .get("nodes")
4901 .and_then(Value::as_array)
4902 .ok_or_else(|| SourceError::Malformed {
4903 message: "missing comment nodes".into(),
4904 })?
4905 .iter()
4906 .map(map_comment)
4907 .collect::<Result<Vec<_>, _>>()?;
4908 items.reverse();
4909 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
4910 message: "missing pageInfo".into(),
4911 })?;
4912 let older = info
4913 .get("hasPreviousPage")
4914 .and_then(Value::as_bool)
4915 .ok_or_else(|| SourceError::Malformed {
4916 message: "missing boolean pageInfo.hasPreviousPage".into(),
4917 })?;
4918 let next = if older {
4919 Some(Cursor(str_at(info, "startCursor")?.into()))
4920 } else {
4921 None
4922 };
4923 Ok(Page { items, next })
4924}
4925
4926fn map_comment(v: &Value) -> Result<Comment, SourceError> {
4927 let author = match v.get("user") {
4928 None => {
4929 return Err(SourceError::Malformed {
4930 message: "missing comment user field".into(),
4931 });
4932 }
4933 // An integration or a bot: Linear names no user, and this source invents none.
4934 Some(Value::Null) => None,
4935 Some(user) => Some(str_at(user, "displayName")?.to_owned()),
4936 };
4937 // A comment a copy wrote here carries its origin as the hidden marker at the end of its
4938 // body — Linear sets every comment's author and times itself — and is read back with the
4939 // marker taken out and reported as its origin.
4940 Ok(Comment {
4941 origin: None,
4942 id: NativeId(backend_id(v, "id")?.into()),
4943 author,
4944 created_at: time(v, "createdAt")?,
4945 updated_at: time(v, "updatedAt")?,
4946 body: str_at(v, "body")?.into(),
4947 url: optional_string(v, "url")?,
4948 }
4949 .unmarked())
4950}
4951
4952/// The comment a `commentCreate` or `commentUpdate` answered with, as Linear now holds it.
4953fn written_comment(data: &Value, root: MutationRoot) -> Result<Comment, SourceError> {
4954 let comment = mutation_payload(data, root)?
4955 .get("comment")
4956 .ok_or_else(|| SourceError::Malformed {
4957 message: format!("missing {}.comment", root.as_str()),
4958 })?;
4959 map_comment(comment)
4960}
4961
4962/// The issue a comment is on, or `None` for a comment on something else — a project, a
4963/// document, an update — which is a comment no task of this source has.
4964fn comment_issue(v: &Value) -> Result<Option<NativeId>, SourceError> {
4965 match v.get("issue") {
4966 None => Err(SourceError::Malformed {
4967 message: "missing comment issue field".into(),
4968 }),
4969 Some(Value::Null) => Ok(None),
4970 Some(issue) => Ok(Some(NativeId(backend_id(issue, "id")?.into()))),
4971 }
4972}
4973
4974/// Linear relates one Linear item to another and nothing else, so an edge whose far end
4975/// is in a different source is the one edge no `relations` entry can hold. Those edges
4976/// are read from the near item's own [`DependencyEdge::RECORDED_KEY`] metadata, and they
4977/// are served *after* the native relations are spent: a page under this cursor is the
4978/// recorded tail of the same walk, which keeps the native pages exactly what they were.
4979const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
4980
4981impl LinearSource {
4982 async fn dependencies(
4983 &self,
4984 query: &str,
4985 root: DependencyRoot,
4986 id: &NativeId,
4987 direction: Direction,
4988 page: &PageRequest,
4989 ) -> Result<Page<DependencyEdge>, SourceError> {
4990 // A cached document's token vouches only for a first page of at most
4991 // `change_token::PAGE` rows, and Linear prices a relations connection by the rows it is
4992 // asked for, so a cached read asks for that many: a wider page buys the cache nothing.
4993 // An item with more takes another request per page, which the hour budget counts.
4994 let ceiling = if matches!(
4995 query,
4996 graphql::ISSUE_RELATIONS_CACHED | graphql::PROJECT_RELATIONS_CACHED
4997 ) {
4998 u32::try_from(change_token::PAGE).unwrap_or(MAX_PAGE_SIZE)
4999 } else {
5000 MAX_PAGE_SIZE
5001 };
5002 let limit = page.limit.min(ceiling);
5003 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5004 if let Some(offset) = cursor.and_then(|c| c.strip_prefix(RECORDED_CURSOR)) {
5005 // This cursor resumes the *forward* tail and only a forward walk ever issues
5006 // one, so a reverse read carrying it is resuming a walk it did not come from.
5007 // Serving it would answer a reverse read with forward edges, which is the one
5008 // thing a recorded edge must never do — its reverse is derived from the far
5009 // end and is never written down here.
5010 if direction != Direction::DependsOn {
5011 return Err(SourceError::Malformed {
5012 message: format!(
5013 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a reverse dependency read never issues; resume it in the direction that reported it"
5014 ),
5015 });
5016 }
5017 let offset: usize = offset.parse().map_err(|_| SourceError::Malformed {
5018 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
5019 })?;
5020 let d = self
5021 .send(query, json!({"id":id.0,"first":1,"after":null}))
5022 .await?;
5023 self.note_edges_version(root, direction, id, &d, WalkPage::Recorded);
5024 return Ok(recorded_page(
5025 recorded(&d, root, id, &self.name)?,
5026 offset,
5027 limit as usize,
5028 ));
5029 }
5030 let d = self
5031 .send(query, json!({"id":id.0,"first":limit,"after":cursor}))
5032 .await?;
5033 let walked = if cursor.is_none() {
5034 WalkPage::First
5035 } else {
5036 WalkPage::Later
5037 };
5038 self.note_edges_version(root, direction, id, &d, walked);
5039 let mut answered = relation_page(&d, root, id, direction)?;
5040 // Only forwards: the reverse of a recorded edge is derived from the far end, never
5041 // written down on the near item. They ride on the last page of relations, which read
5042 // the long form they are recorded in, as far as the page has room — so they cost no
5043 // request of their own — and only what does not fit is resumed by a cursor. With the
5044 // store's cache attached they keep a page of their own, because that walk is kept only
5045 // when each page of it reads at one token, and a hit on it costs no request at all.
5046 if answered.next.is_none() && direction == Direction::DependsOn {
5047 let recorded = recorded(&d, root, id, &self.name)?;
5048 let room = if self.attached() {
5049 0
5050 } else {
5051 (limit as usize).saturating_sub(answered.items.len())
5052 };
5053 let total = recorded.len();
5054 answered.items.extend(recorded.into_iter().take(room));
5055 if total > room {
5056 answered.next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
5057 }
5058 }
5059 Ok(answered)
5060 }
5061}
5062
5063fn recorded(
5064 d: &Value,
5065 root: DependencyRoot,
5066 id: &NativeId,
5067 name: &SourceName,
5068) -> Result<Vec<DependencyEdge>, SourceError> {
5069 let item = d.get(root.as_str()).ok_or_else(|| SourceError::Malformed {
5070 message: format!("missing {}", root.as_str()),
5071 })?;
5072 let (_, metadata) = metadata_description(optional_string(item, root.long_form())?)?;
5073 // `relations` on an issue holds issues and on a project holds projects, both of this
5074 // workspace — so a same-kind far end in this same source is one Linear itself was
5075 // supposed to hold, and the key is refused rather than quietly read, whether the entry
5076 // left the source out or spelled this one.
5077 DependencyEdge::recorded(
5078 &metadata,
5079 id,
5080 root.item_kind(),
5081 name,
5082 Some(root.item_kind()),
5083 )
5084 .map_err(|message| SourceError::Malformed { message })
5085}
5086
5087fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
5088 let total = edges.len();
5089 let items: Vec<DependencyEdge> = edges.into_iter().skip(offset).take(limit.max(1)).collect();
5090 let end = offset.saturating_add(items.len());
5091 Page {
5092 items,
5093 next: (end < total).then(|| Cursor(format!("{RECORDED_CURSOR}{end}"))),
5094 }
5095}
5096
5097/// The status an issue's workflow state or a project's project status reads as, through this
5098/// instance's `status_mapping` for that kind.
5099///
5100/// A name the kind's mapping names reads as that category, under the name Linear holds — which
5101/// is what lets two states of one type, `Todo` and `Queued`, read as two categories. Every
5102/// other name reads as `unknown` under its own name, whatever its type: Linear has no built-in
5103/// names, so a type is not a category, and reading one as if it were would report a category
5104/// no write of this source could have put it at.
5105fn mapped_status(
5106 v: &Value,
5107 statuses: &StatusMapping,
5108 kind: ItemKind,
5109) -> Result<Status, SourceError> {
5110 let name = str_at(v, "name")?;
5111 Ok(Status {
5112 category: statuses
5113 .category_of(kind, name)
5114 .unwrap_or(StatusCategory::Unknown),
5115 name: name.to_owned(),
5116 })
5117}
5118fn str_at<'a>(v: &'a Value, k: &str) -> Result<&'a str, SourceError> {
5119 v.get(k)
5120 .and_then(Value::as_str)
5121 .ok_or_else(|| SourceError::Malformed {
5122 message: format!("missing string field {k}"),
5123 })
5124}
5125fn map_label(v: &Value) -> Result<Label, SourceError> {
5126 Ok(Label {
5127 id: NativeId(str_at(v, "id")?.into()),
5128 name: str_at(v, "name")?.into(),
5129 color: optional_string(v, "color")?,
5130 })
5131}
5132fn labels_of(v: &Value) -> Result<Vec<Label>, SourceError> {
5133 v.get("nodes")
5134 .and_then(Value::as_array)
5135 .ok_or_else(|| SourceError::Malformed {
5136 message: "missing label nodes".into(),
5137 })?
5138 .iter()
5139 .map(map_label)
5140 .collect()
5141}
5142fn time(v: &Value, k: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
5143 optional_str(v, k)?
5144 .map(|s| {
5145 s.parse().map_err(|e| SourceError::Malformed {
5146 message: format!("invalid {k}: {e}"),
5147 })
5148 })
5149 .transpose()
5150}
5151/// One issue as a task, `source` being this source's configured name.
5152///
5153/// The name is what lets [`TaskRef::listed`] tell `work:I-1` on the issue `I-1` of the
5154/// source `work` apart as that issue itself, rather than recognising only the bare spelling.
5155///
5156/// Its version is the issue's `lin1` token — see [`change_token`] — spelled with the relations part
5157/// when `attached`, and absent when the response does not carry every part whole.
5158fn map_task(
5159 v: &Value,
5160 source: &SourceName,
5161 statuses: &StatusMapping,
5162 spelling: change_token::Spelling,
5163) -> Result<Task, SourceError> {
5164 let (content, mut metadata) = metadata_description(optional_string(v, "description")?)?;
5165 let repositories = Repository::from_metadata(&metadata)
5166 .map_err(|message| SourceError::Malformed { message })?;
5167 let classification = classification_of(&mut metadata)?;
5168 let url = optional_string(v, "url")?;
5169 let id = NativeId(str_at(v, "id")?.into());
5170 // Taken out of the caller's metadata as they are read: a reserved key is this product's,
5171 // and reporting it there as well would hand a consumer two spellings of one list.
5172 let delivers = delivery_list(&mut metadata, TaskRef::DELIVERS_KEY, &id, source)?;
5173 let delivered_by = delivery_list(&mut metadata, TaskRef::DELIVERED_BY_KEY, &id, source)?;
5174 Ok(Task {
5175 version: change_token::task(v, spelling),
5176 id,
5177 // `Issue.identifier` is `String!` and every read of an issue selects it, so a
5178 // response without one is a response this source cannot read rather than an issue
5179 // with no handle — Linear gives every issue one.
5180 key: Some(str_at(v, "identifier")?.into()),
5181 title: str_at(v, "title")?.into(),
5182 content,
5183 status: mapped_status(
5184 v.get("state").ok_or_else(|| SourceError::Malformed {
5185 message: "missing state".into(),
5186 })?,
5187 statuses,
5188 ItemKind::Task,
5189 )?,
5190 priority: issue_priority(v)?,
5191 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
5192 message: "missing labels".into(),
5193 })?)?,
5194 project: filed_under(v)?,
5195 location: web_address(url.as_deref()),
5196 url,
5197 created_at: time(v, "createdAt")?,
5198 updated_at: time(v, "updatedAt")?,
5199 metadata,
5200 repositories,
5201 delivers,
5202 delivered_by,
5203 classification,
5204 })
5205}
5206/// A priority as Linear's `Issue.priority` and its two input members spell it.
5207///
5208/// Linear's own scale, as its published schema describes the field: `0` is no priority,
5209/// `1` urgent, `2` high, `3` normal and `4` low. Normal is this contract's `medium`.
5210const fn linear_priority(priority: Priority) -> u8 {
5211 match priority {
5212 Priority::None => 0,
5213 Priority::Urgent => 1,
5214 Priority::High => 2,
5215 Priority::Medium => 3,
5216 Priority::Low => 4,
5217 }
5218}
5219
5220/// The priority an issue carries, read from `Issue.priority`.
5221///
5222/// Linear declares that field `Float!` while its inputs take an `Int`, so `2` and `2.0` are
5223/// the same answer. Anything else — absent, null, fractional, or outside `0` to `4` — is a
5224/// response this source cannot read, never a guess at the nearest level: a priority reported
5225/// that a filter for it could not find is capability rule 1 broken.
5226fn issue_priority(v: &Value) -> Result<Priority, SourceError> {
5227 let raw = v.get("priority").ok_or_else(|| SourceError::Malformed {
5228 message: "missing number field priority".into(),
5229 })?;
5230 let level = raw.as_f64().filter(|level| level.fract() == 0.0);
5231 Priority::ALL
5232 .into_iter()
5233 .find(|priority| level == Some(f64::from(linear_priority(*priority))))
5234 .ok_or_else(|| SourceError::Malformed {
5235 message: format!(
5236 "field priority is {raw}, which is none of Linear's priorities 0 (none), \
5237 1 (urgent), 2 (high), 3 (normal) and 4 (low)"
5238 ),
5239 })
5240}
5241
5242/// One delivery list read out of an issue's metadata slot, and removed from it.
5243///
5244/// An entry that is not a task id, that names the issue itself, or that repeats is a
5245/// malformed response naming the task and the entry, never a list quietly shortened.
5246fn delivery_list(
5247 metadata: &mut std::collections::BTreeMap<String, Value>,
5248 key: &str,
5249 task: &NativeId,
5250 source: &SourceName,
5251) -> Result<Vec<TaskRef>, SourceError> {
5252 let held = metadata.remove(key);
5253 TaskRef::from_value(key, task, Some(source), held.as_ref())
5254 .map_err(|message| SourceError::Malformed { message })
5255}
5256/// Remove the two delivery keys from a project's or a document's metadata.
5257///
5258/// Neither is work that delivers anything, so a key there names nothing this contract has,
5259/// and it is not the caller's free metadata either: it is this product's reserved spelling.
5260fn strip_delivery_keys(metadata: &mut std::collections::BTreeMap<String, Value>) {
5261 metadata.remove(TaskRef::DELIVERS_KEY);
5262 metadata.remove(TaskRef::DELIVERED_BY_KEY);
5263}
5264/// Its version is the project's `lin1` token in `spelling`, absent when the response does not
5265/// carry every part whole.
5266fn map_project(
5267 v: &Value,
5268 statuses: &StatusMapping,
5269 spelling: change_token::Spelling,
5270) -> Result<Project, SourceError> {
5271 let (content, mut metadata) = metadata_description(optional_string(v, "content")?)?;
5272 strip_delivery_keys(&mut metadata);
5273 let repositories = Repository::from_metadata(&metadata)
5274 .map_err(|message| SourceError::Malformed { message })?;
5275 let classification = classification_of(&mut metadata)?;
5276 let url = optional_string(v, "url")?;
5277 Ok(Project {
5278 version: change_token::project_token(v, spelling),
5279 id: NativeId(str_at(v, "id")?.into()),
5280 title: str_at(v, "name")?.into(),
5281 content,
5282 status: mapped_status(
5283 v.get("status").ok_or_else(|| SourceError::Malformed {
5284 message: "missing status".into(),
5285 })?,
5286 statuses,
5287 ItemKind::Project,
5288 )?,
5289 labels: labels_of(v.get("labels").ok_or_else(|| SourceError::Malformed {
5290 message: "missing project labels".into(),
5291 })?)?,
5292 location: web_address(url.as_deref()),
5293 url,
5294 created_at: time(v, "createdAt")?,
5295 updated_at: time(v, "updatedAt")?,
5296 metadata,
5297 repositories,
5298 classification,
5299 })
5300}
5301
5302/// Where a Linear entity is: the web address Linear itself reports for it, as a link.
5303///
5304/// Every issue, project and document of a Linear workspace has a page a person can open,
5305/// so this source says so for all three — the counterpart of a folder of Markdown
5306/// reporting the path of the file behind an item. A source that reported nothing here is
5307/// what leaves a reader holding an opaque id, and `None` is reserved for the case Linear
5308/// really did not say, which is not the same as saying the entity is nowhere.
5309fn web_address(url: Option<&str>) -> Option<Location> {
5310 url.map(|url| Location::Url(url.to_owned()))
5311}
5312
5313/// The project a Linear item is filed under, or `None` for one filed under nothing.
5314///
5315/// One reader for issues and documents alike, because the field is the same field: an
5316/// absent `project` key is a malformed response, a null one is an orphan.
5317fn filed_under(v: &Value) -> Result<Option<NativeId>, SourceError> {
5318 match v.get("project") {
5319 None => Err(SourceError::Malformed {
5320 message: "missing project field".into(),
5321 }),
5322 Some(Value::Null) => Ok(None),
5323 Some(project) => Ok(Some(NativeId(str_at(project, "id")?.into()))),
5324 }
5325}
5326
5327/// The classification an item's slot records, taken out of the caller's metadata as it is
5328/// read: the key is this product's, and the item reports it as its own field.
5329fn classification_of(
5330 metadata: &mut std::collections::BTreeMap<String, Value>,
5331) -> Result<Classification, SourceError> {
5332 let classification = Classification::from_metadata(metadata)
5333 .map_err(|message| SourceError::Malformed { message })?;
5334 metadata.remove(Classification::METADATA_KEY);
5335 Ok(classification)
5336}
5337
5338fn map_document(v: &Value) -> Result<Document, SourceError> {
5339 let (content, mut metadata) = metadata_description(optional_string(v, "content")?)?;
5340 strip_delivery_keys(&mut metadata);
5341 let repositories = Repository::from_metadata(&metadata)
5342 .map_err(|message| SourceError::Malformed { message })?;
5343 let classification = classification_of(&mut metadata)?;
5344 let url = optional_string(v, "url")?;
5345 Ok(Document {
5346 version: change_token::document_token(v),
5347 id: NativeId(str_at(v, "id")?.into()),
5348 title: str_at(v, "title")?.into(),
5349 content,
5350 project: filed_under(v)?,
5351 // Linear's `Document` carries no labels, and that is the published schema rather
5352 // than a gap here: the types of it that carry `labels` are `Issue`, `Project`,
5353 // `Team`, `Initiative` and `Organization`. Reporting none is what a source with no
5354 // native slot owes; standing one up beside a first-class type is what this source
5355 // exists not to do, and `write_document` refuses a label by name for the same
5356 // reason rather than dropping it.
5357 labels: Vec::new(),
5358 location: web_address(url.as_deref()),
5359 url,
5360 created_at: time(v, "createdAt")?,
5361 updated_at: time(v, "updatedAt")?,
5362 metadata,
5363 repositories,
5364 classification,
5365 })
5366}
5367
5368/// Whether this document satisfies the predicates this source applies to a fetched page.
5369///
5370/// Two of them reach a page rather than the `documents(filter:)` variables, and each for a
5371/// reason of Linear's own. `DocumentFilter.project` is a `ProjectFilter` where
5372/// `IssueFilter.project` is a `NullableProjectFilter`, so only the issue side can be asked
5373/// for the items belonging to no project. And a Linear document carries no label at all,
5374/// so a query demanding one keeps nothing and a query excluding one keeps everything —
5375/// which is this source *applying* the predicate it declares native, over the labels the
5376/// document really has, rather than ignoring it.
5377fn document_matches(document: &Document, project: &ProjectFilter, labels: &LabelFilter) -> bool {
5378 let carries = |name: &String| {
5379 document
5380 .labels
5381 .iter()
5382 .any(|label| label.name.eq_ignore_ascii_case(name))
5383 };
5384 let filed = match project {
5385 ProjectFilter::Any => true,
5386 ProjectFilter::Orphans => document.project.is_none(),
5387 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
5388 };
5389 filed
5390 && (labels.any_of.is_empty() || labels.any_of.iter().any(&carries))
5391 && labels.all_of.iter().all(&carries)
5392 && !labels.none_of.iter().any(&carries)
5393}
5394
5395fn optional<T>(
5396 d: &Value,
5397 k: &str,
5398 f: impl Fn(&Value) -> Result<T, SourceError>,
5399) -> Result<Option<T>, SourceError> {
5400 match d.get(k) {
5401 None => Err(SourceError::Malformed {
5402 message: format!("missing {k}"),
5403 }),
5404 Some(Value::Null) => Ok(None),
5405 // An item Linear no longer shows is not an item this source holds, and Linear says
5406 // so with `archivedAt` rather than by answering null.
5407 //
5408 // **None of Linear's three `delete` verbs removes anything.** `issueDelete`,
5409 // `projectDelete` and `documentDelete` move the item to the trash: observed on
5410 // 2026-09-04, each answered `success: true` and the item still read back by id,
5411 // carrying `archivedAt` and `trashed: true`. Its separate *archive* verb is a third
5412 // state — `archivedAt` set, `trashed` null — and Linear excludes both from every
5413 // connection, so `issues`, `projects` and `documents` had already stopped returning
5414 // them while a read by id still did.
5415 //
5416 // `archivedAt` rather than `trashed` for exactly that reason: it is the marker both
5417 // states share, so a read by id answers what a listing answers, and a delete means
5418 // what a copy's undo needs it to mean — the item this run created is gone.
5419 Some(value) if !matches!(value.get("archivedAt"), None | Some(Value::Null)) => Ok(None),
5420 Some(value) => f(value).map(Some),
5421 }
5422}
5423fn connection<T>(
5424 d: &Value,
5425 k: &str,
5426 f: impl Fn(&Value) -> Result<T, SourceError>,
5427) -> Result<Page<T>, SourceError> {
5428 let c = d.get(k).ok_or_else(|| SourceError::Malformed {
5429 message: format!("missing {k} connection"),
5430 })?;
5431 let items = c
5432 .get("nodes")
5433 .and_then(Value::as_array)
5434 .ok_or_else(|| SourceError::Malformed {
5435 message: "missing nodes".into(),
5436 })?
5437 .iter()
5438 .map(f)
5439 .collect::<Result<_, _>>()?;
5440 let next = page_next(c)?;
5441 Ok(Page { items, next })
5442}
5443#[derive(Clone, Copy, PartialEq, Eq, Hash)]
5444enum DependencyRoot {
5445 Issue,
5446 Project,
5447}
5448impl DependencyRoot {
5449 const fn item_kind(self) -> ItemKind {
5450 match self {
5451 Self::Issue => ItemKind::Task,
5452 Self::Project => ItemKind::Project,
5453 }
5454 }
5455 const fn as_str(self) -> &'static str {
5456 match self {
5457 Self::Issue => "issue",
5458 Self::Project => "project",
5459 }
5460 }
5461 /// The field holding the item's long form and its metadata slot: an issue's
5462 /// `description`, a project's `content`.
5463 const fn long_form(self) -> &'static str {
5464 match self {
5465 Self::Issue => "description",
5466 Self::Project => "content",
5467 }
5468 }
5469}
5470fn relation_page(
5471 d: &Value,
5472 root: DependencyRoot,
5473 id: &NativeId,
5474 direction: Direction,
5475) -> Result<Page<DependencyEdge>, SourceError> {
5476 let key = if direction == Direction::DependsOn {
5477 "relations"
5478 } else {
5479 "inverseRelations"
5480 };
5481 let c = d
5482 .get(root.as_str())
5483 .and_then(|v| v.get(key))
5484 .ok_or_else(|| SourceError::Malformed {
5485 message: format!("missing {key}"),
5486 })?;
5487 let nodes = c
5488 .get("nodes")
5489 .and_then(Value::as_array)
5490 .ok_or_else(|| SourceError::Malformed {
5491 message: "missing relation nodes".into(),
5492 })?;
5493 let mut items = Vec::new();
5494 for n in nodes {
5495 let other = n
5496 .get(if direction == Direction::DependsOn {
5497 "relatedIssue"
5498 } else {
5499 "issue"
5500 })
5501 .or_else(|| {
5502 n.get(if direction == Direction::DependsOn {
5503 "relatedProject"
5504 } else {
5505 "project"
5506 })
5507 })
5508 .and_then(|v| v.get("id"))
5509 .and_then(Value::as_str)
5510 .ok_or_else(|| SourceError::Malformed {
5511 message: "missing related id".into(),
5512 })?;
5513 let (from, to) = if direction == Direction::DependsOn {
5514 (id.clone(), NativeId(other.into()))
5515 } else {
5516 (NativeId(other.into()), id.clone())
5517 };
5518 // 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.
5519 let relation_type =
5520 n.get("type")
5521 .and_then(Value::as_str)
5522 .ok_or_else(|| SourceError::Malformed {
5523 message: "missing relation type".into(),
5524 })?;
5525 // An issue relation and a project relation do not share a vocabulary. Linear
5526 // spells a project dependency `dependency`, where an issue's is `blocks`; the
5527 // write side sends exactly that pair and says why. So each root reads only its
5528 // own, and a value the other root would have accepted is refused here rather than
5529 // read as an edge this source could not have written.
5530 //
5531 // `related` is one of those values, and only an issue relation has it. Linear's
5532 // validator enumerates a project relation's `type` as `dependency` alone — see
5533 // the write side, which had `related` refused by the real API on 2026-09-04 — so
5534 // a project relation typed `related` is not a relation this workspace can hold.
5535 let kind = match (root, relation_type) {
5536 (DependencyRoot::Issue, "blocks") | (DependencyRoot::Project, "dependency") => {
5537 DependencyKind::Blocks
5538 }
5539 (DependencyRoot::Issue, "related") => DependencyKind::Related,
5540 _ => {
5541 return Err(SourceError::Malformed {
5542 message: format!(
5543 "invalid relation type: {relation_type} on a {} relation",
5544 root.as_str()
5545 ),
5546 });
5547 }
5548 };
5549 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
5550 let item_kind = root.item_kind();
5551 items.push(DependencyEdge {
5552 from: DependencyEndpoint::from_native(from, item_kind),
5553 to: DependencyEndpoint::from_native(to, item_kind),
5554 kind,
5555 });
5556 }
5557 let next = page_next(c)?;
5558 Ok(Page { items, next })
5559}
5560
5561fn optional_str<'a>(v: &'a Value, k: &str) -> Result<Option<&'a str>, SourceError> {
5562 match v.get(k) {
5563 None => Err(SourceError::Malformed {
5564 message: format!("missing field {k}"),
5565 }),
5566 Some(Value::Null) => Ok(None),
5567 Some(value) => value
5568 .as_str()
5569 .map(Some)
5570 .ok_or_else(|| SourceError::Malformed {
5571 message: format!("field {k} is not a string"),
5572 }),
5573 }
5574}
5575
5576/// Linear has no caller-defined fields. The source owns an unobtrusive Markdown comment at the
5577/// end of the long-form field, and writes it on one line with the canonical JSON inside a code
5578/// span: ``<!-- onetaskgraph.metadata `{…}` -->``. The code span is the one spelling Linear keeps
5579/// byte for byte in a description or a document — see the module documentation's ruling on what
5580/// Linear does to an HTML comment — and [`slot_json`] escapes the three characters that could
5581/// end it early.
5582const METADATA_PREFIX: &str = "<!-- onetaskgraph.metadata";
5583/// The opening of the slot this source writes.
5584const METADATA_OPEN_SPAN: &str = "<!-- onetaskgraph.metadata `";
5585/// The close of the slot this source writes.
5586const METADATA_CLOSE_SPAN: &str = "` -->";
5587/// The opening of the multi-line slot this source wrote before the code span, still read: an
5588/// item written then keeps its metadata. Linear normalized its JSON, so a key or a value
5589/// Linear rewrote reads back as Linear left it — or, for an array Linear escaped, as a
5590/// malformed slot naming itself — and the next write of that item writes the code span.
5591const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
5592const METADATA_CLOSE: &str = "\n-->";
5593/// The same close as Linear hands that multi-line slot back: it escapes a line opening
5594/// `-->`, so the slot reads back with a backslash before its close (observed from the real API
5595/// on 2026-09-14 for a document and on 2026-10-02 for an issue's description too).
5596const METADATA_CLOSE_ESCAPED: &str = "\n\\-->";
5597
5598/// One JSON value in the encoding the slot holds it in: compact canonical JSON with `<`, `>`
5599/// and `` ` `` escaped as `\u003c`, `\u003e` and `\u0060`.
5600///
5601/// All three only ever occur inside a JSON string, where the escape means the same character,
5602/// so the value parses back exactly; and with them escaped no value can close the code span or
5603/// the HTML comment around it. A search phrase is not built with this: `slot_phrase` sends only
5604/// a value none of whose characters any encoder escapes, which this leaves as written.
5605fn slot_json(value: &impl serde::Serialize) -> Result<String, SourceError> {
5606 let encoded = serde_json::to_string(value).map_err(|error| SourceError::Malformed {
5607 message: error.to_string(),
5608 })?;
5609 Ok(encoded
5610 .replace('<', "\\u003c")
5611 .replace('>', "\\u003e")
5612 .replace('`', "\\u0060"))
5613}
5614
5615/// The slot holding exactly `metadata`, as this source writes it.
5616fn slot_text(metadata: &std::collections::BTreeMap<String, Value>) -> Result<String, SourceError> {
5617 Ok(format!(
5618 "{METADATA_OPEN_SPAN}{}{METADATA_CLOSE_SPAN}",
5619 slot_json(metadata)?
5620 ))
5621}
5622
5623/// Where the trailing metadata slot of `description` is: the byte its opening marker starts
5624/// at, and the span of the encoded JSON inside it — or `None` when it ends in no slot.
5625///
5626/// The one place the slot is recognised, in either spelling, so what [`metadata_description`]
5627/// reads out and what [`metadata_slot`] keeps for a content write are the same bytes.
5628fn slot_bounds(description: &str) -> Result<Option<(usize, usize, usize)>, SourceError> {
5629 let Some(start) = description.rfind(METADATA_PREFIX) else {
5630 return Ok(None);
5631 };
5632 let rest = &description[start..];
5633 let (encoded_start, close) = if rest.starts_with(METADATA_OPEN_SPAN) {
5634 let encoded_start = start + METADATA_OPEN_SPAN.len();
5635 (
5636 encoded_start,
5637 description[encoded_start..]
5638 .rfind(METADATA_CLOSE_SPAN)
5639 .map(|at| (at, METADATA_CLOSE_SPAN.len())),
5640 )
5641 } else if rest.starts_with(METADATA_OPEN) {
5642 let encoded_start = start + METADATA_OPEN.len();
5643 (
5644 encoded_start,
5645 [METADATA_CLOSE, METADATA_CLOSE_ESCAPED]
5646 .into_iter()
5647 .filter_map(|close| {
5648 description[encoded_start..]
5649 .find(close)
5650 .map(|at| (at, close.len()))
5651 })
5652 .min(),
5653 )
5654 } else {
5655 return Ok(None);
5656 };
5657 let Some((relative_end, close_len)) = close else {
5658 return Err(SourceError::Malformed {
5659 message: "unterminated onetaskgraph metadata slot in Linear description".into(),
5660 });
5661 };
5662 let encoded_end = encoded_start + relative_end;
5663 if !description[encoded_end + close_len..].trim().is_empty() {
5664 return Ok(None);
5665 }
5666 Ok(Some((start, encoded_start, encoded_end)))
5667}
5668
5669/// The metadata slot `description` ends in, exactly as it is stored, or `None`.
5670fn metadata_slot(description: &str) -> Result<Option<&str>, SourceError> {
5671 Ok(slot_bounds(description)?.map(|(start, _, _)| &description[start..]))
5672}
5673
5674fn metadata_description(
5675 description: Option<String>,
5676) -> Result<(Option<String>, std::collections::BTreeMap<String, Value>), SourceError> {
5677 let Some(description) = description else {
5678 return Ok((None, Default::default()));
5679 };
5680 let Some((start, encoded_start, encoded_end)) = slot_bounds(&description)? else {
5681 return Ok((Some(description), Default::default()));
5682 };
5683 let metadata =
5684 serde_json::from_str(&description[encoded_start..encoded_end]).map_err(|error| {
5685 SourceError::Malformed {
5686 message: format!(
5687 "invalid canonical JSON in Linear onetaskgraph metadata slot: {error}"
5688 ),
5689 }
5690 })?;
5691 // Exactly the text above the slot less the one blank line `long_form` sets it off by, so
5692 // content whose own end is whitespace reads back as itself. A description edited in Linear
5693 // down to a single line break before the slot loses just that one.
5694 let above = &description[..start];
5695 let mut visible = above
5696 .strip_suffix("\n\n")
5697 .or_else(|| above.strip_suffix('\n'))
5698 .unwrap_or(above);
5699 // A table ending the content keeps its last row's line break, as Linear stores it, and the
5700 // one blank line before the slot is the line after it: see `described`.
5701 if above.ends_with("\n\n") && stored_form::ends_in_table_row(visible) {
5702 visible = &above[..above.len() - 1];
5703 }
5704 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
5705}
5706
5707/// The narrowing that asks Linear for the issues whose description holds `"<value>"` — a
5708/// string `value` as any JSON encoder writes it, quotes included — or `None` when `value` holds
5709/// a character an encoder may write another way, and only the confirmation decides.
5710///
5711/// A candidate set rather than the answer: the phrase can sit in the visible prose, or under
5712/// another key, and both are kept out by the confirmation over the parsed slot that follows
5713/// every read. What it cannot do is miss an issue whose slot holds the value — in the code span
5714/// this source writes, in the multi-line slot it wrote before, or in one a person spaced or
5715/// re-encoded by hand — which is what makes sending it sound. So it names the value alone, never
5716/// the key beside it, whose spacing a slot is free to vary; and only a value of printable ASCII
5717/// none of whose characters any encoder escapes — not `"`, `\`, `/`, `<`, `>`, `&`, `'` or a
5718/// backtick — because one that is escaped would be spelled in a stored slot otherwise than here.
5719fn slot_phrase(value: &str) -> Option<Value> {
5720 let verbatim = value.chars().all(|character| {
5721 (character.is_ascii_graphic() && !"\"\\/<>&'`".contains(character)) || character == ' '
5722 });
5723 verbatim.then(|| json!({"description": {"contains": format!("\"{value}\"")}}))
5724}
5725
5726/// Whether `title`/`content` satisfies `query`, case-insensitively — the contract's own rule,
5727/// which the engine applies for a source that does not search, so the two cannot answer one
5728/// workspace differently.
5729fn text_holds(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
5730 let terms = query.terms.to_lowercase();
5731 let in_title = title.to_lowercase().contains(&terms);
5732 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
5733 match query.fields {
5734 TextFields::Title => in_title,
5735 TextFields::Content => in_content,
5736 TextFields::TitleOrContent => in_title || in_content,
5737 }
5738}
5739
5740fn optional_string(v: &Value, k: &str) -> Result<Option<String>, SourceError> {
5741 Ok(optional_str(v, k)?.map(Into::into))
5742}
5743fn backend_id<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
5744 let id = str_at(value, field)?;
5745 (!id.is_empty())
5746 .then_some(id)
5747 .ok_or_else(|| SourceError::Malformed {
5748 message: format!("field {field} is an empty backend id"),
5749 })
5750}
5751fn mutation_payload(data: &Value, root: MutationRoot) -> Result<&Value, SourceError> {
5752 let root = root.as_str();
5753 let payload = data.get(root).ok_or_else(|| SourceError::Malformed {
5754 message: format!("missing {root}"),
5755 })?;
5756 match payload.get("success").and_then(Value::as_bool) {
5757 Some(true) => Ok(payload),
5758 Some(false) => Err(SourceError::Refused {
5759 message: format!("Linear reported {root} was unsuccessful"),
5760 }),
5761 None => Err(SourceError::Malformed {
5762 message: format!("missing boolean {root}.success"),
5763 }),
5764 }
5765}
5766fn page_next(c: &Value) -> Result<Option<Cursor>, SourceError> {
5767 let info = c.get("pageInfo").ok_or_else(|| SourceError::Malformed {
5768 message: "missing pageInfo".into(),
5769 })?;
5770 let more = info
5771 .get("hasNextPage")
5772 .and_then(Value::as_bool)
5773 .ok_or_else(|| SourceError::Malformed {
5774 message: "missing boolean pageInfo.hasNextPage".into(),
5775 })?;
5776 if !more {
5777 return Ok(None);
5778 }
5779 let cursor = str_at(info, "endCursor")?;
5780 Ok(Some(Cursor(cursor.into())))
5781}
5782
5783// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] https://linear.app/developers/rate-limiting documents these headers and epoch milliseconds, but Linear publishes no machine-readable definition of its codes and headers. One shared parser serves GraphQL and asset HTTP requests; the loopback rate-limit journeys and live asset journey hold the documented response contract.
5784fn reset_wait(headers: &reqwest::header::HeaderMap) -> Option<std::time::Duration> {
5785 use std::time::Duration;
5786 let reset = [
5787 "x-ratelimit-endpoint-requests-reset",
5788 "x-ratelimit-requests-reset",
5789 "x-ratelimit-complexity-reset",
5790 ]
5791 .iter()
5792 .filter_map(|name| headers.get(*name)?.to_str().ok()?.parse::<u64>().ok())
5793 .max();
5794 reset
5795 .map(|reset| {
5796 Duration::from_millis(reset.saturating_sub(
5797 u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or_default(),
5798 ))
5799 })
5800 .or_else(|| {
5801 headers
5802 .get("retry-after")?
5803 .to_str()
5804 .ok()?
5805 .parse::<u64>()
5806 .ok()
5807 .map(Duration::from_secs)
5808 })
5809}
5810// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]