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