Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A write held to a version** (`--if-version`) is checked immediately before its first
4//! mutation and not as one step with it: GitHub takes no conditional write, so the engine reads
5//! the task through this source just before writing it, and a change landing between that read
6//! and the mutation — the time one request takes — is GitHub's residual race.
7//!
8//! **A board is a container of projects, not a project.** Its own `title`,
9//! `shortDescription` and `readme` are never read as an item's fields and are never
10//! written: nothing in this source can rename the board a user configured.
11//!
12//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
13//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
14//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
15//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
16//! naming the field — but that repository is the *fallback*, not the home of every item.
17//!
18//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
19//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
20//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
21//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
22//! change to the rule. -->
23//! **Which repository an issue is created in is decided by the item's own `repositories`
24//! field, under one rule.** Exactly one entry names the repository the issue is created in:
25//! a task issue is where a person finds the work from the repository it changes, and one
26//! filed in a board's nominated repository is invisible from every other. Zero entries, or
27//! two or more, name none, so a task's or a document's issue is created in the repository
28//! its parent project's issue lives in — read from the board, or from this process's own
29//! record of a project it created earlier in the same command — and a project's issue, or
30//! a task or document written with no parent, is created in the configured `repository:`.
31//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
32//! existing issue is never moved: the update path leaves the issue where it is and records
33//! the list in the metadata slot when it differs, so the read side's derivation and the
34//! creation rule agree by construction.
35//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
36//!
37//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
38//! board has no document type and nothing but issues to hold one in, so the title is the
39//! discriminator and it is the whole of it. The title this source *reports* is the one a
40//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
41//! the body so `content` is what the person wrote — and writing a document puts the prefix
42//! back, so a round trip returns the title that went in.
43//!
44//! **Telling a document from a project from a task.** The design prefix is read **first**:
45//! a document is never a project and never a task, whatever sub-issues it has or does not
46//! have. Only then does the rest apply — a board issue is a project when *either* it has
47//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
48//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
49//! necessary: it is what makes an *empty* project — the state a project copy passes
50//! through between creating the project and filing its first task — readable as a
51//! project, while the sub-issue arm lets a person author a project on the board by hand
52//! with no knowledge of this product's metadata at all. Reading the prefix later than the
53//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
54//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
55//! task nor a document and are ignored.
56//!
57//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
58//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
59//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
60//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
61//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
62//! every comment as the account the token belongs to, so a comment handed an author of its
63//! own is refused rather than posted under another name. GitHub's comment mutations take the
64//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
65//! is on, and a comment on some other issue is one this task does not have.
66//!
67//! **Where an entity is, is a link.** Every project, task and document this source reports
68//! carries a [`Location::Url`] naming the issue's own web address — the same address the
69//! `url` field already reports, in the shape that says a reader can open it. That is the
70//! contrast the location contract exists for: a reader holding an entity from this source
71//! is handed something to link to and one holding an entity from a folder of Markdown is
72//! handed a path, and neither has to know which plugin answered. It does not replace or
73//! derive from `url`; that field goes on reporting what it always reported.
74//!
75//! **Where metadata lives.** Short typed things go to typed fields and native relations:
76//! status to the board's `Status` single-select and the issue's own state, the copy
77//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
78//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
79//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
80//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
81//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
82//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
83//! a project or a document alike — is one update of the issue body that changes that slot
84//! and not one byte outside it, and it is not sent at all when the key already holds the
85//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
86//! is kept in that same slot, written by that same update.
87//!
88//! **A caller's value can also be projected onto a board text field**, so the board can filter
89//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
90//! metadata key and a path inside its value. The slot stays the value's one home — every read
91//! reports metadata from it alone — and the field follows it on every write of an item, a
92//! create, an update and a copy alike: a string is written, and not sent again while the field
93//! holds it — an empty string included, which is written as itself; nothing there or `null`
94//! clears a field holding a value; any other
95//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
96//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
97//! own moves its field first and the body last, as every write of an existing item does. A
98//! board without the field, or with a non-text field of that name, is refused before any
99//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
100//! a text field and never changes a field that is there.
101//!
102// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
103//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
104//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
105//! option of the board's one `Status` field for a task and for a project: a bare name is
106//! the option for both kinds, `null` disables the category for both, and `{task, project}`
107//! names it per kind. A category the mapping does not mention keeps its shipped default for
108//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
109//! gets the shipped default for the kind it leaves out. Two categories one kind would read
110//! back from one option are refused as the configuration is read, ignoring case, while one
111//! option may stand for different categories of the two kinds. Writes go by the kind of the
112//! item written: a status that kind has no option for, or whose option the board lacks, is
113//! refused before any mutation, naming the source, the kind, the category and the key
114//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
115//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
116//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
117//! before selecting its option. Reads give a closed issue's reason precedence over its
118//! option, while an open issue's option decides its category through its own kind's
119//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
120//! The guarded [`GitHubProjectsSource::status_options`] and
121//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
122//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
123//! existing option id and verify the field and item assignments immediately afterwards.
124//! They ask for both kinds' options, counting a terminal category's mapped option as
125//! configured because a terminal write refuses without it. No ordinary source read or
126//! write calls that mutation, whose
127//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
128//! and a mistake destroys every item's status. A status this board cannot represent is a
129//! refusal naming the status and the instance instead.
130//!
131//! `unknown` has no shipped option because this source cannot preserve an open-ended
132//! status word: it writes an existing board option and never
133//! creates an option. An operator may map `unknown` to one existing option, in which case
134//! every unknown word lands on that option and reads back as `unknown` under the option's
135//! name. This differs from `local-md`, which writes and reads the original word itself.
136//!
137//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
138//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
139//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
140//! finished tasks were only moved to a "Done" column would read 0% complete forever.
141// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
142//!
143//! # What this source declares, field by field
144//!
145//! One verdict per field of [`Capabilities`], and what `Native` means when this source
146//! says it. *Proven* means a shared journey drives it against the real
147//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
148//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
149//! [`capabilities`](TaskSource::capabilities) from parting.
150//!
151//! | Field | Verdict |
152//! | --- | --- |
153//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
154//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
155//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
156//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
157//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
158//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
159//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
160//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
161//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
162//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
163//! | `filter_by_metadata` | **Supported, and asked of GitHub** — through the body and never through a projected `metadata_fields` text field, which is the board's to filter on and not this source's to read. A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
164//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
165//! | `reconcile_status` | **Supported,** and proven by `onetaskgraph-github-projects-e2e`'s `status_reconcile.rs`: the board's own `ProjectV2.items(query:)` filter, `is:closed`, read page by page and never the unfiltered items, each candidate confirmed from its own item node — a closed task whose card is not at the option the mapping gives its closure's category. |
166//! | `complete_by_issue` | **Supported,** and proven by `status_complete.rs` there and in the engine's suite: each issue a reference names read in one aliased request per fifty — its repository, its number, its first page of board memberships with each card's `Status` read by name — and nothing else of the board. |
167//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
168//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
169//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
170//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
171//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
172//!
173//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
174//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
175//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
176//! and the raw bytes as the request body. The numeric repository id is read at most once
177//! per repository per source instance. A loopback API endpoint also selects the loopback
178//! uploads host; production needs no assets repository, commits or extra configuration.
179//! Every returned JSON `url` is read with the same token before a body is written: only
180//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
181//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
182//! asset and HTTP status and says the token type may not be accepted by the upload
183//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
184//! fine-grained PATs or OAuth tokens.
185//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
186//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
187//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
188//! and verified afresh, in the repository the issue already lives in on an update.
189//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
190//! Uploads take the same mutation spacing as content writes. Both uploads and verification
191//! reads wait out classified rate limits within the configured per-call retry budget,
192//! on the supplied clock; verifying reads take no mutation slot.
193//!
194//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
195//! source has documents and that its tasks have comments, both of which hold — and the three
196//! facts behind the uniform `Native` on the
197//! predicates beside it are recorded below rather than re-derived, because a reader who
198//! takes `Native` to mean *the remote service filters* will read that uniformity as a
199//! lie.
200//!
201//! First, the plugin contract defines `Support::Native` as *the source applies this
202//! predicate itself*, and says nothing about where it applies it. What the declaration
203//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
204//! — so that the engine may push it down and apply nothing of its own.
205//!
206//! Second, this source can keep that promise for every predicate at no additional API
207//! cost, because whichever of the reads below answers a query has already read every
208//! candidate that query will return before it filters anything. Filtering those items is
209//! in-process work over data already in hand.
210//!
211//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
212//! applied in process over what that question returned. A project filter has a relationship — a
213//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
214//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
215//! a search for metadata values, is the board-scoped issue search carrying the text and each
216//! value as quoted phrases; an origin is the board's own field filter over its origin field
217//! beside the same search for the id. **The text search narrows, and that is this source's
218//! declared semantics:** GitHub matches whole words where the substring rule this source and
219//! the local Markdown source confirm with would match inside one, so an item holding the text
220//! only inside a longer word is never a candidate. Every item returned does contain the text.
221//! A project query's text, and a document query's scoped to no project, is that same search
222//! and narrows on the same terms, its candidates confirmed by their kind as well.
223//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
224//! so those three are applied in process over the candidates, and a query carrying none of
225//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
226//! engine compensate for work this source has already done, and declaring `projects` native
227//! while ignoring the filter (which this source once did) silently returns another project's
228//! tasks, because the engine trusts the declaration and applies nothing locally.
229//!
230//! # The three ways this source reaches an item, and what each costs
231//!
232//! A board read is charged for what its *nested* connections could return rather than for
233//! what was asked, so one whole-board read costs the same whether the question was about
234//! one project or about all of them. That is why a question about one project is never
235//! answered by reading the board:
236//!
237//! | The question | What is sent | What it costs |
238//! | --- | --- | --- |
239//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)`, carrying the field definitions of the boards it sits on and the far ends of its `blockedBy`, which is what a write of it needs — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
240//! | one task with its first page of comments, for `task show` and a comment listing | [`graphql::ISSUE_DETAIL`] — the same `node(id:)` read with the issue's `comments` | the item and a page of its comments |
241//! | one board page with each task's first comments, for `TaskSource::query_task_details` | [`graphql::BOARD_WITH_COMMENTS`] — the board's own `items`, without search completion | tasks and first comment pages in one request; board consistency, with comment-activity filtering refused |
242//! | several tasks with their comments, for `task show-many` | [`graphql::ISSUE_DETAILS`] — [`DETAIL_BATCH`] aliased `node(id:)` fields per request | each item and a page of its comments |
243//! | the board's own id and field definitions, for a write whose item does not carry them | [`graphql::BOARD_FIELDS`] — the board's `id` and `fields`, and no `items` — or, for a create that needs the repository's id too, [`graphql::CREATION_CONTEXT`], both in one request | the board's fields |
244//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
245//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
246//! | which projects hold a text, or which documents do when no project narrows the question | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text as one quoted phrase, `in:title`, `in:body` or both, as a task's text is sent — walked to its end in pages of twenty | the issues that match |
247//! | which tasks were commented on since an instant | [`graphql::SEARCH_ISSUES`] — the same board-scoped search with an `updated:>=` qualifier — then [`graphql::ISSUE_COMMENTS`] for each candidate it names | the issues updated since, and their comments |
248//! | which tasks hold a text, or a metadata value | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text and each value as quoted phrases, `in:title`, `in:body` or both, and an `updated:>=` qualifier too when comment activity is asked for — in pages of twenty, only as many as the caller's rows need | the issues that match |
249//! | which tasks were copied from one origin | [`graphql::ORIGIN_LOOKUP`] — the board's own `items` under its field filter on the origin field, and the same board-scoped search for the id `in:body`, in one request, each paged at three | the carriers of that origin, which is one item |
250//! | every task, every document, every label, when nothing above narrows the question | [`graphql::BOARD`] — the board's own `items` — **and** [`graphql::SEARCH_ISSUES`], because neither enumeration of a board is complete alone; see [`GitHubProjectsSource::board`] | the board, twice over |
251//! | which board item one issue is, past the page that came with it | [`graphql::ISSUE_BOARD_ITEMS`] — that issue's own `projectItems` | one issue's memberships |
252//!
253//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
254//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
255//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
256//! equal to declared points equal to the row. They include the origin lookup and the
257//! field/repository discovery a create needs. A bound re-copy changes status, priority,
258//! content and metadata; comment recount means a subsequent detail read. Each request here
259//! costs one declared point. A membership beyond the embedded page can additionally require
260//! the one-point membership recovery described above. A bound re-copy of a task filed under a
261//! project adds one read, the engine confirming that project's link by its own id once per
262//! command; and the same-source far ends a write newly names — those that do not already block
263//! the item, whose own read answered for them — are read together by their own ids,
264//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
265//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
266//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
267//!
268//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
269//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
270//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
271//! holds both halves.
272//!
273//! **An existing item is written body last.** A bound re-copy and a `task update` send its
274//! board fields first — the `Status` option and the `Priority` together, in one request — then
275//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
276//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
277//! undoing an earlier field when a later one fails, so that order is what makes a write
278//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
279//! the one piece of metadata written before the body, an origin a copy re-points, is put back
280//! when a later write is refused — and when putting it back is refused too, the write's own
281//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
282//! of those writes in turn, whole and as one aliased field failing after the one before it.
283//!
284//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
285//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
286//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
287//!
288//! - **A board is accepted at creation but its item is not answered, so a create still files
289//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
290//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
291//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
292//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
293//!   against a real board on 2026-10-01 with a create sending the board there and reading the
294//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
295//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
296//!   refused "Content already exists in this project" — GitHub had filed the issue after
297//!   answering, and refuses a second filing rather than answering with the item it holds. A
298//!   create therefore sends no `projectV2Ids` and files the issue with
299//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
300//!   real is the read before it: the board's fields and the repository's id together, in
301//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
302//! - **A comment still reads its target first, so a comment is 2 requests.**
303//!   `AddCommentInput.subjectId: ID!` is declared there with
304//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
305//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
306//!   project's issue, a document's issue, an issue on no board of this source and a pull
307//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
308//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
309//!   refuses those by name.
310//!
311//! | Verb | Requests / points | Documents |
312//! | --- | --- | --- |
313//! | new copy | 4 | ORIGIN_LOOKUPS (the origin, the board's fields and the repository's node and numeric ids together, a page of one carrier for each origin), CREATE_ISSUE filing the issue on the board and under its project, CREATED_BOARD_ITEMS once GitHub has added it, WRITE_FIELD_VALUES |
314//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, CREATED_BOARD_ITEMS, WRITE_FIELD_VALUES: the new copy with no origin to ask about |
315//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS when a board field differs, then UPDATE_ISSUE last carrying only the title, body or state that differs — none, when nothing does |
316//! | bound copy, filed under a project | 4 | the bound copy's three, and one ISSUE of the destination project its link names, read once per command |
317//! | bound copy, newly naming n dependencies | + ceil(n / DETAIL_BATCH) + n | ISSUE_DETAILS for the far ends that do not already block the item, DETAIL_BATCH (24) to a request (one alone is ISSUE), then one ADD_BLOCKED_BY each; a far end already blocking it is answered by its own read and costs nothing |
318//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
319//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
320//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
321//! | copy --comments, n changed tasks | ceil(n / COPY_DETAIL_BATCH) | ISSUE_COPY_DETAILS: each task with its first page of comments and its `blockedBy`, COPY_DETAIL_BATCH (21) at a time; a task whose comments run past that page adds one ISSUE_DETAIL per further page, and a task skipped from its listing costs nothing beyond it |
322//! | recount | 1 | ISSUE_DETAIL |
323//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
324//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
325//! | content | 2 | ISSUE, UPDATE_ISSUE |
326//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
327//! | document metadata, `--expect` a version | 2 | ISSUE, then UPDATE_ISSUE only when the issue is still at that version — the precondition GitHub's own update cannot take — primed with what the read answered |
328//! | update | 3 | `task update` naming any of title, body, metadata, status and priority — all five included: ISSUE, UPDATE_FIELDS (the status option and the priority together), UPDATE_ISSUE (title, body with its metadata slot, and state) last |
329//! | record only | 1 | ISSUE |
330//!
331//! <!-- github-search-paging:start -->
332//! Board-scoped text, metadata, project-name and comment-activity searches send every
333//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
334//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
335//! needs rows. A page is never resized to the rows still needed: GitHub orders one
336//! search differently at different page sizes, so one fixed size makes a paged walk
337//! send exactly the requests one whole read sends, and the answer's order is the order
338//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
339//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
340//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
341//! returned and fetched pages: a limit is sliced from the pages it needs, and local
342//! confirmation can require more candidates than matching rows. Walking all pages
343//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
344//! cursor and how far into that page the last answer stopped, and resumes in the same
345//! process or a new one, without duplicates or gaps. It carries no rows: one process
346//! sends each page's search once, and a new process re-reads only the page it resumes
347//! in, then sends a further page once, never as a re-read, only when its limit still
348//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
349//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
350//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
351//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
352//! not required to include the original process's writes still omitted by the index.
353//! <!-- github-search-paging:end -->
354//!
355//! The board half of an issue — its board item's id, its `Status` option and this
356//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
357//! an item reached any of those ways resolves through the same
358//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
359//! same status, the same labels and the same qualified id. That connection comes back a
360//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
361//! on the page in hand and — only if that page reports more of the connection — in the
362//! last row's read of that one issue's memberships, resumed from the page's own cursor and
363//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
364//! report, which is what keeps an id naming another repository's issue from being answered
365//! as an item of this board; and because the page is where the search starts rather than
366//! where it ends, that answer is one about a connection read to exhaustion and never about
367//! an unread page. Nothing costs the extra read but an issue on more boards than a page
368//! holds: an issue this board really does not hold reports no next page, so its
369//! memberships are already exhausted where they arrived.
370//!
371//! **No document here selects the board's own `Labels` field, and nothing is lost by
372//! that.** An item's labels are read from its content alone, wherever that content is
373//! reached: the three documents above select `Issue.labels` on the fragment, and
374//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
375//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
376//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
377//! create one, and `ProjectV2FieldValue` — the whole of what
378//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
379//! it from the content, for every content type it exists on, and there is nothing it can
380//! hold that the content does not already say: for an `Issue` it *is* that issue's own
381//! labels, so selecting it beside them unions a set with itself.
382//!
383//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
384//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
385//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
386//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
387//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
388//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
389//! label set, and that set is the fixture issue's own, by
390//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
391//! item whose content is a draft reports an empty set, by
392//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
393//! selection is held over [`graphql::DOCUMENTS`] by
394//! `no_document_selects_the_boards_own_labels_field`.
395//!
396//! The whole-board row is still the board's own item connection, and deliberately: a
397//! **draft** board item is not an issue, so no search can list one, and the reads that have
398//! to answer for the whole board are the ones whose cost is the board's size anyway.
399//!
400//! **A question about one item this source already names by id never lists the board.**
401//! Whether that item is on this board, and what its board fields are, is answered by reading
402//! that item — its own `Issue.projectItems`, walked to exhaustion by
403//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
404//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
405//! holds. That covers a write's destination, the project a new item is filed under, a
406//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
407//! and the delete that takes back an item a copy made. What such a write needs of the board
408//! and the item does not carry — the board's id, the `Status` and origin field definitions —
409//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
410//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
411//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
412//! minutes. Scanning this host's 842-item board has refused a document copy and an update
413//! even though the items' own reads named that board. A scan there gives the wrong answer
414//! as well as paying for every page. So a `board.items` lookup does not belong on any of
415//! those paths.
416//!
417//! **What a read may return is capped too, and that cap is on the document rather than on
418//! the board.** GitHub limits the number of nodes **one query may return** to
419//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
420//! an error naming the connection the count crossed at, not a slow or a partial result.
421//! Every board this source reads is refused the same way, so no board is too big for these
422//! documents and none is small enough to save one that is over.
423//!
424//! The count is arithmetic over the document's own text: each connection contributes the
425//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
426//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
427//! restate them — `github-graphql-node-count` implements them, and
428//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
429//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
430//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
431//! same text on every run and fails naming any that reaches the limit — so a connection
432//! added to a shared fragment is caught there rather than by GitHub.
433//!
434//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
435//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
436//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
437//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
438//! effectively squared there, which is why it is the one the limit is most sensitive to.
439//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
440//! page of memberships misses is recovered by one further read rather than refused, so it
441//! buys a bound every read pays for at the price of a request only a multi-board issue
442//! pays.
443//!
444//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
445//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
446//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
447//! `cost` is rate-limit points, metered per hour across everything one credential does; it
448//! is what the two limiters [`Limiter`] tells apart meter, and a document under
449//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
450//! that second number, and `tests/point_cost.rs` pins every document in
451//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
452//! one under, the pin itself is the check. The credentialed lane reconciles both figures
453//! against GitHub's own, off a probe it already sends.
454//!
455//! **What is pinned that way is a per-document price and never a session's.** The record in
456//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
457//! **requests** and **worst-case nodes** — and neither is points. What one whole session
458//! consumes of the hourly point allowance is observable only from a credentialed run's own
459//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
460//! and what `tests/live.rs` prints at the end of every run.
461//!
462//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
463//!
464//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
465//! enumerations of a board can supply one alone.** Resolving a node id is strongly
466//! consistent, so a read by id and a project's own sub-issues are already current. The
467//! other two are not, and they are behind by different amounts and in different directions:
468//!
469//! - GitHub's **issue search** is an index and answers a write made moments ago with the
470//!   value from before it — usually for a second or two.
471//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
472//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
473//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
474//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
475//!
476//! That second one is a measurement rather than a caution. This repository's own
477//! credentialed journey writes a project and waits for the board to report it, then writes a
478//! task and waits for the same thing seconds later on the same board: the project wait is
479//! answered through the search and converged in two or three attempts in each of three runs,
480//! and the task wait is answered through `ProjectV2.items` and converged in none of them
481//! inside thirty. Separately, an item added to a second and larger board was read back by
482//! `Issue.projectItems` on that board's own id while every one of that connection's nine
483//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
484//! through the lagging one alone is what had a board read deny an issue that had certainly
485//! landed on it.
486//!
487//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
488//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
489//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
490//! search reports what the projection is behind on. What closes the last
491//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
492//! source answers is completed with what this process itself wrote, so an item created
493//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
494//! nothing is written down, and the record dies with the process. **A wait that has to
495//! observe GitHub's own data cannot be answered from that record** — which is why the
496//! credentialed journey asks through a source built afresh, and why the union above rather
497//! than a longer wait is what makes such a wait converge.
498//!
499//! **A narrowed read is the same bargain, stated for each of the three predicates it
500//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
501//! than walking the board, and every such answer is completed with what this process wrote —
502//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
503//! each filtered by the same predicates as the rest — so an item this command wrote a moment
504//! ago is returned by a query that matches it whether or not the index has caught up. An item
505//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
506//! consistent. What is left is stated rather than papered over:
507//!
508//! | Read | Finds | Behind by |
509//! | --- | --- | --- |
510//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
511//! | origin, first read | the board's field filter over the origin field — every carrier, whichever release wrote it | what `ProjectV2.items` is behind on, which the measurements above put in minutes |
512//! | origin, second read | the issue search for the id in the body, where a write of this release mirrors it | a second or two, as any search |
513//! | origin, third read | this process's own writes | nothing |
514//!
515//! So an origin carrier another process added within the last second or two, before either
516//! index has it, can be missing from an origin query, and one written by the release before
517//! this one — its origin in the field alone — can be missing for as long as the board's own
518//! item connection is behind on it. A copy that must not duplicate its own earlier write
519//! relies on the link it records, not on either index. **A board draft is not an issue**, so
520//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
521//! lists one, the origin lookup drops any the board's own field filter names, and one this
522//! process wrote is not added back either.
523//!
524//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
525//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
526//! body's metadata slot, so the issue search can find it in seconds. The field is
527//! authoritative: this source reads an item's origin from the field alone, so a slot that
528//! disagrees with it, or holds one where the field holds none, is never read as a second
529//! origin — and the release before this one reads the slot, drops that key's copy for the
530//! field's, and sees the same one origin.
531//!
532//! Filtering happens before paging, so a page of a filtered result is a page of the
533//! survivors rather than the survivors of a page. Label matching and the substring rule a
534//! text candidate is confirmed by answer the same question the same way the local Markdown
535//! source's do; which candidates a text search has to confirm is GitHub's word match, which
536//! is the one place the two sources can answer the same text differently.
537//!
538//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
539//! has one source, `capabilities`, and the note above is the reasoning behind it rather
540//! than a second copy of it: without the three facts recorded here a reader takes the
541//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
542//! crate's own capabilities test, which pins every field of it against a fully spelled-out
543//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
544//! fails to compile there rather than going unasserted. -->
545//! The fixture-server tests above run wherever this crate is selected; the credentialed
546//! lane runs in the same required check, beside them, and can fail it — it verifies the
547//! current schema, then drives every field of the table above against the real board. It builds its own fixture there — two projects, one task filed under each,
548//! one filed under neither, a label on one of the three and a closed status on another —
549//! because that shape is what tells an honoured predicate from an ignored one: a board
550//! holding a single project answers a project filter the same way whether or not this
551//! source applies it, which is exactly how the defect above went unseen.
552//!
553//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
554//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
555//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
556//! session or request, independently of the live demand. Fix that variable in the machine's
557//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
558//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
559//! policy crate; absent credentials or nominations otherwise skip.
560//!
561//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
562//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
563//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
564//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
565//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
566//! after its run reads back as `completed`, immediately before the listed-artifact batch.
567//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
568//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
569//! and the board's `onetaskgraph.origin` field untouched.
570//!
571//! # What a session of requests costs, and where the report is
572//!
573//! This source records **every** request it sends into [`accounting::Accounting`], at
574//! `send_once` — the one place a request leaves this crate, which is why a read path added
575//! later is counted without anybody remembering to count it. That is the whole of what this
576//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
577//! session's spend is arrived at, and what it deliberately does not know are set out.
578//!
579//! What one whole session of the live journey costs, counted that way against this crate's
580//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
581//! reduction it came out of, and with what it does and does not say about rate-limit points.
582//!
583//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
584//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
585//! code path — no environment variable, no feature, no build configuration — because an
586//! instrument nobody switches on measures nothing, and
587//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
588//! source's counts the whole session rather than this source's share. The credentialed lane
589//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
590//! passed or failed.
591//!
592//! **A live session refuses to start unless the account can afford it.** Before it does any
593//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
594//! GitHub documents as not counting against the REST rate limit and which answers both of
595//! its budgets at once — and starts only if, for each of them, what remains minus this
596//! session's estimated cost is still at least
597//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
598//! allowance. A session that cannot **declines**: it did not run, so it is
599//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
600//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
601//! waiting for the budget to come back. The estimate is derived offline from
602//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
603//! which is also where the published rule that model rests on is cited; the accounting
604//! above records the gate's own read like any other request, and
605//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
606//!
607//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
608//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
609//! text, which is what lets it run on every platform and on a pull request from a fork with
610//! no credential — and that is what actually stops a regression merging. But an offline
611//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
612//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
613//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
614//! number of nodes this query may return"* and whose `cost` is what that document would
615//! spend, both for a document **without executing it**, and the lane asks it for every query
616//! document this source sends, under the largest bindings this source sends, and fails when
617//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
618//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
619//! one; the offline pins still cover it. It records what those calls reported about the
620//! account's own allowance, because whether asking is free is a thing to observe rather than
621//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
622//! and `cost` is metered against an hourly allowance the accounting above reads off a
623//! credentialed run's own response headers.
624//!
625//! **GitHub has two rate limiters and this source is refused by both, so nothing here
626//! treats them as one thing.** The primary budget is the hourly allowance `gh api
627//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
628//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
629//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
630//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
631//! one part of.
632#![deny(missing_docs)]
633
634use std::collections::BTreeMap;
635use std::sync::{Arc, Mutex};
636use std::time::Duration;
637
638use chrono::{DateTime, Utc};
639use onetaskgraph_plugin_api::{
640    Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
641    ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
642    DependencySupport, Designator, Direction, Document, DocumentQuery, DocumentVersionCoverage,
643    Health, HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
644    MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
645    ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
646    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
647    StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
648    TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
649    WaitCause, WriteJournal, WriteSupport, system_clock,
650};
651use reqwest::{Client, StatusCode, Url};
652use schemars::{Schema, schema_for};
653use secrecy::{ExposeSecret, SecretString};
654use serde::{Deserialize, Serialize};
655use serde_json::{Value, json};
656
657pub mod accounting;
658mod assets;
659mod batch;
660pub mod change_token;
661mod expected;
662pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
663mod pacing;
664mod projection;
665mod visibility;
666
667use accounting::Accounting;
668
669/// The registry name for this plugin.
670pub const KIND: &str = "github-projects";
671/// GitHub's maximum connection page size.
672pub const MAX_PAGE_SIZE: u32 = 100;
673/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
674/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
675/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
676pub const SEARCH_PAGE_SIZE: u32 = 20;
677/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
678/// its comments: the largest batch the node-count model prices at one point.
679///
680/// Each aliased item is resolved once, and what GitHub charges for it is the connections
681/// under it — its labels, its page of board memberships, the field values of each of those
682/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
683/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
684/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
685/// one point and fails if one item more would still be priced at one.
686pub const DETAIL_BATCH: usize = 24;
687/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
688///
689/// Each reference asks GitHub for one connection — a page of its board memberships, each with
690/// its `Status` value read by name rather than through a connection of field values — so fifty
691/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
692/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
693/// issues per source. `tests/point_cost.rs` prices the document at one point.
694pub const REFERENCE_BATCH: usize = 50;
695/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
696/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
697/// point.
698///
699/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
700/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
701/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
702/// if one item more would still be priced at one.
703pub const COPY_DETAIL_BATCH: usize = 21;
704/// How many mutations one aliased request of a copy's creates, closes and updates carries:
705/// [`graphql::CREATE_ISSUES`], [`graphql::CLOSE_ISSUES`] and [`graphql::UPDATE_ISSUES`].
706///
707/// Eleven is what GitHub was seen to accept in one request — the timing spike
708/// (`board-copy-speed-2026-10-18-spike-copy-timing-report`) sent eleven aliased mutations of four
709/// kinds, three `createIssue`, three `addProjectV2ItemById`, three field updates, an
710/// `addSubIssue` and an `addBlockedBy`, and every one ran — and no more was established.
711pub const MUTATION_BATCH: usize = 11;
712/// How many aliased writes one request of a copy's board field values and blocked-by edges
713/// carries: [`graphql::WRITE_FIELD_VALUES`], [`graphql::ADD_BLOCKED_BYS`] and, for a request
714/// holding both kinds, [`graphql::WRITE_FIELDS_AND_EDGES`].
715///
716/// Nineteen is the most aliases the feasibility spike
717/// (`board-copy-gaps-2026-10-24-spike-feasibility-report`) sent in one mutation request and
718/// saw GitHub price at the one point every mutation request costs; no larger request was
719/// established, so a copy holding more is split.
720pub const WRITE_BATCH: usize = 19;
721/// How many issues one [`graphql::DELETE_ISSUES`] request deletes: one request of eleven
722/// aliased `deleteIssue` met `RESOURCE_LIMITS_EXCEEDED` from the eighth alias on, and batches
723/// of five succeeded (the feasibility spike's undo-batching run).
724pub const DELETE_BATCH: usize = 5;
725/// How many origins one [`graphql::ORIGIN_LOOKUPS`] request asks about: the nine records of a
726/// plan and the project's document, and no more, since each carries a page of the board's field
727/// filter and one of the issue search.
728pub const ORIGIN_BATCH: usize = 10;
729/// How many searches one [`graphql::SEARCH_BATCH`] request asks: what `task find` asks of a
730/// board in one call, each search an aliased `search` field of its own.
731pub const SEARCH_BATCH: usize = 20;
732/// How many origins one [`graphql::SEARCH_BATCH`] request looks up beside its searches, each by
733/// the board's own field filter in an aliased `items` field of its own and by one of the
734/// request's searches.
735pub const SEARCH_BATCH_ORIGINS: usize = 5;
736/// The rows each search of a [`graphql::SEARCH_BATCH`] request asks for on its first page.
737///
738/// A batched find asks narrowed questions that each expect a handful of answers, and every
739/// search slot is priced at its page size: three rows a slot keep a first round of
740/// [`SEARCH_BATCH`] searches at three points, where a page of [`SEARCH_PAGE_SIZE`] each would
741/// cost twenty. A search with more to answer is walked on at [`SEARCH_PAGE_SIZE`] rows a page.
742pub const SEARCH_BATCH_FIRST_PAGE: u32 = 3;
743
744/// The rows each resumed page of one [`graphql::SEARCH_BATCH`] round asks for, when `resumed`
745/// of its slots — searches and origin item listings alike — resume from a cursor.
746///
747/// Every resumed page of a round shares one [`SEARCH_PAGE_SIZE`] of rows, rather than each
748/// asking that many: a round in which all twenty searches walked on at twenty rows a slot cost
749/// 22 points in one request, while sharing holds every round to what its first pages cost and
750/// one page more. A search with more to answer is walked on over more rounds, each page still
751/// read, so what a walk answers is unchanged; a round of one resumed search asks the page a
752/// search alone asks.
753#[must_use]
754pub fn search_batch_resumed_page(resumed: usize) -> u32 {
755    let resumed = u32::try_from(resumed.max(1)).unwrap_or(u32::MAX);
756    (SEARCH_PAGE_SIZE / resumed).max(1)
757}
758
759/// The page sizes of the most expensive [`graphql::SEARCH_BATCH`] round this source sends:
760/// every slot on its first page, at [`SEARCH_BATCH_FIRST_PAGE`] rows, but one resumed slot
761/// asking all of [`search_batch_resumed_page`]'s shared rows.
762///
763/// `tests/point_cost.rs` holds every round shape [`search_batch_resumed_page`] allows to cost
764/// no more than this, and the credentialed lane reconciles GitHub's own price of the document
765/// under it. [`largest_page_sizes`] still binds every slot at [`SEARCH_PAGE_SIZE`], an upper
766/// bound on this, so the offline worst case covers more than any round sends.
767#[must_use]
768pub fn search_batch_largest_round() -> Variables {
769    let mut sizes = largest_page_sizes();
770    for slot in 0..SEARCH_BATCH {
771        sizes.insert(format!("first{slot}"), SEARCH_BATCH_FIRST_PAGE);
772    }
773    for slot in 0..SEARCH_BATCH_ORIGINS {
774        sizes.insert(format!("itemsFirst{slot}"), SEARCH_BATCH_FIRST_PAGE);
775    }
776    sizes.insert("first0".to_owned(), search_batch_resumed_page(1));
777    sizes
778}
779/// How many comments one [`graphql::ADD_COMMENTS`] request adds, and how many issue updates one
780/// [`graphql::WRITE_TASKS`] request carries: [`MUTATION_BATCH`], the most aliased mutations
781/// GitHub was seen to accept in one request.
782pub const COMMENT_BATCH: usize = MUTATION_BATCH;
783/// How many board field values one [`graphql::WRITE_TASKS`] request carries, beside
784/// [`MUTATION_BATCH`] field clears: [`WRITE_BATCH`].
785pub const TASK_FIELD_BATCH: usize = WRITE_BATCH;
786
787/// The most nodes any one document this source sends may be asked to return.
788///
789/// GitHub's own published per-query ceiling, taken from
790/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
791/// workspace cannot hold a stale copy of somebody else's number. A query above it is
792/// **refused before it is executed**, whoever is asking and whatever board they are
793/// asking about — so this is a bound on the documents rather than a budget that runs out.
794///
795/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
796/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
797/// everything the credential does — two numbers against two limits, and this constant
798/// bounds only the first. The second is computed offline too, per document:
799/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
800/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
801/// lane. There is no constant like this one to hold a price under, because points are an
802/// hourly allowance rather than a per-call bound.
803///
804/// Neither is a session's price. What `session-cost.md` records of a whole session is its
805/// **requests** and its **worst-case nodes**; what a whole session spends in points is
806/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
807/// [`accounting`]. The module section on the three ways this source reaches an item says how
808/// the count is arrived at, and which of the page sizes below decide it.
809pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
810
811/// Nested connection size for the connections that hang off one item.
812///
813/// It multiplies through every document that reaches an item under a page — the count
814/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
815/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
816/// every document under these constants and fails naming any that reaches the limit, so
817/// raising this is caught there rather than by GitHub.
818const NESTED_PAGE_SIZE: u32 = 50;
819/// How many of one issue's board memberships are read when an issue is reached directly.
820///
821/// An issue reached through a search or through its own node id carries its board half in
822/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
823/// under a page of issues, so every point of it multiplies through the whole document and
824/// is paid for whether or not any issue is on a second board — which is why it is
825/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
826///
827/// **Three, because what a page misses is now recovered rather than refused**, and the
828/// recovery is what the value is chosen against. An issue whose entry for this board sits
829/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
830/// that page's own cursor — so the value trades a bound every read pays for a request only
831/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
832/// boards would pay that request *per issue*, which is order N against the one page per
833/// hundred issues a read costs today. At three it is only reached by an issue on four or
834/// more boards at once, which keeps the recovery path exceptional rather than routine for
835/// a plausible deployment.
836const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
837/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
838/// its two connections for.
839///
840/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
841/// second is a duplicate a copy already takes the first of. Both connections are walked to
842/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
843/// never what the answer is. It is small because every point of it is paid on every lookup,
844/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
845/// worst-case nodes than the one whole-board read they replaced.
846const ORIGIN_PAGE_SIZE: u32 = 3;
847/// How many carriers of each origin the batched [`graphql::ORIGIN_LOOKUPS`] asks each of its two
848/// connections for: one, the one carrier an origin names.
849///
850/// A second carrier still shows: the page then says there is more, and that origin is left to
851/// its own [`graphql::ORIGIN_LOOKUP`], which walks every page at `ORIGIN_PAGE_SIZE` — so a
852/// duplicate is found exactly as before, by the lookup that reads all of them. The feasibility
853/// spike (`board-copy-gaps-2026-10-24-spike-feasibility-report`) priced the batch of ten
854/// origins at two points with a first page of three and at one with a first page of one.
855const ORIGIN_LOOKUPS_PAGE_SIZE: u32 = 1;
856
857/// How many of one project's sub-issues one [`graphql::SUB_ISSUES`] page asks for: the most
858/// one point buys, since every row carries six connections GitHub charges for once per row.
859/// `session-cost.md` derives it; `tests/point_cost.rs` prices the document at one point and
860/// fails if one row more would still be priced at one.
861pub const MEMBER_PAGE_SIZE: u32 = 23;
862
863pub use github_graphql_node_count::{NodeCountError, Variables};
864
865/// The largest value this source can bind to each page-size variable its documents name.
866///
867/// Every `first:` in [`graphql`] reads one of these seven, and each is capped at the
868/// constant above it wherever a caller's own limit could reach it — `$first` at
869/// [`MAX_PAGE_SIZE`], `$commentFirst` at [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
870/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`, `$lookupFirst` at
871/// `ORIGIN_LOOKUPS_PAGE_SIZE`, `$memberFirst` at [`MEMBER_PAGE_SIZE`]. So this is the worst case a caller can drive this source to,
872/// not one configuration of it, which is what makes a bound computed under it a bound on
873/// every read.
874pub fn largest_page_sizes() -> Variables {
875    let mut sizes = Variables::from([
876        ("first".to_owned(), MAX_PAGE_SIZE),
877        ("commentFirst".to_owned(), MAX_PAGE_SIZE),
878        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
879        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
880        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
881        ("lookupFirst".to_owned(), ORIGIN_LOOKUPS_PAGE_SIZE),
882        ("memberFirst".to_owned(), MEMBER_PAGE_SIZE),
883    ]);
884    // Each slot of the batched search pages at most what one search does.
885    for slot in 0..SEARCH_BATCH {
886        sizes.insert(format!("first{slot}"), SEARCH_PAGE_SIZE);
887    }
888    for slot in 0..SEARCH_BATCH_ORIGINS {
889        sizes.insert(format!("itemsFirst{slot}"), SEARCH_PAGE_SIZE);
890    }
891    sizes
892}
893
894/// The most nodes `document` could be asked to return, by GitHub's published rules.
895///
896/// Computed offline from the document's own text under [`largest_page_sizes`] — no
897/// network, no credential and no schema — by
898/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
899/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
900/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
901///
902/// # Errors
903///
904/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
905/// no single operation, or binds a page size this source does not name — each of which is
906/// a defect in the document rather than a number.
907pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
908    node_count(document, &largest_page_sizes())
909}
910
911/// The most rate-limit points one call of `document` could spend, by GitHub's published
912/// rules.
913///
914/// Computed offline from the document's own text under [`largest_page_sizes`] — no
915/// network, no credential and no schema — by
916/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
917/// This is `cost`, metered **per hour** against the allowance one credential shares across
918/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
919/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
920/// under, so what `tests/point_cost.rs` does with it is pin every document in
921/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
922/// figures against GitHub's own reported `cost`.
923///
924/// # Errors
925///
926/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
927/// no single operation, or binds a page size this source does not name — each of which is
928/// a defect in the document rather than a number.
929pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
930    github_graphql_node_count::point_cost(document, &largest_page_sizes())
931}
932
933/// The rate-limit points one call of `document` spends under `variables`, by GitHub's published
934/// rules: [`worst_case_point_cost`] is this under [`largest_page_sizes`], and a request this
935/// source sent is priced by it under the page sizes that request really bound — what GitHub
936/// charges the request, since it charges every connection at the page size asked for.
937///
938/// # Errors
939///
940/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
941/// no single operation, or binds a page size `variables` does not name.
942pub fn point_cost(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
943    github_graphql_node_count::point_cost(document, variables)
944}
945
946/// The most nodes `document` could be asked to return under `variables`.
947///
948/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
949/// [`accounting`] is this under the bindings one request really sent — one spelling of the
950/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
951/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
952///
953/// # Errors
954///
955/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
956/// no single operation, or binds a page size `variables` does not name.
957pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
958    github_graphql_node_count::node_count(document, variables)
959}
960
961/// `variables` with the page sizes and flags a token twin's inline issue selection binds,
962/// at a read's page sizes.
963fn token_variables(mut variables: Value) -> Value {
964    if let Some(object) = variables.as_object_mut() {
965        object.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
966        object.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
967        object.insert("duplicates".to_owned(), json!(true));
968    }
969    variables
970}
971
972/// The input members a mutation names the issue or board item it writes by.
973const WRITTEN_NAMES: [&str; 4] = ["id", "issueId", "subjectId", "itemId"];
974
975tokio::task_local! {
976    /// The source a targeted update is running on, by its address, while it runs: what has its
977    /// mutations sent as the twins selecting the whole issue, so the update answers the version
978    /// its own last response shows (see `GitHubProjectsSource::written_tokens`).
979    static SPELLING: usize;
980}
981
982/// The issue-title prefix that makes a board issue a document.
983///
984/// A GitHub Projects board has no document type — it holds issues — so the discriminator
985/// is the title, and this is the whole of it: an issue whose title begins with these bytes
986/// is a document and every other issue is the task or project the sub-issue rule makes it.
987///
988/// It is spelled **once**, here, and read rather than restated everywhere else — including
989/// by the shared journeys, which take it from this constant so a board fixture cannot
990/// drift from what this source reads. `docs/metadata.md` records the two consequences that
991/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
992/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
993/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
994pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
995
996/// The most characters GitHub stores in one issue's body: a longer one is refused as
997/// `body is too long (maximum is 65536 characters)`.
998pub const BODY_LIMIT: u64 = 65_536;
999
1000/// The address a new upload's URL is spelled at, as GitHub serves a user attachment: what the
1001/// size of a body is measured with before an asset of it is uploaded, a UUID long.
1002const PREDICTED_ATTACHMENT_URL: &str =
1003    "https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000";
1004
1005/// Exact GraphQL query documents issued by this plugin.
1006///
1007/// Keeping the production documents here lets the pinned-schema test validate the same
1008/// bytes that are sent to GitHub, rather than a test-only copy which could drift
1009/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
1010/// field, and its guarded caller always supplies the complete existing option set with ids.
1011pub mod graphql {
1012    /// The board half of one item: the field values every document here reads it from.
1013    ///
1014    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
1015    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
1016    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
1017    /// *the same value*, because
1018    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
1019    /// one path. Three spellings of it is what would drift, so there is one.
1020    ///
1021    /// The `Status` option and this source's own origin text field are the whole of it. It
1022    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
1023    /// content, so it holds nothing the content's own `labels` do not already say, and it
1024    /// would sit a label connection two page sizes deep.
1025    macro_rules! board_item_values {
1026        () => {
1027            r#"fieldValues(first:$nestedFirst){nodes{
1028          ... on ProjectV2ItemFieldSingleSelectValue{name field{
1029            ... on ProjectV2SingleSelectField{id name options{id name}}
1030          }}
1031          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
1032        }pageInfo{hasNextPage}}"#
1033        };
1034    }
1035
1036    /// The variable every document reading an issue declares for the relationship events its
1037    /// change token counts, with its value as the default, so no caller binds it.
1038    ///
1039    /// A variable rather than a literal list because every argument a production document
1040    /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
1041    /// its default to the pinned enum. Its default is the six event types GitHub records a
1042    /// relationship added or removed with: a dependency on either end, as `blockedBy` and
1043    /// `blocking`, so one a person makes from the far end moves this end's count too; and a
1044    /// project membership on the task, as its parent, which GitHub records there whichever
1045    /// issue the move was made from. A sub-issue's events on its parent are not counted: a
1046    /// project carries no task token, and a copy writes nothing of a task its own sub-issues
1047    /// decide. [`super::change_token`] says why the count is in the token.
1048    macro_rules! relationship_events_variable {
1049        () => {
1050            ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
1051        };
1052    }
1053
1054    /// Everything this source reads about one issue, wherever it reaches that issue.
1055    ///
1056    /// A macro rather than a constant so the three documents below can `concat!` it: one
1057    /// spelling of these fields is what makes an issue read through the board-scoped
1058    /// search, through its own node id, and through its project's sub-issue relationship
1059    /// resolve to *the same* item, which is the whole of what
1060    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
1061    ///
1062    /// `projectItems` is what carries the board half of an issue: the board item's own id
1063    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
1064    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
1065    /// issue rather than on the board, which is what makes the cost of a read proportional
1066    /// to what was asked for instead of to the board's size.
1067    ///
1068    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
1069    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
1070    /// not on that page: a page here is where the search for the entry starts rather than
1071    /// where it ends.
1072    ///
1073    /// It does **not** select the board's `Labels` field value, and that is the whole of
1074    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
1075    /// a label connection there sits under `fieldValues` under `projectItems` under a page
1076    /// of issues, spending `$nestedFirst` twice down one path, and took
1077    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
1078    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
1079    /// above, and that connection is where every label this source reports comes from. No
1080    /// document in this module selects the board field any longer, [`BOARD`] included; the
1081    /// module documentation records why nothing it could have held is lost.
1082    macro_rules! board_issue {
1083        () => {
1084            concat!(
1085                r#" fragment BoardIssue on Issue{"#,
1086                board_issue_fields!(),
1087                "}"
1088            )
1089        };
1090    }
1091
1092    /// What [`board_issue!`] selects of an issue, written out where a document selects it
1093    /// inline rather than through the fragment.
1094    macro_rules! board_issue_fields {
1095        () => {
1096            concat!(
1097                r#"__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
1098      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
1099      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1100        "#,
1101                board_item_values!(),
1102                r#"}pageInfo{hasNextPage endCursor}}"#
1103            )
1104        };
1105    }
1106
1107    /// One page of a repository's issues, newest first, each with when it was created and its
1108    /// body: what a retried copy reads to find the issue its earlier attempt created, by the
1109    /// create key in that body's metadata slot — never through search, whose index lags.
1110    pub const ISSUES_BY_CREATION: &str = "query($owner:String!,$name:String!,$first:Int!,$after:String){repository(owner:$owner,name:$name){issues(first:$first,after:$after,orderBy:{field:CREATED_AT,direction:DESC}){nodes{id createdAt body}pageInfo{hasNextPage endCursor}}}}";
1111    /// Every issue of one board, found by a search scoped to that board.
1112    ///
1113    /// This is how the projects a board holds are listed, and it selects no `items`
1114    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
1115    /// container walked page by page, so nothing nested inside a board item is paid for.
1116    /// Which of the issues it returns is a project is then read off `parent` — GitHub
1117    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
1118    /// discriminator has to be applied to the field, which is a scalar on the issue and
1119    /// costs nothing.
1120    pub const SEARCH_ISSUES: &str = concat!(
1121        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1122        relationship_events_variable!(),
1123        r#"){
1124      search(query:$search,type:$type,first:$first,after:$after){
1125        pageInfo{hasNextPage endCursor}
1126        nodes{__typename ...BoardIssue}
1127      }
1128    }"#,
1129        board_issue!()
1130    );
1131
1132    /// What a dependency read selects of each far end: enough to say which kind of item it
1133    /// is, its body included for the kind marker.
1134    macro_rules! related_issue {
1135        () => {
1136            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
1137        };
1138    }
1139
1140    /// One issue by its own node id, which is what a qualified id names here — with what a
1141    /// write of it needs and the issue does not carry in `board_issue!`: the field
1142    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
1143    ///
1144    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
1145    /// answers a write made moments ago with the value from before it, and resolving a node
1146    /// id does not.
1147    ///
1148    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
1149    /// reads it by its own id, and with them that one read answers everything the write
1150    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
1151    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
1152    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
1153    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
1154    /// they sit under one item, and this read is still one point.
1155    pub const ISSUE: &str = concat!(
1156        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1157        relationship_events_variable!(),
1158        r#"){
1159      node(id:$id){__typename ...BoardIssue ... on Issue{
1160        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1161          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1162          ... on ProjectV2Field{__typename id name dataType}
1163        }pageInfo{hasNextPage}}}}}
1164        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1165      }}
1166    }"#,
1167        board_issue!(),
1168        related_issue!()
1169    );
1170
1171    /// One document by its own node id, as [`ISSUE`] reads one, with the project it is filed
1172    /// under and that project's members beside it: the project issue whole, and a page of its
1173    /// sub-issues, each as [`SUB_ISSUES`] carries one but with no page of what blocks it.
1174    ///
1175    /// What a guarded write of a document's metadata reads, and what reading a document by its
1176    /// id is: a write to a board the boundary screens is held to who may read the document,
1177    /// which is the project's and every member's classification as much as its own, and here
1178    /// the one request that holds the version the write is compared against answers all of it.
1179    /// Its members carry no edges, which nothing classifying them reads.
1180    pub const DOCUMENT_IN_PROJECT: &str = concat!(
1181        r#"query($id:ID!,$first:Int!,$memberFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1182        relationship_events_variable!(),
1183        r#"){
1184      node(id:$id){__typename ...BoardIssue ... on Issue{
1185        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1186          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1187          ... on ProjectV2Field{__typename id name dataType}
1188        }pageInfo{hasNextPage}}}}}
1189        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1190        project:parent{__typename ...BoardIssue
1191          subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}
1192        }
1193      }}
1194    }"#,
1195        board_issue!(),
1196        related_issue!()
1197    );
1198
1199    /// One project's tasks: the sub-issues of the issue that project is, each with a page of
1200    /// what blocks it.
1201    ///
1202    /// Paged at `$memberFirst` — [`MEMBER_PAGE_SIZE`](super::MEMBER_PAGE_SIZE), the most rows
1203    /// one point buys — rather than at `$first`, because every row carries six connections and
1204    /// a hundred-row page cost six points to list a project of ten.
1205    ///
1206    /// The work this costs is the project's own size. Nothing about it grows as the board
1207    /// gains projects, or as those projects gain tasks.
1208    ///
1209    /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1210    /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1211    /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1212    /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1213    /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1214    /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1215    /// It adds a connection under each issue of the page — one rate-limit point per page,
1216    /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1217    /// answer questions that never read an edge.
1218    ///
1219    /// **The project issue itself rides here too**, with the page of what blocks it, because
1220    /// the reader of a project's tasks reads the project next: a copy writing the project reads
1221    /// it and its edges before it writes them. Carried on the request that already names the
1222    /// project, the first page answers that read and the dependency read after it, at the
1223    /// state GitHub answered in this command, rather than spending one more read on an item
1224    /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1225    /// copy's read of a task this listing carried is answered by it rather than sent again.
1226    /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1227    pub const SUB_ISSUES: &str = concat!(
1228        r#"query($id:ID!,$memberFirst:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1229        relationship_events_variable!(),
1230        r#"){
1231      node(id:$id){__typename ...BoardIssue
1232        ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1233          subIssues(first:$memberFirst,after:$after){
1234          pageInfo{hasNextPage endCursor}
1235          nodes{__typename ...BoardIssue ... on Issue{
1236            blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1237          }}
1238        }}}
1239    }"#,
1240        board_issue!(),
1241        related_issue!()
1242    );
1243
1244    /// Everything this source reads about one issue comment, wherever it reaches one.
1245    ///
1246    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1247    /// and a comment just edited are handed to one mapper, so they are selected by one
1248    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1249    /// longer exists, and `login` is the one member every kind of actor carries; `__typename`
1250    /// says whether it is a `Bot`, whose `login` GitHub's own pages spell with `[bot]` after it.
1251    macro_rules! issue_comment {
1252        () => {
1253            "id author{__typename login} createdAt updatedAt body url"
1254        };
1255    }
1256
1257    /// What a read of the board's own `items` selects of each item's content.
1258    ///
1259    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1260    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1261    /// content by one spelling.
1262    macro_rules! board_item_content {
1263        () => { board_item_content!("") };
1264        ($comments:expr) => {
1265            concat!(r#" content{
1266        ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}"#, $comments, r#"}
1267        ... on PullRequest{__typename id}
1268        ... on DraftIssue{__typename id title body createdAt updatedAt}
1269      }"#)
1270        };
1271    }
1272
1273    macro_rules! board_listing {
1274        ($variables:expr, $comments:expr) => { concat!(
1275        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1276        relationship_events_variable!(), $variables,
1277        r#"){
1278      owner:repositoryOwner(login:$owner){
1279        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1280      }
1281    } fragment Board on ProjectV2 { id title
1282      fields(first:$nestedFirst){nodes{
1283        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1284        ... on ProjectV2Field{__typename id name dataType}
1285      }pageInfo{hasNextPage}}
1286      items(first:$first,after:$after){nodes{id updatedAt "#,
1287        board_item_values!(),
1288        board_item_content!($comments),
1289        r#"} pageInfo{hasNextPage endCursor}}
1290    }"#
1291    ) };
1292    }
1293
1294    /// Reads the board's fields and one page of its items.
1295    pub const BOARD: &str = board_listing!("", "");
1296
1297    /// A board page carrying each issue's first comment page in the same request.
1298    pub const BOARD_WITH_COMMENTS: &str = concat!(
1299        board_listing!(
1300            ",$commentFirst:Int!",
1301            concat!(
1302                "comments(first:$commentFirst){nodes{",
1303                issue_comment!(),
1304                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}"
1305            )
1306        ),
1307        related_issue!()
1308    );
1309
1310    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1311    /// the board.
1312    ///
1313    /// **`originItems`** is the board's own items narrowed by its own field filter —
1314    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1315    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1316    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1317    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1318    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1319    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1320    /// fresh `addProjectV2ItemById` the way that connection does.
1321    ///
1322    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1323    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1324    /// indexes that comment, and the index catches up with a write in a second or two rather
1325    /// than in minutes, so it finds a carrier another process wrote that the first read is
1326    /// still behind on.
1327    ///
1328    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1329    /// — and resumes from its own cursor; a connection already walked to its end is resumed
1330    /// from its last cursor, which answers an empty page. Every candidate either read returns
1331    /// is confirmed against its own origin field before it is reported, so a token match of
1332    /// the search or anything else the filter admits never is.
1333    ///
1334    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1335    /// own whole reads counts this one among them.
1336    pub const ORIGIN_LOOKUP: &str = concat!(
1337        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1338        relationship_events_variable!(),
1339        r#"){
1340      originItems:repositoryOwner(login:$owner){
1341        ... on ProjectV2Owner{projectV2(number:$number){
1342          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1343        board_item_values!(),
1344        board_item_content!(),
1345        r#"} pageInfo{hasNextPage endCursor}}
1346        }}
1347      }
1348      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1349        pageInfo{hasNextPage endCursor}
1350        nodes{__typename ...BoardIssue}
1351      }
1352    }"#,
1353        board_issue!()
1354    );
1355
1356    /// Every closed item of the board, by the board's own field filter — what
1357    /// `task status reconcile` reads, and nothing else of the board.
1358    ///
1359    /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1360    /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1361    /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1362    /// closed, with a reason and a card that contradict each other, from the item node it
1363    /// already carries, so a filter that also let open items through would cost reads and
1364    /// write nothing wrong.
1365    ///
1366    /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1367    /// option writes that follow need no read of the board. The root is aliased `closedItems`
1368    /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1369    /// among them.
1370    pub const CLOSED_ITEMS: &str = concat!(
1371        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1372        relationship_events_variable!(),
1373        r#"){
1374      closedItems:repositoryOwner(login:$owner){
1375        ... on ProjectV2Owner{projectV2(number:$number){id
1376          fields(first:$nestedFirst){nodes{
1377            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1378            ... on ProjectV2Field{__typename id name dataType}
1379          }pageInfo{hasNextPage}}
1380          items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1381        board_item_values!(),
1382        board_item_content!(),
1383        r#"} pageInfo{hasNextPage endCursor}}
1384        }}
1385      }
1386    }"#
1387    );
1388
1389    /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1390    macro_rules! reference_alias {
1391        ($n:literal) => {
1392            concat!(
1393                "\n      r",
1394                stringify!($n),
1395                ":repository(owner:$owner",
1396                stringify!($n),
1397                ",name:$name",
1398                stringify!($n),
1399                "){issue(number:$number",
1400                stringify!($n),
1401                "){...HeldIssue}}"
1402            )
1403        };
1404    }
1405
1406    /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1407    /// number — the one read `task status complete` makes of a source, and nothing else of
1408    /// the board: no `items`, no search and no project read.
1409    ///
1410    /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1411    /// references one point.** It selects no labels, and of each board membership only the
1412    /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1413    /// connection, its page of memberships, where the whole fragment asks for five. What a
1414    /// completion writes is a card and, at most, the issue's state, so the labels and the other
1415    /// field values it leaves out are never what it needs; an item read this way is held for
1416    /// that write alone and never answers another read. An issue whose entry for this board is
1417    /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1418    /// every other read of an issue recovers one.
1419    ///
1420    /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1421    /// fewer references binds the slots it has none for to its last one.
1422    pub const ISSUES_BY_REFERENCE: &str = concat!(
1423        r#"query($owner0:String!,$name0:String!,$number0:Int!,$owner1:String!,$name1:String!,$number1:Int!,$owner2:String!,$name2:String!,$number2:Int!,$owner3:String!,$name3:String!,$number3:Int!,$owner4:String!,$name4:String!,$number4:Int!,$owner5:String!,$name5:String!,$number5:Int!,$owner6:String!,$name6:String!,$number6:Int!,$owner7:String!,$name7:String!,$number7:Int!,$owner8:String!,$name8:String!,$number8:Int!,$owner9:String!,$name9:String!,$number9:Int!,$owner10:String!,$name10:String!,$number10:Int!,$owner11:String!,$name11:String!,$number11:Int!,$owner12:String!,$name12:String!,$number12:Int!,$owner13:String!,$name13:String!,$number13:Int!,$owner14:String!,$name14:String!,$number14:Int!,$owner15:String!,$name15:String!,$number15:Int!,$owner16:String!,$name16:String!,$number16:Int!,$owner17:String!,$name17:String!,$number17:Int!,$owner18:String!,$name18:String!,$number18:Int!,$owner19:String!,$name19:String!,$number19:Int!,$owner20:String!,$name20:String!,$number20:Int!,$owner21:String!,$name21:String!,$number21:Int!,$owner22:String!,$name22:String!,$number22:Int!,$owner23:String!,$name23:String!,$number23:Int!,$owner24:String!,$name24:String!,$number24:Int!,$owner25:String!,$name25:String!,$number25:Int!,$owner26:String!,$name26:String!,$number26:Int!,$owner27:String!,$name27:String!,$number27:Int!,$owner28:String!,$name28:String!,$number28:Int!,$owner29:String!,$name29:String!,$number29:Int!,$owner30:String!,$name30:String!,$number30:Int!,$owner31:String!,$name31:String!,$number31:Int!,$owner32:String!,$name32:String!,$number32:Int!,$owner33:String!,$name33:String!,$number33:Int!,$owner34:String!,$name34:String!,$number34:Int!,$owner35:String!,$name35:String!,$number35:Int!,$owner36:String!,$name36:String!,$number36:Int!,$owner37:String!,$name37:String!,$number37:Int!,$owner38:String!,$name38:String!,$number38:Int!,$owner39:String!,$name39:String!,$number39:Int!,$owner40:String!,$name40:String!,$number40:Int!,$owner41:String!,$name41:String!,$number41:Int!,$owner42:String!,$name42:String!,$number42:Int!,$owner43:String!,$name43:String!,$number43:Int!,$owner44:String!,$name44:String!,$number44:Int!,$owner45:String!,$name45:String!,$number45:Int!,$owner46:String!,$name46:String!,$number46:Int!,$owner47:String!,$name47:String!,$number47:Int!,$owner48:String!,$name48:String!,$number48:Int!,$owner49:String!,$name49:String!,$number49:Int!,$boardItems:Int!,$statusField:String!,$duplicates:Boolean!"#,
1424        relationship_events_variable!(),
1425        r#"){"#,
1426        reference_alias!(0),
1427        reference_alias!(1),
1428        reference_alias!(2),
1429        reference_alias!(3),
1430        reference_alias!(4),
1431        reference_alias!(5),
1432        reference_alias!(6),
1433        reference_alias!(7),
1434        reference_alias!(8),
1435        reference_alias!(9),
1436        reference_alias!(10),
1437        reference_alias!(11),
1438        reference_alias!(12),
1439        reference_alias!(13),
1440        reference_alias!(14),
1441        reference_alias!(15),
1442        reference_alias!(16),
1443        reference_alias!(17),
1444        reference_alias!(18),
1445        reference_alias!(19),
1446        reference_alias!(20),
1447        reference_alias!(21),
1448        reference_alias!(22),
1449        reference_alias!(23),
1450        reference_alias!(24),
1451        reference_alias!(25),
1452        reference_alias!(26),
1453        reference_alias!(27),
1454        reference_alias!(28),
1455        reference_alias!(29),
1456        reference_alias!(30),
1457        reference_alias!(31),
1458        reference_alias!(32),
1459        reference_alias!(33),
1460        reference_alias!(34),
1461        reference_alias!(35),
1462        reference_alias!(36),
1463        reference_alias!(37),
1464        reference_alias!(38),
1465        reference_alias!(39),
1466        reference_alias!(40),
1467        reference_alias!(41),
1468        reference_alias!(42),
1469        reference_alias!(43),
1470        reference_alias!(44),
1471        reference_alias!(45),
1472        reference_alias!(46),
1473        reference_alias!(47),
1474        reference_alias!(48),
1475        reference_alias!(49),
1476        "\n    }",
1477        r#" fragment HeldIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
1478      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1479        status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1480          ... on ProjectV2SingleSelectField{id name options{id name}}
1481        }}}
1482      }pageInfo{hasNextPage endCursor}}}"#
1483    );
1484
1485    /// The board's own id and field definitions, and not one of its items.
1486    ///
1487    /// What a write needs of the board when the item it writes does not say: the id a field
1488    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1489    /// origin fields. It selects no `items`, so what it costs is the board's field list
1490    /// however many items the board holds — and it decides nothing about which items those
1491    /// are, which is the question a read of one item by its own id answers instead.
1492    ///
1493    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1494    /// board's item reads by their root counts this one among them.
1495    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1496      boardFields:repositoryOwner(login:$owner){
1497        ... on ProjectV2Owner{projectV2(number:$number){id
1498          fields(first:$nestedFirst){nodes{
1499            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1500            ... on ProjectV2Field{__typename id name dataType}
1501          }pageInfo{hasNextPage}}
1502        }}
1503      }
1504    }"#;
1505
1506    /// One board draft by its own node id, with the board item it sits in.
1507    ///
1508    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1509    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1510    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1511    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1512    /// board listing hands it to, and nothing has to list the board to find one.
1513    pub const DRAFT: &str = concat!(
1514        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1515      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1516        projectV2Items(first:$boardItems){nodes{id project{id number}
1517        "#,
1518        board_item_values!(),
1519        r#"}pageInfo{hasNextPage endCursor}}}}
1520    }"#
1521    );
1522
1523    /// One issue's board memberships alone, walked past the page a read of it carried.
1524    ///
1525    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1526    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1527    /// boards than that page holds may have this board's entry past its end. This asks that
1528    /// one issue for its memberships and nothing else — the caller already holds the issue —
1529    /// so an answer of "this board does not hold it" is only ever given about a connection
1530    /// read to exhaustion.
1531    ///
1532    /// It selects the board item's id, its project number and the same
1533    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1534    /// very same resolver: an issue recovered this way reports the same title, the same
1535    /// status, the same labels and the same qualified id as one whose entry was on the
1536    /// page.
1537    ///
1538    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1539    /// multiplies through it and the membership connection can be walked at
1540    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1541    /// further request for any issue a person really keeps.
1542    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1543        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1544      node(id:$id){
1545        ... on Issue{projectItems(first:$first,after:$after){
1546          nodes{id updatedAt project{id number}
1547        "#,
1548        board_item_values!(),
1549        r#"}
1550          pageInfo{hasNextPage endCursor}}}
1551      }
1552    }"#
1553    );
1554    /// Whether the board's Project is public — half of what decides whether a write here can
1555    /// be read by anybody. Needs the `read:project` scope.
1556    pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1557    /// Resolves the configured repository's node id, which creating an issue requires.
1558    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1559    /// What creating an issue needs and has not read yet: the board's own id and field
1560    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1561    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1562    ///
1563    /// Sent at the point a create knows which repository it is for, when neither half is
1564    /// already known to this process; a create needing only one of them sends that one's own
1565    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1566    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1567    /// status.
1568    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1569      boardFields:repositoryOwner(login:$owner){
1570        ... on ProjectV2Owner{projectV2(number:$number){id
1571          fields(first:$nestedFirst){nodes{
1572            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1573            ... on ProjectV2Field{__typename id name dataType}
1574          }pageInfo{hasNextPage}}
1575        }}
1576      }
1577      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1578    }"#;
1579    /// Reads both dependency directions for one issue, with each far end's own kind — and
1580    /// the issue's own body, which is where an edge to another source is recorded, so that
1581    /// half of a dependency read needs no second read of the issue or of the board.
1582    pub const ISSUE_DEPENDENCIES: &str = concat!(
1583        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1584      ... on Issue{body
1585        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1586        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1587      }}}"#,
1588        related_issue!()
1589    );
1590    /// What one item depends on: [`ISSUE_DEPENDENCIES`] narrowed to `blockedBy`, so a
1591    /// dependency read asks GitHub for the one direction it answers. The issue's `body` rides
1592    /// along because its metadata slot is where an edge to another source is recorded — and a
1593    /// board draft's `body` too, with the one board item GitHub links a draft to and, aliased
1594    /// `board`, this board's own id, so a draft's recorded edges are read off this same answer
1595    /// and only when that item is this board's: no draft read and no board read follow it.
1596    ///
1597    /// The draft's board item is asked for under a root of its own, aliased `draft`, rather
1598    /// than beside its `body` under `node`. GitHub counts the type-conditional fragments of
1599    /// one selection as the largest of them — `blockedBy`'s 100 alone, where this workspace's
1600    /// calculation adds the draft's 3 — while separate roots are summed by both, so this is
1601    /// the spelling whose node count GitHub and the calculation agree on. Both price it at
1602    /// one point; the credentialed lane's reconciliation is what holds the two to that.
1603    pub const ISSUE_DEPENDS_ON: &str = concat!(
1604        r#"query($id:ID!,$first:Int!,$after:String,$owner:String!,$number:Int!,$boardItems:Int!){
1605      node(id:$id){__typename
1606      ... on Issue{body
1607        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1608      }
1609      ... on DraftIssue{id body}}
1610      draft:node(id:$id){... on DraftIssue{projectV2Items(first:$boardItems){nodes{project{id number}}pageInfo{hasNextPage}}}}
1611      board:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id}}}}"#,
1612        related_issue!()
1613    );
1614    /// What depends on one issue: [`ISSUE_DEPENDENCIES`] with its `blocking` alone. No `body`:
1615    /// the reverse of a recorded edge is derived from the far end, never written down here.
1616    pub const ISSUE_DEPENDED_ON_BY: &str = concat!(
1617        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1618      ... on Issue{
1619        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1620      }}}"#,
1621        related_issue!()
1622    );
1623    /// Creates one issue. Outside a copy it is created on no board, and [`ADD_TO_BOARD`] files
1624    /// it; inside one, its input names the board in `projectV2Ids` and its project in
1625    /// `parentIssueId`, so this one request files it under both — see
1626    /// [`GitHubProjectsSource::create_filed_issue`](super::GitHubProjectsSource).
1627    pub const CREATE_ISSUE: &str =
1628        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1629    /// Puts an existing issue on the configured board.
1630    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1631    /// Updates an issue's visible fields and its open or closed state in one call.
1632    pub const UPDATE_ISSUE: &str =
1633        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1634    /// Updates an existing draft's user-visible fields.
1635    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1636    /// Updates a text or single-select value on one project item.
1637    pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1638    /// Writes up to six board fields and clears up to three in one ordered mutation: every
1639    /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1640    /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1641    pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$fourth:UpdateProjectV2ItemFieldValueInput!,$fifth:UpdateProjectV2ItemFieldValueInput!,$sixth:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$clearSecond:ClearProjectV2ItemFieldValueInput!,$clearThird:ClearProjectV2ItemFieldValueInput!,$writeFirst:Boolean!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeFourth:Boolean!,$writeFifth:Boolean!,$writeSixth:Boolean!,$writeClear:Boolean!,$writeClearSecond:Boolean!,$writeClearThird:Boolean!){updateProjectV2ItemFieldValue(input:$input) @include(if:$writeFirst){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} fourth:updateProjectV2ItemFieldValue(input:$fourth) @include(if:$writeFourth){projectV2Item{id}} fifth:updateProjectV2ItemFieldValue(input:$fifth) @include(if:$writeFifth){projectV2Item{id}} sixth:updateProjectV2ItemFieldValue(input:$sixth) @include(if:$writeSixth){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}} clearedSecond:clearProjectV2ItemFieldValue(input:$clearSecond) @include(if:$writeClearSecond){projectV2Item{id}} clearedThird:clearProjectV2ItemFieldValue(input:$clearThird) @include(if:$writeClearThird){projectV2Item{id}}}"#;
1642    /// Clears one project item's value of one field, which is what a `none` priority is.
1643    pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1644    /// Creates one field: a single-select one with its options, or a text one for a projected
1645    /// metadata value. Only the guarded field setup may use this document, and only for a
1646    /// field the board lacks.
1647    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1648    /// Replaces a single-select field's options. Only the guarded field setup — the
1649    /// `status-options` and `fields` operations — may use this document, because GitHub
1650    /// treats the input as the complete option list.
1651    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1652    /// A fresh snapshot of the Status field and every board item's assignment.
1653    pub const STATUS_OPTIONS_SNAPSHOT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){owner:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id fields(first:$nestedFirst){nodes{... on ProjectV2SingleSelectField{id name options{id name color description}}}pageInfo{hasNextPage}} items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{... on ProjectV2ItemFieldSingleSelectValue{name optionId field{... on ProjectV2SingleSelectField{id name}}}}pageInfo{hasNextPage}}}pageInfo{hasNextPage endCursor}}}}}}"#;
1654    /// Files one issue under another as a sub-issue, which is what project membership is.
1655    pub const ADD_SUB_ISSUE: &str =
1656        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1657    /// Takes one issue back out of its parent.
1658    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1659    /// Adds GitHub's native issue blocked-by relationship.
1660    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1661    /// One origin of [`ORIGIN_LOOKUPS`]: [`ORIGIN_LOOKUP`]'s two connections for the origin the
1662    /// `$filter<n>` and `$search<n>` of slot `<n>` name, sent only while `$ask<n>` holds.
1663    macro_rules! origin_lookup_alias {
1664        ($n:literal) => {
1665            concat!(
1666                "\n      o",
1667                stringify!($n),
1668                ":repositoryOwner(login:$owner) @include(if:$ask",
1669                stringify!($n),
1670                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$lookupFirst,query:$filter",
1671                stringify!($n),
1672                "){nodes{id updatedAt ",
1673                board_item_values!(),
1674                board_item_content!(),
1675                "} pageInfo{hasNextPage endCursor}}}}}\n      s",
1676                stringify!($n),
1677                ":search(query:$search",
1678                stringify!($n),
1679                ",type:$type,first:$lookupFirst) @include(if:$ask",
1680                stringify!($n),
1681                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}"
1682            )
1683        };
1684    }
1685
1686    /// [`ORIGIN_LOOKUPS`], with `$extra_variables` declared after its own and `$extra_fields`
1687    /// selected beside them: one spelling of the batched lookup for the documents that send it.
1688    macro_rules! origin_lookups {
1689        ($extra_variables:literal, $extra_fields:literal) => {
1690            concat!(
1691                r#"query($owner:String!,$number:Int!,$type:SearchType!,$lookupFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!,$repositoryOwner:String!,$repositoryName:String!,$filter0:String!,$filter1:String!,$filter2:String!,$filter3:String!,$filter4:String!,$filter5:String!,$filter6:String!,$filter7:String!,$filter8:String!,$filter9:String!,$search0:String!,$search1:String!,$search2:String!,$search3:String!,$search4:String!,$search5:String!,$search6:String!,$search7:String!,$search8:String!,$search9:String!,$ask0:Boolean!,$ask1:Boolean!,$ask2:Boolean!,$ask3:Boolean!,$ask4:Boolean!,$ask5:Boolean!,$ask6:Boolean!,$ask7:Boolean!,$ask8:Boolean!,$ask9:Boolean!"#,
1692                relationship_events_variable!(),
1693                $extra_variables,
1694                r#"){
1695      boardFields:repositoryOwner(login:$owner){
1696        ... on ProjectV2Owner{projectV2(number:$number){id
1697          fields(first:$nestedFirst){nodes{
1698            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1699            ... on ProjectV2Field{__typename id name dataType}
1700          }pageInfo{hasNextPage}}
1701        }}
1702      }
1703      repository(owner:$repositoryOwner,name:$repositoryName){id databaseId nameWithOwner}"#,
1704                $extra_fields,
1705                origin_lookup_alias!(0),
1706                origin_lookup_alias!(1),
1707                origin_lookup_alias!(2),
1708                origin_lookup_alias!(3),
1709                origin_lookup_alias!(4),
1710                origin_lookup_alias!(5),
1711                origin_lookup_alias!(6),
1712                origin_lookup_alias!(7),
1713                origin_lookup_alias!(8),
1714                origin_lookup_alias!(9),
1715                "\n    }",
1716                board_issue!()
1717            )
1718        };
1719    }
1720
1721    /// The first page of [`ORIGIN_LOOKUP`] for up to [`ORIGIN_BATCH`](super::ORIGIN_BATCH)
1722    /// origins a copy is about to look for, and everything creating an item then reads —
1723    /// the board's own id and field definitions, as [`CREATION_CONTEXT`] reads them, and the node
1724    /// id and the numeric id of the repository an issue is created in — in one request.
1725    ///
1726    /// What a copy reads before it writes anything, so that finding none of its records on the
1727    /// board and creating every one costs this read and the writes. An origin whose page says
1728    /// there is more is left to its own [`ORIGIN_LOOKUP`]. The repository's numeric id is what an
1729    /// image upload names, so a copy carrying assets needs no `GET /repos/{owner}/{repo}` either.
1730    pub const ORIGIN_LOOKUPS: &str = origin_lookups!("", "");
1731
1732    /// [`ORIGIN_LOOKUPS`], with a page of the members of the project `$project` beside it, each
1733    /// trimmed to what classifying it reads — its id, its parent, its body and its repository,
1734    /// and no connection under it.
1735    ///
1736    /// What a document copy sends where the public boundary screens its write: the document is
1737    /// held to who may read its project, which is its members' classification as much as the
1738    /// project's own, and this request carries both the project — among the origins it asks —
1739    /// and its members. GitHub's own dry run prices it at the one point [`ORIGIN_LOOKUPS`]
1740    /// costs alone (`docs/measurements/board-copy-gaps-2026-10-24/coalesced-members-dry-run`).
1741    pub const ORIGIN_LOOKUPS_WITH_MEMBERS: &str = origin_lookups!(
1742        ",$project:ID!,$memberFirst:Int!",
1743        "\n      members:node(id:$project){... on Issue{subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{id body parent{id} repository{nameWithOwner}}}}}"
1744    );
1745
1746    /// One slot `<n>` of a copy's aliased writes: `$<prefix><n>` is its input and `$send<n>`
1747    /// whether it is sent.
1748    macro_rules! write_slot {
1749        ($prefix:literal, $field:literal, $selection:literal, $n:literal) => {
1750            concat!(
1751                " ",
1752                $prefix,
1753                stringify!($n),
1754                ":",
1755                $field,
1756                "(input:$",
1757                $prefix,
1758                stringify!($n),
1759                ") @include(if:$send",
1760                stringify!($n),
1761                "){",
1762                $selection,
1763                "}"
1764            )
1765        };
1766    }
1767
1768    /// The variables of a copy's aliased writes: eleven inputs of `$type` named `$<prefix><n>`,
1769    /// and eleven `$send<n>`.
1770    macro_rules! write_variables {
1771        ($prefix:literal, $type:literal) => {
1772            concat!(
1773                "mutation(",
1774                "$",
1775                $prefix,
1776                "0:",
1777                $type,
1778                "!,$",
1779                $prefix,
1780                "1:",
1781                $type,
1782                "!,$",
1783                $prefix,
1784                "2:",
1785                $type,
1786                "!,$",
1787                $prefix,
1788                "3:",
1789                $type,
1790                "!,$",
1791                $prefix,
1792                "4:",
1793                $type,
1794                "!,$",
1795                $prefix,
1796                "5:",
1797                $type,
1798                "!,$",
1799                $prefix,
1800                "6:",
1801                $type,
1802                "!,$",
1803                $prefix,
1804                "7:",
1805                $type,
1806                "!,$",
1807                $prefix,
1808                "8:",
1809                $type,
1810                "!,$",
1811                $prefix,
1812                "9:",
1813                $type,
1814                "!,$",
1815                $prefix,
1816                "10:",
1817                $type,
1818                "!,",
1819                "$send0:Boolean!,$send1:Boolean!,$send2:Boolean!,$send3:Boolean!,$send4:Boolean!,",
1820                "$send5:Boolean!,$send6:Boolean!,$send7:Boolean!,$send8:Boolean!,$send9:Boolean!,",
1821                "$send10:Boolean!){"
1822            )
1823        };
1824    }
1825
1826    /// A copy's aliased writes of one kind: [`MUTATION_BATCH`](super::MUTATION_BATCH) slots of
1827    /// `$field`, each sent only while its `$send<n>` holds.
1828    macro_rules! write_batch {
1829        ($prefix:literal, $type:literal, $field:literal, $selection:literal) => {
1830            concat!(
1831                write_variables!($prefix, $type),
1832                write_slot!($prefix, $field, $selection, 0),
1833                write_slot!($prefix, $field, $selection, 1),
1834                write_slot!($prefix, $field, $selection, 2),
1835                write_slot!($prefix, $field, $selection, 3),
1836                write_slot!($prefix, $field, $selection, 4),
1837                write_slot!($prefix, $field, $selection, 5),
1838                write_slot!($prefix, $field, $selection, 6),
1839                write_slot!($prefix, $field, $selection, 7),
1840                write_slot!($prefix, $field, $selection, 8),
1841                write_slot!($prefix, $field, $selection, 9),
1842                write_slot!($prefix, $field, $selection, 10),
1843                "}"
1844            )
1845        };
1846    }
1847
1848    /// Creates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues in one request, each filed on
1849    /// the board and under its project by the same `createIssue` — its `projectV2Ids` and
1850    /// `parentIssueId` — as [`CREATE_ISSUE`] creates one.
1851    pub const CREATE_ISSUES: &str = write_batch!(
1852        "create",
1853        "CreateIssueInput",
1854        "createIssue",
1855        "issue{id number url}"
1856    );
1857
1858    /// One issue of [`CREATED_BOARD_ITEMS`]: the board items the issue `$id<n>` names is on, with
1859    /// the `Status` value of each, read only while `$read<n>` holds.
1860    macro_rules! created_item_alias {
1861        ($n:literal) => {
1862            concat!(
1863                "\n      i",
1864                stringify!($n),
1865                ":node(id:$id",
1866                stringify!($n),
1867                ") @include(if:$read",
1868                stringify!($n),
1869                "){... on Issue{id projectItems(first:$boardItems){nodes{id project{id} status:fieldValueByName(name:$statusName){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}"
1870            )
1871        };
1872    }
1873
1874    /// The board item of each issue a copy created, up to
1875    /// [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH) at a time, with its `Status` as the board
1876    /// set it: what `createIssue`'s `projectV2Ids` files the issue as, which GitHub adds after
1877    /// answering the create, so its id comes from a read made once every create has been sent.
1878    pub const CREATED_BOARD_ITEMS: &str = concat!(
1879        r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$read0:Boolean!,$read1:Boolean!,$read2:Boolean!,$read3:Boolean!,$read4:Boolean!,$read5:Boolean!,$read6:Boolean!,$read7:Boolean!,$read8:Boolean!,$read9:Boolean!,$read10:Boolean!,$read11:Boolean!,$read12:Boolean!,$read13:Boolean!,$read14:Boolean!,$read15:Boolean!,$read16:Boolean!,$read17:Boolean!,$read18:Boolean!,$read19:Boolean!,$read20:Boolean!,$boardItems:Int!,$statusName:String!){"#,
1880        created_item_alias!(0),
1881        created_item_alias!(1),
1882        created_item_alias!(2),
1883        created_item_alias!(3),
1884        created_item_alias!(4),
1885        created_item_alias!(5),
1886        created_item_alias!(6),
1887        created_item_alias!(7),
1888        created_item_alias!(8),
1889        created_item_alias!(9),
1890        created_item_alias!(10),
1891        created_item_alias!(11),
1892        created_item_alias!(12),
1893        created_item_alias!(13),
1894        created_item_alias!(14),
1895        created_item_alias!(15),
1896        created_item_alias!(16),
1897        created_item_alias!(17),
1898        created_item_alias!(18),
1899        created_item_alias!(19),
1900        created_item_alias!(20),
1901        "\n    }"
1902    );
1903
1904    /// The variables and the aliases of [`WRITE_BATCH`](super::WRITE_BATCH) slots: `$<prefix><n>`
1905    /// is a slot's input and `$<send><n>` whether it is sent, for the slot numbers given.
1906    macro_rules! wide_variables {
1907        ($prefix:literal, $type:literal, $send:literal; $($n:literal)+) => {
1908            concat!($("$", $prefix, stringify!($n), ":", $type, "!,$", $send, stringify!($n), ":Boolean!,",)+)
1909        };
1910    }
1911    macro_rules! wide_slots {
1912        ($prefix:literal, $field:literal, $selection:literal, $send:literal; $($n:literal)+) => {
1913            concat!($(
1914                " ", $prefix, stringify!($n), ":", $field, "(input:$", $prefix, stringify!($n),
1915                ") @include(if:$", $send, stringify!($n), "){", $selection, "}",
1916            )+)
1917        };
1918    }
1919
1920    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) board field values a copy writes on the items it
1921    /// created, each on its own item, in one ordered request.
1922    pub const WRITE_FIELD_VALUES: &str = concat!(
1923        "mutation(",
1924        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1925        "){",
1926        wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1927        "}"
1928    );
1929
1930    /// A copy's board field values and its blocked-by edges together, up to
1931    /// [`WRITE_BATCH`](super::WRITE_BATCH) of the two kinds between them, in one ordered request:
1932    /// every field value its `$sendField<n>` sends, then every edge its `$sendBlock<n>` sends.
1933    /// What a copy sends when one request of its writes holds both kinds; one holding a single
1934    /// kind is [`WRITE_FIELD_VALUES`] or [`ADD_BLOCKED_BYS`].
1935    pub const WRITE_FIELDS_AND_EDGES: &str = concat!(
1936        "mutation(",
1937        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1938        wide_variables!("block", "AddBlockedByInput", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1939        "){",
1940        wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1941        wide_slots!("block", "addBlockedBy", "issue{id} blockingIssue{id}", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1942        "}"
1943    );
1944
1945    /// Closes up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues a copy created at a closing
1946    /// status, each with its own `stateInput` and nothing else, once its option is selected.
1947    pub const CLOSE_ISSUES: &str =
1948        write_batch!("close", "UpdateIssueInput", "updateIssue", "issue{id}");
1949
1950    /// Updates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) existing issues a copy rewrites,
1951    /// each with the members its own [`UPDATE_ISSUE`] would have sent, in one request.
1952    pub const UPDATE_ISSUES: &str =
1953        write_batch!("update", "UpdateIssueInput", "updateIssue", "issue{id}");
1954
1955    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) native blocked-by relationships a copy adds to the
1956    /// issues it created, in one request.
1957    pub const ADD_BLOCKED_BYS: &str = concat!(
1958        "mutation(",
1959        wide_variables!("block", "AddBlockedByInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1960        "){",
1961        wide_slots!("block", "addBlockedBy", "issue{id} blockingIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1962        "}"
1963    );
1964
1965    /// Removes one native issue blocked-by relationship.
1966    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1967    /// Deletes one issue, which takes its board item with it.
1968    ///
1969    /// The engine sends this in one situation only: undoing a copy that could not finish,
1970    /// over the items that same copy created. Deleting the issue removes the board item
1971    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1972    pub const DELETE_ISSUE: &str =
1973        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1974    /// Deletes up to [`DELETE_BATCH`](super::DELETE_BATCH) issues in one request, each as
1975    /// [`DELETE_ISSUE`] deletes one: what a write that created several issues and was refused
1976    /// part way sends to take them back.
1977    pub const DELETE_ISSUES: &str = concat!(
1978        "mutation(",
1979        wide_variables!("delete", "DeleteIssueInput", "send"; 0 1 2 3 4),
1980        "){",
1981        wide_slots!("delete", "deleteIssue", "repository{id}", "send"; 0 1 2 3 4),
1982        "}"
1983    );
1984
1985    /// One task's comments: a page of its issue's own `comments` connection.
1986    ///
1987    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1988    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1989    /// list every time somebody edited it; left unordered the connection answers in the order
1990    /// the comments were written, which is the order GitHub documents for the same collection
1991    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1992    /// node count and the caller's own page size is pushed straight down.
1993    pub const ISSUE_COMMENTS: &str = concat!(
1994        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1995        issue_comment!(),
1996        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1997    );
1998    /// One issue by its own node id, with a page of its comments: what `task show` and a
1999    /// comment listing read, in one request.
2000    ///
2001    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
2002    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
2003    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
2004    /// connection there would multiply through both of those documents' price, and neither
2005    /// needs one.
2006    pub const ISSUE_DETAIL: &str = concat!(
2007        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2008        relationship_events_variable!(),
2009        r#"){
2010      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
2011        issue_comment!(),
2012        r#"}pageInfo{hasNextPage endCursor}}}}
2013    }"#,
2014        board_issue!()
2015    );
2016
2017    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
2018    /// page of its comments when `$comments` asks for them.
2019    macro_rules! issue_details_alias {
2020        ($n:literal) => {
2021            concat!(
2022                "\n      i",
2023                stringify!($n),
2024                ":node(id:$id",
2025                stringify!($n),
2026                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
2027                issue_comment!(),
2028                "}pageInfo{hasNextPage endCursor}}}}"
2029            )
2030        };
2031    }
2032
2033    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
2034    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
2035    ///
2036    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
2037    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
2038    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
2039    /// neither — so every connection under it would be priced at nothing and the pin in
2040    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
2041    /// one-item read the model already prices, so the batch costs what its aliases cost.
2042    ///
2043    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
2044    /// slots it has no item for to the last item it does, and reads that item again; the
2045    /// price is the document's, whatever its variables, so a short batch costs what a full
2046    /// one does and nothing more.
2047    pub const ISSUE_DETAILS: &str = concat!(
2048        r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$id21:ID!,$id22:ID!,$id23:ID!,$first:Int!,$comments:Boolean!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2049        relationship_events_variable!(),
2050        r#"){"#,
2051        issue_details_alias!(0),
2052        issue_details_alias!(1),
2053        issue_details_alias!(2),
2054        issue_details_alias!(3),
2055        issue_details_alias!(4),
2056        issue_details_alias!(5),
2057        issue_details_alias!(6),
2058        issue_details_alias!(7),
2059        issue_details_alias!(8),
2060        issue_details_alias!(9),
2061        issue_details_alias!(10),
2062        issue_details_alias!(11),
2063        issue_details_alias!(12),
2064        issue_details_alias!(13),
2065        issue_details_alias!(14),
2066        issue_details_alias!(15),
2067        issue_details_alias!(16),
2068        issue_details_alias!(17),
2069        issue_details_alias!(18),
2070        issue_details_alias!(19),
2071        issue_details_alias!(20),
2072        issue_details_alias!(21),
2073        issue_details_alias!(22),
2074        issue_details_alias!(23),
2075        "\n    }",
2076        board_issue!()
2077    );
2078
2079    /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
2080    /// page of its comments and the issues it is blocked by.
2081    macro_rules! issue_copy_details_alias {
2082        ($n:literal) => {
2083            concat!(
2084                "\n      i",
2085                stringify!($n),
2086                ":node(id:$id",
2087                stringify!($n),
2088                "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
2089                issue_comment!(),
2090                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
2091            )
2092        };
2093    }
2094
2095    /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
2096    /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
2097    /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
2098    /// at a time, so a copy of a changed task costs this read and nothing else.
2099    ///
2100    /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
2101    /// what `task show-many` is priced by and which reads no edge.
2102    ///
2103    /// `children`, read only while `$children` holds, is which issues the issue `$project` has
2104    /// as sub-issues — each one's id and title, the page `$nestedFirst` allows — so that a
2105    /// project copy, which reads its project and every task at once, knows from this same read
2106    /// which tasks the project holds at the destination, and its orphan check is answered by it
2107    /// rather than by a listing of the project.
2108    pub const ISSUE_COPY_DETAILS: &str = concat!(
2109        r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$project:ID!,$children:Boolean!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2110        relationship_events_variable!(),
2111        r#"){"#,
2112        issue_copy_details_alias!(0),
2113        issue_copy_details_alias!(1),
2114        issue_copy_details_alias!(2),
2115        issue_copy_details_alias!(3),
2116        issue_copy_details_alias!(4),
2117        issue_copy_details_alias!(5),
2118        issue_copy_details_alias!(6),
2119        issue_copy_details_alias!(7),
2120        issue_copy_details_alias!(8),
2121        issue_copy_details_alias!(9),
2122        issue_copy_details_alias!(10),
2123        issue_copy_details_alias!(11),
2124        issue_copy_details_alias!(12),
2125        issue_copy_details_alias!(13),
2126        issue_copy_details_alias!(14),
2127        issue_copy_details_alias!(15),
2128        issue_copy_details_alias!(16),
2129        issue_copy_details_alias!(17),
2130        issue_copy_details_alias!(18),
2131        issue_copy_details_alias!(19),
2132        issue_copy_details_alias!(20),
2133        "\n      children:node(id:$project) @include(if:$children){... on Issue{id subIssues(first:$nestedFirst){nodes{id title} pageInfo{hasNextPage}}}}",
2134        "\n    }",
2135        board_issue!(),
2136        related_issue!()
2137    );
2138
2139    /// Which issue one comment is on, read before that comment is edited or removed.
2140    ///
2141    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
2142    /// comment id given against the wrong task would change a comment on another issue.
2143    pub const COMMENT_ISSUE: &str =
2144        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
2145    /// Adds one comment to an issue, signed as the account the token belongs to.
2146    pub const ADD_COMMENT: &str = concat!(
2147        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
2148        issue_comment!(),
2149        r#"}}}}"#
2150    );
2151    /// Replaces the body of one issue comment.
2152    pub const UPDATE_COMMENT: &str = concat!(
2153        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
2154        issue_comment!(),
2155        r#"}}}"#
2156    );
2157    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
2158    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
2159
2160    /// The variables of the search slots `<n>` of [`SEARCH_BATCH`]: its search, where it
2161    /// resumes, its page size and whether it is asked.
2162    macro_rules! search_batch_variables {
2163        ($($n:literal)+) => {
2164            concat!($(
2165                ",$search", stringify!($n), ":String!,$after", stringify!($n),
2166                ":String,$first", stringify!($n), ":Int!,$ask", stringify!($n), ":Boolean!",
2167            )+)
2168        };
2169    }
2170    /// The search slots `<n>` of [`SEARCH_BATCH`]: [`SEARCH_ISSUES`]'s one connection, aliased
2171    /// `s<n>`, sent only while `$ask<n>` holds.
2172    macro_rules! search_batch_slots {
2173        ($($n:literal)+) => {
2174            concat!($(
2175                "\n      s", stringify!($n), ":search(query:$search", stringify!($n),
2176                ",type:$type,first:$first", stringify!($n), ",after:$after", stringify!($n),
2177                ") @include(if:$ask", stringify!($n),
2178                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}",
2179            )+)
2180        };
2181    }
2182    /// The variables of the origin slots `<n>` of [`SEARCH_BATCH`]: the board's field filter
2183    /// naming the origin, where its items resume, their page size and whether it is asked.
2184    macro_rules! origin_batch_variables {
2185        ($($n:literal)+) => {
2186            concat!($(
2187                ",$filter", stringify!($n), ":String!,$itemsAfter", stringify!($n),
2188                ":String,$itemsFirst", stringify!($n), ":Int!,$origin", stringify!($n),
2189                ":Boolean!",
2190            )+)
2191        };
2192    }
2193    /// The origin slots `<n>` of [`SEARCH_BATCH`]: [`ORIGIN_LOOKUP`]'s board half, the board's
2194    /// own items through its field filter, aliased `o<n>`, sent only while `$origin<n>` holds.
2195    macro_rules! origin_batch_slots {
2196        ($($n:literal)+) => {
2197            concat!($(
2198                "\n      o", stringify!($n), ":repositoryOwner(login:$owner) @include(if:$origin",
2199                stringify!($n),
2200                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$itemsFirst",
2201                stringify!($n), ",after:$itemsAfter", stringify!($n), ",query:$filter",
2202                stringify!($n), "){nodes{id updatedAt ", board_item_values!(),
2203                board_item_content!(), "} pageInfo{hasNextPage endCursor}}}}}",
2204            )+)
2205        };
2206    }
2207
2208    /// Up to [`SEARCH_BATCH`](super::SEARCH_BATCH) board-scoped issue searches in one request,
2209    /// each [`SEARCH_ISSUES`]'s connection in an aliased `s<n>` field with its own query, page
2210    /// size and cursor, and beside them up to
2211    /// [`SEARCH_BATCH_ORIGINS`](super::SEARCH_BATCH_ORIGINS) origin lookups' board halves —
2212    /// [`ORIGIN_LOOKUP`]'s `items` filtered by the origin field — in aliased `o<n>` fields, each
2213    /// origin's issue search in a search slot of its own.
2214    ///
2215    /// What `task find` asks a board, every search a page at a time: one request a round,
2216    /// rather than one per search. A slot not asked is left out by `@include`, its page size
2217    /// bound to one; GitHub answers an error about one slot at that slot's path, and the other
2218    /// slots are answered beside it.
2219    pub const SEARCH_BATCH: &str = concat!(
2220        r#"query($owner:String!,$number:Int!,$type:SearchType!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2221        relationship_events_variable!(),
2222        search_batch_variables!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2223        origin_batch_variables!(0 1 2 3 4),
2224        "){",
2225        search_batch_slots!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2226        origin_batch_slots!(0 1 2 3 4),
2227        "\n    }",
2228        board_issue!()
2229    );
2230
2231    /// The comment slots `<n>` of [`ADD_COMMENTS`]: [`ADD_COMMENT`]'s mutation, aliased
2232    /// `comment<n>`, sent only while `$send<n>` holds.
2233    macro_rules! comment_slots {
2234        ($($n:literal)+) => {
2235            concat!($(
2236                " comment", stringify!($n), ":addComment(input:$comment", stringify!($n),
2237                ") @include(if:$send", stringify!($n), "){subject{id} commentEdge{node{",
2238                issue_comment!(), "}}}",
2239            )+)
2240        };
2241    }
2242
2243    /// Adds up to [`COMMENT_BATCH`](super::COMMENT_BATCH) comments in one request, each as
2244    /// [`ADD_COMMENT`] adds one, in order: what `task comment add-many` sends. GitHub runs each
2245    /// alias on its own and answers one it refused at its own path, beside the others.
2246    pub const ADD_COMMENTS: &str = concat!(
2247        "mutation(",
2248        wide_variables!("comment", "AddCommentInput", "send"; 0 1 2 3 4 5 6 7 8 9 10),
2249        "){",
2250        comment_slots!(0 1 2 3 4 5 6 7 8 9 10),
2251        "}"
2252    );
2253
2254    /// Many tasks' targeted updates, each as [`UPDATE_FIELDS`] and [`UPDATE_ISSUE`] write one:
2255    /// up to [`TASK_FIELD_BATCH`](super::TASK_FIELD_BATCH) board field values in `field<n>`,
2256    /// [`MUTATION_BATCH`](super::MUTATION_BATCH) field clears in `clear<n>`, and as many
2257    /// `updateIssue`s in `update<n>`, each sent only while its own `send…<n>` holds.
2258    ///
2259    /// What `task update-many` sends, in two requests at most: every item's field values and
2260    /// clears first, then every issue update of an item whose fields all landed — the order one
2261    /// item's update keeps, so a refusal part way leaves an item's body as it stood.
2262    ///
2263    /// Each `update<n>` selects the issue it updated as a read of it does, so its response spells
2264    /// the change token the task holds after its batch's writes — the version `update-many`
2265    /// answers — with no read after them. Its page sizes are bound by `$nestedFirst` and
2266    /// `$boardItems`, as every read's are.
2267    pub const WRITE_TASKS: &str = concat!(
2268        "mutation(",
2269        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2270        wide_variables!("clear", "ClearProjectV2ItemFieldValueInput", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2271        wide_variables!("update", "UpdateIssueInput", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2272        r#"$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2273        relationship_events_variable!(),
2274        "){",
2275        wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2276        wide_slots!("clear", "clearProjectV2ItemFieldValue", "projectV2Item{id}", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2277        wide_slots!("update", "updateIssue", "issue{id ...BoardIssue}", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2278        "}",
2279        board_issue!()
2280    );
2281
2282    /// What a mutation of a write the store's cache expects selects of the issue it wrote:
2283    /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
2284    /// change token's every part and the first page of its comments.
2285    ///
2286    /// Sent **only** while that write runs, in place of the document named beside each
2287    /// below, so every other write's documents, prices and request counts are what they
2288    /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
2289    /// mutation's response is stamped seconds after the change it made, so against GitHub
2290    /// itself a response rarely determines a version; the cache is then left unserveable
2291    /// for the task, and its next read refreshes it.
2292    macro_rules! whole_issue {
2293        () => {
2294            concat!(
2295                r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
2296                issue_comment!(),
2297                r#"}pageInfo{hasNextPage endCursor}}}"#
2298            )
2299        };
2300    }
2301
2302    /// The variables every document of `whole_issue!` declares beside its own.
2303    macro_rules! whole_variables {
2304        () => {
2305            concat!(
2306                r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2307                relationship_events_variable!()
2308            )
2309        };
2310    }
2311
2312    /// [`ADD_COMMENT`], selecting the whole issue commented on.
2313    pub const ADD_COMMENT_WHOLE: &str = concat!(
2314        r#"mutation($input:AddCommentInput!"#,
2315        whole_variables!(),
2316        r#"){addComment(input:$input){subject{id "#,
2317        whole_issue!(),
2318        r#"} commentEdge{node{"#,
2319        issue_comment!(),
2320        r#"}}}}"#,
2321        board_issue!()
2322    );
2323    /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
2324    pub const UPDATE_COMMENT_WHOLE: &str = concat!(
2325        r#"mutation($input:UpdateIssueCommentInput!"#,
2326        whole_variables!(),
2327        r#"){updateIssueComment(input:$input){issueComment{"#,
2328        issue_comment!(),
2329        r#" issue{"#,
2330        whole_issue!(),
2331        r#"}}}}"#,
2332        board_issue!()
2333    );
2334    /// [`UPDATE_ISSUE`], selecting the whole issue updated.
2335    pub const UPDATE_ISSUE_WHOLE: &str = concat!(
2336        r#"mutation($input:UpdateIssueInput!"#,
2337        whole_variables!(),
2338        r#"){updateIssue(input:$input){issue{id "#,
2339        whole_issue!(),
2340        r#"}}}"#,
2341        board_issue!()
2342    );
2343    /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
2344    pub const UPDATE_FIELD_WHOLE: &str = concat!(
2345        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2346        whole_variables!(),
2347        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2348        whole_issue!(),
2349        r#"}}}}"#,
2350        board_issue!()
2351    );
2352    /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
2353    pub const CLEAR_FIELD_WHOLE: &str = concat!(
2354        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2355        whole_variables!(),
2356        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2357        whole_issue!(),
2358        r#"}}}}"#,
2359        board_issue!()
2360    );
2361
2362    /// The variables every document selecting [`board_issue_fields!`] inline declares beside
2363    /// its own.
2364    macro_rules! token_variables {
2365        () => {
2366            concat!(
2367                r#",$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2368                relationship_events_variable!()
2369            )
2370        };
2371    }
2372
2373    /// [`UPDATE_ISSUE`], selecting what a read of the issue updated selects — every part of its
2374    /// change token, and no comment — inline, so its root fields are the plain document's.
2375    ///
2376    /// What a targeted update sends in the plain document's place, so the version it answers
2377    /// is the one its own last response spells (`GitHubProjectsSource::written_tokens`). It
2378    /// costs the one point the plain document does.
2379    pub const UPDATE_ISSUE_TOKEN: &str = concat!(
2380        r#"mutation($input:UpdateIssueInput!"#,
2381        token_variables!(),
2382        r#"){updateIssue(input:$input){issue{"#,
2383        board_issue_fields!(),
2384        r#"}}}"#
2385    );
2386    /// [`UPDATE_FIELD`], selecting the issue behind the board item written as
2387    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2388    pub const UPDATE_FIELD_TOKEN: &str = concat!(
2389        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2390        token_variables!(),
2391        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2392        board_issue_fields!(),
2393        r#"}}}}}"#
2394    );
2395    /// [`CLEAR_FIELD`], selecting the issue behind the board item cleared as
2396    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2397    pub const CLEAR_FIELD_TOKEN: &str = concat!(
2398        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2399        token_variables!(),
2400        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2401        board_issue_fields!(),
2402        r#"}}}}}"#
2403    );
2404
2405    /// The document a targeted update sends in `document`'s place, when it has one: the twin
2406    /// whose answer spells the change token of the issue written.
2407    #[must_use]
2408    pub fn token_twin(document: &str) -> Option<&'static str> {
2409        [
2410            (UPDATE_ISSUE, UPDATE_ISSUE_TOKEN),
2411            (UPDATE_FIELD, UPDATE_FIELD_TOKEN),
2412            (CLEAR_FIELD, CLEAR_FIELD_TOKEN),
2413        ]
2414        .into_iter()
2415        .find(|(plain, _)| *plain == document)
2416        .map(|(_, token)| token)
2417    }
2418
2419    /// The document sent in `document`'s place while a write the store's cache expects runs,
2420    /// when it has one.
2421    #[must_use]
2422    pub fn whole_twin(document: &str) -> Option<&'static str> {
2423        [
2424            (ADD_COMMENT, ADD_COMMENT_WHOLE),
2425            (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
2426            (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
2427            (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
2428            (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
2429        ]
2430        .into_iter()
2431        .find(|(plain, _)| *plain == document)
2432        .map(|(_, whole)| whole)
2433    }
2434
2435    /// Every document above, with what this source is doing when it sends one.
2436    ///
2437    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
2438    /// name the call that was refused, and a `match` with a catch-all arm would answer a
2439    /// document added later with "talking to GitHub" and never say so.
2440    ///
2441    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
2442    /// const` here that this list omits, so the two cannot part — which is the same guard
2443    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
2444    pub const DOCUMENTS: [(&str, &str); 63] = [
2445        (
2446            ISSUES_BY_CREATION,
2447            "listing a repository's issues newest first for a create key",
2448        ),
2449        (SEARCH_ISSUES, "searching this board's issues"),
2450        (ISSUE, "reading one issue"),
2451        (
2452            DOCUMENT_IN_PROJECT,
2453            "reading one document with its project and the project's members",
2454        ),
2455        (
2456            ISSUE_BOARD_ITEMS,
2457            "reading one issue's board memberships past the page it came with",
2458        ),
2459        (SUB_ISSUES, "reading a project's tasks"),
2460        (BOARD, "reading the board"),
2461        (
2462            BOARD_WITH_COMMENTS,
2463            "reading a board page with first comments",
2464        ),
2465        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
2466        (CLOSED_ITEMS, "reading the board's closed items"),
2467        (
2468            ISSUES_BY_REFERENCE,
2469            "reading a batch of issues by their references",
2470        ),
2471        (BOARD_FIELDS, "reading the board's fields"),
2472        (DRAFT, "reading one draft"),
2473        (REPOSITORY, "reading the destination repository"),
2474        (
2475            PROJECT_VISIBILITY,
2476            "reading whether the board's project is public",
2477        ),
2478        (
2479            CREATION_CONTEXT,
2480            "reading the board's fields and the destination repository",
2481        ),
2482        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
2483        (ISSUE_DEPENDS_ON, "reading what an item depends on"),
2484        (ISSUE_DEPENDED_ON_BY, "reading what depends on an issue"),
2485        (CREATE_ISSUE, "creating an issue"),
2486        (
2487            ORIGIN_LOOKUPS,
2488            "looking up the items copied from several origins, and what creating one reads",
2489        ),
2490        (
2491            ORIGIN_LOOKUPS_WITH_MEMBERS,
2492            "looking up the items copied from several origins, with a project's members",
2493        ),
2494        (CREATE_ISSUES, "creating several issues together"),
2495        (
2496            CREATED_BOARD_ITEMS,
2497            "reading the board items of the issues a copy created",
2498        ),
2499        (
2500            WRITE_FIELD_VALUES,
2501            "writing board fields of the items a copy created",
2502        ),
2503        (
2504            WRITE_FIELDS_AND_EDGES,
2505            "writing board fields and dependencies of the items a copy created",
2506        ),
2507        (CLOSE_ISSUES, "closing the issues a copy created"),
2508        (UPDATE_ISSUES, "updating several issues together"),
2509        (ADD_BLOCKED_BYS, "recording several dependencies together"),
2510        (ADD_TO_BOARD, "adding an issue to the board"),
2511        (UPDATE_ISSUE, "updating an issue"),
2512        (UPDATE_DRAFT, "updating a draft item"),
2513        (UPDATE_FIELD, "writing a board field"),
2514        (UPDATE_FIELDS, "writing board fields together"),
2515        (CLEAR_FIELD, "clearing a board field"),
2516        (
2517            CREATE_FIELD,
2518            "creating a board single-select field with its options",
2519        ),
2520        (
2521            STATUS_OPTIONS_SNAPSHOT,
2522            "snapshotting board Status options and assignments",
2523        ),
2524        (
2525            STATUS_OPTIONS_UPDATE,
2526            "safely replacing the board Status option list",
2527        ),
2528        (ADD_SUB_ISSUE, "filing an issue under its project"),
2529        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
2530        (ADD_BLOCKED_BY, "recording a dependency"),
2531        (REMOVE_BLOCKED_BY, "removing a dependency"),
2532        (DELETE_ISSUE, "deleting an issue"),
2533        (DELETE_ISSUES, "deleting several issues together"),
2534        (ISSUE_COMMENTS, "reading a task's comments"),
2535        (ISSUE_DETAIL, "reading one issue with its comments"),
2536        (
2537            ISSUE_DETAILS,
2538            "reading a batch of issues with their comments",
2539        ),
2540        (
2541            ISSUE_COPY_DETAILS,
2542            "reading a batch of issues with their comments and dependencies for a copy",
2543        ),
2544        (COMMENT_ISSUE, "reading which issue a comment is on"),
2545        (ADD_COMMENT, "adding a comment"),
2546        (UPDATE_COMMENT, "editing a comment"),
2547        (DELETE_COMMENT, "deleting a comment"),
2548        (
2549            SEARCH_BATCH,
2550            "searching this board's issues several ways at once",
2551        ),
2552        (ADD_COMMENTS, "adding several comments together"),
2553        (WRITE_TASKS, "updating several tasks together"),
2554        (
2555            ADD_COMMENT_WHOLE,
2556            "adding a comment, answered with the whole issue",
2557        ),
2558        (
2559            UPDATE_COMMENT_WHOLE,
2560            "editing a comment, answered with the whole issue",
2561        ),
2562        (
2563            UPDATE_ISSUE_WHOLE,
2564            "updating an issue, answered with it whole",
2565        ),
2566        (
2567            UPDATE_FIELD_WHOLE,
2568            "writing a board field, answered with the whole issue",
2569        ),
2570        (
2571            CLEAR_FIELD_WHOLE,
2572            "clearing a board field, answered with the whole issue",
2573        ),
2574        (
2575            UPDATE_ISSUE_TOKEN,
2576            "updating an issue, answered with its version",
2577        ),
2578        (
2579            UPDATE_FIELD_TOKEN,
2580            "writing a board field, answered with the issue's version",
2581        ),
2582        (
2583            CLEAR_FIELD_TOKEN,
2584            "clearing a board field, answered with the issue's version",
2585        ),
2586    ];
2587}
2588
2589/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
2590/// under, the variable carrying its input, and the boolean variable that includes it.
2591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2592pub struct FieldSlot {
2593    /// The key of the answer's `data` this field's payload is under.
2594    pub alias: &'static str,
2595    /// The variable carrying this field's input.
2596    pub variable: &'static str,
2597    /// The boolean variable whose `true` sends this field.
2598    pub include: &'static str,
2599}
2600
2601/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
2602pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
2603    FieldSlot {
2604        alias: "updateProjectV2ItemFieldValue",
2605        variable: "input",
2606        include: "writeFirst",
2607    },
2608    FieldSlot {
2609        alias: "second",
2610        variable: "second",
2611        include: "writeSecond",
2612    },
2613    FieldSlot {
2614        alias: "third",
2615        variable: "third",
2616        include: "writeThird",
2617    },
2618    FieldSlot {
2619        alias: "fourth",
2620        variable: "fourth",
2621        include: "writeFourth",
2622    },
2623    FieldSlot {
2624        alias: "fifth",
2625        variable: "fifth",
2626        include: "writeFifth",
2627    },
2628    FieldSlot {
2629        alias: "sixth",
2630        variable: "sixth",
2631        include: "writeSixth",
2632    },
2633];
2634
2635/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
2636pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
2637    FieldSlot {
2638        alias: "cleared",
2639        variable: "clear",
2640        include: "writeClear",
2641    },
2642    FieldSlot {
2643        alias: "clearedSecond",
2644        variable: "clearSecond",
2645        include: "writeClearSecond",
2646    },
2647    FieldSlot {
2648        alias: "clearedThird",
2649        variable: "clearThird",
2650        include: "writeClearThird",
2651    },
2652];
2653
2654/// Which of GitHub's two rate limiters refused a request.
2655///
2656/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
2657/// secondary one — so an operator told the wrong one takes the wrong next step, which is
2658/// the whole reason this is carried rather than collapsed into "rate limited".
2659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2660enum Limiter {
2661    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
2662    Primary,
2663    /// The burst limiter over content-generating requests, which nothing reports.
2664    Secondary,
2665}
2666
2667/// The wordings GitHub answers a secondary rate limit with.
2668///
2669/// It sends them under a forbidden status, under a too-many-requests status, and inside
2670/// the `errors` of a *successful* response, which is why the text is what this matches on
2671/// rather than the status. `abuse detection` is the wording GitHub used before the
2672/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
2673/// what a burst of content creation is refused with.
2674///
2675/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
2676/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
2677/// when, and the drift gate reconciles the two lists both ways. Public for that gate
2678/// alone — a caller has no use for it, and matching on a refusal is this source's job.
2679pub const SECONDARY_WORDINGS: [&str; 5] = [
2680    "secondary rate limit",
2681    "temporarily blocked from content creation",
2682    "abuse detection",
2683    "submitted too quickly",
2684    "exceeded a secondary",
2685];
2686
2687/// The wordings GitHub answers an exhausted primary budget with.
2688///
2689/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
2690/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
2691/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
2692/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
2693/// two phrases is a substring of it, so without it that answer read as a refusal that will
2694/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
2695/// one reason.
2696pub const PRIMARY_WORDINGS: [&str; 4] = [
2697    "api rate limit exceeded",
2698    "api rate limit already exceeded",
2699    "rate limit exceeded",
2700    "rate_limited",
2701];
2702
2703/// The wordings GitHub answers a write that collided with another change to the same
2704/// ordering with, in the `errors` of an HTTP 200: "Your attempt to move this item created a
2705/// temporary conflict. Please try again." The write did not take effect and GitHub asks for it
2706/// again, so it is retried on the same growing schedule a rate limit without a hint is, within
2707/// the same retry budget — except a `createIssue`, whose answer cannot say the issue was not
2708/// made, and which is therefore reported rather than replayed into a duplicate.
2709///
2710/// Observed rather than documented, so it is pinned exactly as [`SECONDARY_WORDINGS`] is:
2711/// `tests/fixtures/rate-limits.json` records where it was read under `conflict.observed`, and
2712/// the same drift gate reconciles the two both ways. Public for that gate alone.
2713pub const CONFLICT_WORDINGS: [&str; 1] = ["created a temporary conflict"];
2714
2715/// What a response *says about itself*, which is the only place a refusal can be read.
2716///
2717/// Deliberately not the whole response body. A board is a place people write about their
2718/// own work, and a task on it titled "the secondary rate limit" would, matched across the
2719/// raw text, turn a perfectly good answer into a refusal this source then waited out and
2720/// reported. So the item data is never read: what is read is GitHub's own REST-style
2721/// `message` envelope, which is what a forbidden status carries, and the `message` and
2722/// `type` of each GraphQL error, which is where a *successful* response says it.
2723///
2724/// A body that is not JSON at all has nothing structured to read, so only a failing
2725/// response's own text is taken — a successful response that is not JSON is malformed
2726/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
2727fn refusal_wording(status: StatusCode, body: &str) -> String {
2728    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
2729        return if status.is_success() {
2730            String::new()
2731        } else {
2732            body.to_owned()
2733        };
2734    };
2735    let mut said: Vec<&str> = parsed
2736        .get("message")
2737        .and_then(Value::as_str)
2738        .into_iter()
2739        .collect();
2740    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
2741        for error in errors {
2742            said.extend(
2743                ["message", "type"]
2744                    .into_iter()
2745                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
2746            );
2747        }
2748    }
2749    said.join("; ")
2750}
2751
2752impl Limiter {
2753    /// Which limiter refused this response, or `None` when none of them did.
2754    ///
2755    /// The wording is read first and the status only decides what carries none of it,
2756    /// because GitHub answers a secondary limit with a forbidden status far more often
2757    /// than with too-many-requests — while a forbidden status saying nothing about a limit
2758    /// really is a credential this token lacks.
2759    ///
2760    /// A response is a refusal because of its status or its own wording. A spent budget
2761    /// only ever explains one; it never turns an answer into a refusal.
2762    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
2763        let normalized = refusal_wording(status, body).to_ascii_lowercase();
2764        if SECONDARY_WORDINGS
2765            .iter()
2766            .any(|wording| normalized.contains(wording))
2767        {
2768            return Some(Self::Secondary);
2769        }
2770        if status == StatusCode::TOO_MANY_REQUESTS {
2771            return Some(Self::Primary);
2772        }
2773        // An exhausted budget *explains* a response that failed; it does not make one that
2774        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
2775        // request the budget allowed as well as on the ones it then refuses, so reading
2776        // the header alone threw away a good answer — and, once refusals were retried,
2777        // replayed a request that had already taken effect.
2778        if !status.is_success() && budget_exhausted {
2779            return Some(Self::Primary);
2780        }
2781        // A successful response saying it: GitHub reports a GraphQL rate limit in the
2782        // `errors` of an HTTP 200, where nothing about the status says so at all.
2783        if status.is_success()
2784            && PRIMARY_WORDINGS
2785                .iter()
2786                .any(|wording| normalized.contains(wording))
2787        {
2788            return Some(Self::Primary);
2789        }
2790        None
2791    }
2792
2793    /// What this limiter is called where an operator can look it up.
2794    const fn name(self) -> &'static str {
2795        match self {
2796            Self::Primary => "GitHub's primary API rate limit",
2797            Self::Secondary => "GitHub's secondary rate limit",
2798        }
2799    }
2800
2801    /// What the endpoint an operator would go and check says about this limiter.
2802    const fn where_to_look(self) -> &'static str {
2803        match self {
2804            Self::Primary => {
2805                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
2806                 comes back."
2807            }
2808            Self::Secondary => {
2809                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
2810                 primary budget and does not report this one, so budget showing there says \
2811                 nothing about this refusal, and every further attempt extends it."
2812            }
2813        }
2814    }
2815
2816    /// The next step this limiter actually calls for.
2817    const fn what_to_do(self) -> &'static str {
2818        match self {
2819            Self::Primary => {
2820                "wait for the reset `gh api rate_limit` reports, then run the command again."
2821            }
2822            Self::Secondary => {
2823                "leave this board alone for a few minutes, then run the command again — or \
2824                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
2825            }
2826        }
2827    }
2828}
2829
2830/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
2831#[derive(Debug, Clone, Copy)]
2832struct Limited {
2833    limiter: Limiter,
2834    hint: Option<u64>,
2835    /// Which header the hint came from: `retry-after`, or an exhausted primary budget's
2836    /// `x-ratelimit-reset`.
2837    hinted_by: Hint,
2838}
2839
2840/// Where a refusal's hint came from, which is what its wait is named after.
2841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2842enum Hint {
2843    /// `retry-after`, or no hint at all.
2844    RetryAfter,
2845    /// An exhausted primary budget's `x-ratelimit-reset`.
2846    PrimaryReset,
2847}
2848
2849impl Limited {
2850    /// The wait this refusal is answered with, when the backing-off schedule is at `backoff`,
2851    /// and the cause it is named by: the hint, raised to the schedule's own next wait —
2852    /// GitHub really does send `retry-after: 0`, and retrying at once is the one move that
2853    /// extends a secondary limit — or the schedule alone when there is none.
2854    fn wait(self, backoff: Duration) -> (Duration, WaitCause) {
2855        match (self.hint, self.hinted_by) {
2856            (Some(hint), Hint::RetryAfter) => (
2857                Duration::from_secs(hint).max(backoff),
2858                pacing::cause::RETRY_AFTER,
2859            ),
2860            (Some(hint), Hint::PrimaryReset) => (
2861                Duration::from_secs(hint).max(backoff),
2862                pacing::cause::PRIMARY_RESET,
2863            ),
2864            (None, _) => (backoff, pacing::cause::BACKOFF),
2865        }
2866    }
2867
2868    /// What the caller is told once this source has waited as long as it may.
2869    ///
2870    /// Both limiters report as [`SourceError::RateLimited`], because that is what
2871    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
2872    /// about *which* limiter it was makes it a different kind of failure. What differs is
2873    /// the operator's next step, and that is what the message carries — a secondary
2874    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
2875    /// budget looks fine, and then back to retry the very burst that was refused.
2876    fn exhausted(
2877        self,
2878        doing: &str,
2879        waits: u32,
2880        waited: Duration,
2881        needed: Duration,
2882        budget: Duration,
2883    ) -> SourceError {
2884        SourceError::RateLimited {
2885            retry_after_seconds: self.hint,
2886            message: Some(format!(
2887                "{} refused this source while {doing}; it waited {} out over {} and was refused \
2888                 again, and the next wait of {} would take it past the {} one call may spend \
2889                 waiting. {} next: {}",
2890                self.limiter.name(),
2891                plural(waits, "refusal"),
2892                seconds(waited),
2893                seconds(needed),
2894                seconds(budget),
2895                self.limiter.where_to_look(),
2896                self.limiter.what_to_do(),
2897            )),
2898        }
2899    }
2900}
2901
2902/// One HTTP attempt's result, with what its response said about the rate limit.
2903///
2904/// The two travel together so the record and the outcome are written from the same place:
2905/// what a response said about the budget is only readable while that response is in hand,
2906/// and what the attempt *meant* is only decidable once its body has been read.
2907struct Attempted {
2908    result: Result<Value, Attempt>,
2909    limits: accounting::RateLimit,
2910    /// GitHub's own reported cost for this call, for a document that asked for it.
2911    reported_cost: Option<u64>,
2912    response_outcome: accounting::ResponseOutcome,
2913}
2914
2915/// One attempt's outcome: an error to report, or a rate limit to wait out.
2916enum Attempt {
2917    Failed(SourceError),
2918    Limited(Limited),
2919    /// GitHub refused a write as a temporary conflict and asked for it again — see
2920    /// [`CONFLICT_WORDINGS`]. Carries what GitHub said, for the diagnostic once retrying ends.
2921    Conflicted(String),
2922}
2923
2924fn plural(count: u32, thing: &str) -> String {
2925    if count == 1 {
2926        format!("{count} {thing}")
2927    } else {
2928        format!("{count} {thing}s")
2929    }
2930}
2931
2932fn seconds(duration: Duration) -> String {
2933    format!("{:.1}s", duration.as_secs_f64())
2934}
2935
2936/// The member [`stamp_served`] writes onto every node of a response that says when it last
2937/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
2938const SERVED_AT: &str = "onetaskgraphServedAt";
2939
2940/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
2941/// node's time later knows the server's clock on the response that read it — which is what
2942/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
2943fn stamp_served(data: &mut Value, served: &str) {
2944    match data {
2945        Value::Object(members) => {
2946            if members.contains_key("updatedAt") {
2947                members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
2948            }
2949            for member in members.values_mut() {
2950                stamp_served(member, served);
2951            }
2952        }
2953        Value::Array(items) => {
2954            for item in items {
2955                stamp_served(item, served);
2956            }
2957        }
2958        _ => {}
2959    }
2960}
2961
2962/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
2963///
2964/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
2965/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
2966/// header, and neither is what makes a response a refusal — so the whole cost of one this
2967/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
2968/// instead. Refusing the response over the header would turn a readable refusal into an
2969/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
2970fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
2971    value
2972        .and_then(|value| value.to_str().ok())
2973        .and_then(|value| value.trim().parse::<u64>().ok())
2974}
2975
2976/// A transport failure with every cause beneath it, outermost first.
2977///
2978/// The client's own text for a request it could not complete is `error sending request for
2979/// url (…)`, and the socket error that says why — a refused or reset connection, an exhausted
2980/// port range, a connection closed before its answer — is only in the chain of sources below
2981/// it. Those differ in what to do next, so all of them are kept.
2982pub(crate) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String {
2983    let mut text = error.to_string();
2984    let mut cause = error.source();
2985    while let Some(next) = cause {
2986        let said = next.to_string();
2987        if !text.ends_with(&said) {
2988            text = format!("{text}: {said}");
2989        }
2990        cause = next.source();
2991    }
2992    text
2993}
2994
2995/// Every mutation this source sends creates content — an issue, a board item, a field of
2996/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
2997/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
2998/// and what the keyword says are the same set. That is what makes the keyword a sound test
2999/// rather than a convenient one: counting an edit or a removal the limiter might not have
3000/// counted costs a wait, and not counting one it did count costs the next fifty minutes.
3001pub(crate) fn is_mutation(query: &str) -> bool {
3002    query.trim_start().starts_with("mutation")
3003}
3004
3005/// What this source was doing, for a diagnostic that has to say so.
3006///
3007/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
3008/// a document added without a description is caught by that list's own gate instead of
3009/// falling through to the vague arm below.
3010fn operation_description(query: &str) -> &'static str {
3011    graphql::DOCUMENTS
3012        .iter()
3013        .find(|(document, _)| *document == query)
3014        .map_or("talking to GitHub", |(_, doing)| *doing)
3015}
3016
3017/// GitHub's published ceiling on content-generating requests in any one minute.
3018///
3019/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
3020/// GitHub's number rather than this source's. This source paces at it over its window: a
3021/// content-creating request goes out at once while the minute's creations leave room for it,
3022/// and waits until they do otherwise — see the `pacing` module.
3023pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
3024/// The same ceiling as GitHub publishes it for any one hour, paced over its window as the
3025/// per-minute one is. Pinned beside its sibling so the gate would notice either one moving.
3026pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
3027/// GitHub's published ceiling on secondary points in any one minute: what every GraphQL
3028/// request is charged against, a mutation [`SECONDARY_POINTS_PER_MUTATION`] and a query
3029/// [`SECONDARY_POINTS_PER_QUERY`]. Pinned and paced as [`CONTENT_CREATION_PER_MINUTE`] is.
3030pub const SECONDARY_POINTS_PER_MINUTE: u64 = 2_000;
3031/// The secondary points one GraphQL request carrying a mutation is charged.
3032pub const SECONDARY_POINTS_PER_MUTATION: u64 = 5;
3033/// The secondary points one GraphQL request carrying no mutation is charged.
3034pub const SECONDARY_POINTS_PER_QUERY: u64 = 1;
3035/// The fastest *steady* spacing of content-creating mutations that cannot exceed
3036/// [`CONTENT_CREATION_PER_MINUTE`], in milliseconds: 60000/80 is 750.
3037///
3038/// It is no longer the default. This source paces by GitHub's windows instead, so a copy's
3039/// few dozen writes go out as fast as GitHub answers them while the minute has room, rather
3040/// than 750 ms apart — which was most of a plan copy's wall time. An installation that wants
3041/// mutations spaced as well says so through `pacing.min_mutation_interval_ms`, and this is the
3042/// value that holds a long sustained burst under the per-minute bound by spacing alone.
3043pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
3044/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
3045///
3046/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
3047/// own advice for a secondary limit — wait, and wait longer each time — without spending
3048/// the first minute of a transient refusal doing nothing.
3049pub const RETRY_BACKOFF_MS: u64 = 1_000;
3050/// Total time one call may spend waiting out rate limits before it reports a failure.
3051///
3052/// Two minutes is long enough to ride out the refusals a paced copy still collects and
3053/// short enough that a command an operator is watching returns. The bound is what makes
3054/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
3055/// the limiter, not in a process nobody can tell from a wedged one.
3056pub const RETRY_BUDGET_MS: u64 = 120_000;
3057
3058fn default_token_env() -> String {
3059    "GH_PROJECTS_TOKEN".to_owned()
3060}
3061fn default_endpoint() -> String {
3062    "https://api.github.com/graphql".to_owned()
3063}
3064
3065/// The name of a `Status` single-select option on the board.
3066///
3067/// Validated on the way in rather than checked later, so a blank option name — which
3068/// nothing on a board can be — is a state this type cannot hold.
3069#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3070#[serde(try_from = "String")]
3071#[schemars(extend("minLength" = 1))]
3072pub struct ColumnName(String);
3073
3074impl ColumnName {
3075    /// The option name, as the board spells it.
3076    fn as_str(&self) -> &str {
3077        &self.0
3078    }
3079}
3080
3081impl TryFrom<String> for ColumnName {
3082    type Error = String;
3083
3084    fn try_from(name: String) -> Result<Self, Self::Error> {
3085        if name.trim().is_empty() {
3086            return Err("a status_mapping option name cannot be blank".to_owned());
3087        }
3088        Ok(Self(name))
3089    }
3090}
3091
3092/// The two closed states this product can mean.
3093///
3094/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
3095/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
3096/// product never decides on a person's behalf that two issues are the same work.
3097#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
3098#[serde(rename_all = "kebab-case")]
3099pub enum ClosedState {
3100    /// `COMPLETED` — precisely done.
3101    Completed,
3102    /// `NOT_PLANNED` — precisely cancelled.
3103    NotPlanned,
3104}
3105
3106impl ClosedState {
3107    const fn reason(self) -> &'static str {
3108        match self {
3109            Self::Completed => "COMPLETED",
3110            Self::NotPlanned => "NOT_PLANNED",
3111        }
3112    }
3113}
3114
3115/// Configuration for one GitHub Projects v2 board.
3116///
3117/// It serializes to a document this same type reads back as itself, so a caller writing a
3118/// configuration — a consumer filing onto a board it configures — can build one here rather
3119/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
3120#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3121#[serde(default, deny_unknown_fields)]
3122pub struct GitHubProjectsConfig {
3123    /// Login of the user or organization which owns the board.
3124    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
3125    /// The project number shown in the board's GitHub URL.
3126    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
3127    // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This doc is the field's schema description, which is what a person configuring the source reads, so it has to say when the field decides an issue's repository and when the item's own field does; the rule's one executable source is `GitHubProjectsSource::creation_target`, and `tests/plugin.rs` drives each case named here against the loopback board.
3128    /// `owner/name` of the repository this source creates an issue in when the item's own
3129    /// `repositories` field does not decide it.
3130    ///
3131    /// An item naming exactly one repository is created there — on a source declared
3132    /// `visibility: private`, only when that repository reads private, and otherwise as an
3133    /// item naming none is; a task or a document naming none or several is created in its
3134    /// parent project's repository; and a project, or a task or document with no parent,
3135    /// naming none or several is created here. A board
3136    /// has no repository of its own and `createIssue` requires one, so a write without
3137    /// this is refused naming the field. Reads never need it.
3138    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
3139    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
3140    /// GitHub logins whose repositories this source may create an issue in.
3141    ///
3142    /// Absent or empty, the default, restricts nothing. Otherwise a create whose issue the
3143    /// rule `repository` describes would file in a repository owned by anyone else is refused
3144    /// before anything of it is written — by `task copy`, `task copy --create`, a member
3145    /// create of a project copy and a dry run of each — with the error kind `creation-owner`,
3146    /// naming the repository and these owners. Logins compare case-insensitively, as GitHub
3147    /// compares them. An update of an issue that already exists is never refused by it.
3148    /// `task locate` reports such a refusal as `creates_in.refused` rather than failing.
3149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3150    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3151    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3152    #[schemars(!skip_serializing_if)]
3153    pub creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates each entry against GitHub's login grammar, naming the one it refuses.
3154    /// Environment variable containing a fine-grained token with Projects and Issues
3155    /// read/write plus Pull requests read-only access for every repository represented on
3156    /// the board.
3157    #[serde(default = "default_token_env")]
3158    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
3159    /// GraphQL endpoint. GitHub Enterprise installations may override it.
3160    #[serde(default = "default_endpoint")]
3161    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
3162    /// Per-instance mapping from a status category to the option of the board's one
3163    /// `Status` field it lands on, for a task and for a project.
3164    ///
3165    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
3166    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
3167    /// where a kind left out leaves the category unmapped for that kind. A category this
3168    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
3169    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
3170    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
3171    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
3172    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
3173    /// either kind. No two categories may name one option for the same kind, ignoring case.
3174    /// `unknown` may name one existing option; every unknown word then lands on it and
3175    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
3176    /// each unknown word because it never creates board options.
3177    #[serde(default)]
3178    pub status_mapping: StatusMapping,
3179    /// Per-instance mapping from a task's priority to an option of this board's
3180    /// single-select field named `Priority`.
3181    ///
3182    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
3183    /// other priority is refused before it reaches this board. Present, each of `urgent`,
3184    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
3185    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
3186    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
3187    /// no two levels may name one option. Reads and writes never create the field or an
3188    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
3189    /// the board lacks is refused pointing there.
3190    #[serde(default)]
3191    pub priority_mapping: Option<PriorityMappingConfig>,
3192    /// Caller metadata values this source also writes to board text fields, so the board can
3193    /// filter on them.
3194    ///
3195    /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
3196    /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
3197    /// value. On every item write — create, update and copy, of a task, a project or a
3198    /// document — a string found there is written to the field, and nothing is sent when the
3199    /// field already holds it; nothing there, or `null`, clears the field when it holds a
3200    /// value; any other JSON type is refused before any mutation. The metadata comment in the
3201    /// issue body stays the one home of the value, and every read reports metadata from it.
3202    /// Reads and writes never create the field: `onetaskgraph sources fields <source>
3203    /// --apply` does, as a text field, and a write to a board lacking it, or holding a
3204    /// non-text field of that name, is refused pointing there.
3205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3206    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3207    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3208    #[schemars(!skip_serializing_if)]
3209    pub metadata_fields: Vec<MetadataFieldConfig>,
3210    /// How fast this source writes, and how long it waits out a rate-limit refusal.
3211    ///
3212    /// Every field keeps its shipped default when it is absent, and the defaults are
3213    /// GitHub's own published limits rather than taste. See [`Pacing`].
3214    #[serde(default)]
3215    pub pacing: PacingConfig,
3216}
3217
3218/// Which option of the board's `Priority` field each priority lands on.
3219///
3220/// One member per level rather than a map, so a key that is not a level is refused where
3221/// the configuration is read, naming the levels there are. `none` is not a member: it is no
3222/// value in the field, not an option of it.
3223#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3224#[serde(default, deny_unknown_fields)]
3225pub struct PriorityMappingConfig {
3226    /// The option `urgent` lands on; `Urgent` when absent.
3227    pub urgent: Option<PriorityOptionName>,
3228    /// The option `high` lands on; `High` when absent.
3229    pub high: Option<PriorityOptionName>,
3230    /// The option `medium` lands on; `Medium` when absent.
3231    pub medium: Option<PriorityOptionName>,
3232    /// The option `low` lands on; `Low` when absent.
3233    pub low: Option<PriorityOptionName>,
3234}
3235
3236/// The name of an option of the board's `Priority` single-select field.
3237///
3238/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
3239/// blank name.
3240#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3241#[serde(try_from = "String")]
3242#[schemars(extend("minLength" = 1))]
3243pub struct PriorityOptionName(String);
3244
3245impl PriorityOptionName {
3246    /// The option name, as the board spells it.
3247    fn as_str(&self) -> &str {
3248        &self.0
3249    }
3250}
3251
3252impl TryFrom<String> for PriorityOptionName {
3253    type Error = String;
3254
3255    fn try_from(name: String) -> Result<Self, Self::Error> {
3256        if name.trim().is_empty() {
3257            return Err("a priority_mapping option name cannot be blank".to_owned());
3258        }
3259        Ok(Self(name))
3260    }
3261}
3262
3263/// The name of the board field a priority is held in.
3264pub const PRIORITY_FIELD: &str = "Priority";
3265
3266/// The four priorities a board option can hold, in the order a new `Priority` field lists
3267/// them. `none` is not among them: it is the field holding no value.
3268///
3269/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
3270/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
3271/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
3272/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
3273pub const PRIORITY_LEVELS: [Priority; 4] = [
3274    Priority::Urgent,
3275    Priority::High,
3276    Priority::Medium,
3277    Priority::Low,
3278];
3279
3280/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
3281/// see that list for what this pins.
3282#[must_use]
3283pub const fn level_position(priority: Priority) -> Option<usize> {
3284    match priority {
3285        Priority::None => None,
3286        Priority::Urgent => Some(0),
3287        Priority::High => Some(1),
3288        Priority::Medium => Some(2),
3289        Priority::Low => Some(3),
3290    }
3291}
3292
3293/// This instance's complete priority-to-option mapping, read in both directions.
3294///
3295/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
3296/// two levels name one option.
3297#[derive(Debug, Clone)]
3298struct PriorityMapping {
3299    options: [PriorityOptionName; 4],
3300}
3301
3302impl PriorityMapping {
3303    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3304        let shipped = |name: &str| PriorityOptionName(name.to_owned());
3305        let mapping = Self {
3306            options: [
3307                config.urgent.unwrap_or_else(|| shipped("Urgent")),
3308                config.high.unwrap_or_else(|| shipped("High")),
3309                config.medium.unwrap_or_else(|| shipped("Medium")),
3310                config.low.unwrap_or_else(|| shipped("Low")),
3311            ],
3312        };
3313        for (index, option) in mapping.options.iter().enumerate() {
3314            if let Some(earlier) = mapping.options[..index]
3315                .iter()
3316                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
3317            {
3318                return Err(SourceError::Config {
3319                    message: format!(
3320                        "priority_mapping of source {instance} sends both {} and {} to the board \
3321                         option {:?}; one option cannot read back as two priorities",
3322                        PRIORITY_LEVELS[earlier],
3323                        PRIORITY_LEVELS[index],
3324                        option.as_str()
3325                    ),
3326                });
3327            }
3328        }
3329        Ok(mapping)
3330    }
3331
3332    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
3333    fn option(&self, priority: Priority) -> Option<&str> {
3334        level_position(priority).map(|index| self.options[index].as_str())
3335    }
3336
3337    /// The priority a board option name reports, or `None` when nothing maps to it.
3338    fn priority_of(&self, option: &str) -> Option<Priority> {
3339        self.options
3340            .iter()
3341            .position(|name| name.as_str().eq_ignore_ascii_case(option))
3342            .map(|index| PRIORITY_LEVELS[index])
3343    }
3344
3345    /// Every mapped option name, in the order a new `Priority` field lists them.
3346    fn names(&self) -> impl Iterator<Item = &str> {
3347        self.options.iter().map(PriorityOptionName::as_str)
3348    }
3349}
3350
3351/// One metadata value this source projects onto a board text field.
3352#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3353#[serde(deny_unknown_fields)]
3354pub struct MetadataFieldConfig {
3355    /// The name of the board text field the value is written to.
3356    pub field: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `MetadataField::resolve` refuses a blank, reserved, GitHub-owned or repeated name before the private validated entry is built.
3357    /// The top-level metadata key the value is read from. A key under the reserved
3358    /// `onetaskgraph.` prefix is refused.
3359    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `MetadataField::resolve` refuses a blank or reserved key before the private validated entry is built.
3360    /// Object keys walked inside that key's value, outermost first. Absent or empty, the
3361    /// key's own value is the one projected. Left out of a serialized entry when empty.
3362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3363    // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
3364    #[schemars(!skip_serializing_if)]
3365    pub path: Vec<String>,
3366}
3367
3368/// The board fields no metadata value may be projected onto, compared ignoring case: the
3369/// three this source writes itself, and those GitHub owns on every board.
3370// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] This list is the configuration contract `metadata_fields`' consumers agreed, refused at read so a mistaken entry is named early — not a mirror GitHub's field set has to stay in step with. What keeps a write off a field GitHub owns is the write's own check, `projection_field`, which refuses any field whose `dataType` is not `TEXT`: every built-in board field reports its own type (TITLE, ASSIGNEES, LABELS, …), and the live contract introspection holds GitHub to `ProjectV2Field.dataType`. So a built-in field GitHub adds later and this list lacks is still never written.
3371const UNPROJECTABLE_FIELDS: [&str; 13] = [
3372    STATUS_FIELD,
3373    PRIORITY_FIELD,
3374    ORIGIN_FIELD,
3375    "Title",
3376    "Assignees",
3377    "Labels",
3378    "Linked pull requests",
3379    "Milestone",
3380    "Repository",
3381    "Reviewers",
3382    "Parent issue",
3383    "Sub-issues progress",
3384    "Type",
3385];
3386
3387/// One validated [`MetadataFieldConfig`] entry.
3388#[derive(Debug, Clone)]
3389struct MetadataField {
3390    field: ProjectedField,
3391    key: ProjectedKey,
3392    path: Vec<String>,
3393}
3394
3395/// The name of a board text field a metadata value is projected onto: never blank, never one
3396/// this source or GitHub writes, and never one another entry of the same source names.
3397#[derive(Debug, Clone, PartialEq, Eq)]
3398struct ProjectedField(String);
3399
3400/// The top-level metadata key a projected value is read from: never blank, and never under
3401/// the namespace this product reserves.
3402#[derive(Debug, Clone, PartialEq, Eq)]
3403struct ProjectedKey(String);
3404
3405impl std::ops::Deref for ProjectedField {
3406    type Target = str;
3407    fn deref(&self) -> &str {
3408        &self.0
3409    }
3410}
3411
3412impl std::ops::Deref for ProjectedKey {
3413    type Target = str;
3414    fn deref(&self) -> &str {
3415        &self.0
3416    }
3417}
3418
3419/// What one item's metadata says one projected field should hold.
3420#[derive(Debug, Clone, PartialEq, Eq)]
3421enum Projected {
3422    /// A string to hold.
3423    Text(String),
3424    /// Nothing: the key or a step of the path is absent, or the value is `null`.
3425    Nothing,
3426}
3427
3428impl MetadataField {
3429    /// Validate every entry of one instance's `metadata_fields`, refusing the first that
3430    /// cannot stand, naming the source and the entry.
3431    fn resolve(
3432        configured: Vec<MetadataFieldConfig>,
3433        instance: &SourceName,
3434    ) -> Result<Vec<Self>, SourceError> {
3435        let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
3436        for (index, entry) in configured.into_iter().enumerate() {
3437            let refuse = |why: String| SourceError::Config {
3438                message: format!(
3439                    "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
3440                    entry.field, entry.key
3441                ),
3442            };
3443            if entry.field.trim().is_empty() {
3444                return Err(refuse("names a blank field".to_owned()));
3445            }
3446            if entry.key.trim().is_empty() {
3447                return Err(refuse("names a blank key".to_owned()));
3448            }
3449            if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
3450                return Err(refuse(format!(
3451                    "names a key under the reserved \"{}.\" prefix, which this product keeps \
3452                     for its own keys; next: project a caller key instead",
3453                    MetadataKey::RESERVED_NAMESPACE
3454                )));
3455            }
3456            if let Some(owned) = UNPROJECTABLE_FIELDS
3457                .iter()
3458                .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
3459            {
3460                return Err(refuse(format!(
3461                    "names the board field {owned:?}, which this source or GitHub already \
3462                     writes; next: name a text field of your own"
3463                )));
3464            }
3465            if let Some(earlier) = resolved
3466                .iter()
3467                .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
3468            {
3469                return Err(refuse(format!(
3470                    "names the same board field as metadata_fields[{earlier}]; one field \
3471                     cannot hold two values"
3472                )));
3473            }
3474            resolved.push(Self {
3475                field: ProjectedField(entry.field),
3476                key: ProjectedKey(entry.key),
3477                path: entry.path,
3478            });
3479        }
3480        Ok(resolved)
3481    }
3482
3483    /// The path as a refusal spells it: `[]` for the key's own value.
3484    fn spelled_path(&self) -> String {
3485        serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
3486    }
3487
3488    /// What `metadata` says this field should hold, or the refusal for a value no text field
3489    /// can hold.
3490    fn projected(
3491        &self,
3492        metadata: &BTreeMap<String, Value>,
3493        instance: &SourceName,
3494    ) -> Result<Projected, SourceError> {
3495        let mut value = metadata.get(&*self.key);
3496        for step in &self.path {
3497            value = value.and_then(|held| held.get(step.as_str()));
3498        }
3499        let found = match value {
3500            None | Some(Value::Null) => return Ok(Projected::Nothing),
3501            Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
3502            Some(Value::Bool(_)) => "a boolean",
3503            Some(Value::Number(_)) => "a number",
3504            Some(Value::Array(_)) => "an array",
3505            Some(Value::Object(_)) => "an object",
3506        };
3507        Err(SourceError::Refused {
3508            message: format!(
3509                "source {instance} projects metadata key {:?} at path {} onto its board text \
3510                 field {:?}, and this item holds {found} there; only a string, null or nothing \
3511                 can be written to a text field; next: store a string there, or remove the \
3512                 value",
3513                &*self.key,
3514                self.spelled_path(),
3515                &*self.field
3516            ),
3517        })
3518    }
3519}
3520
3521/// What one item's `Priority` field says, read through this instance's mapping.
3522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3523enum HeldPriority {
3524    /// A priority this source reports: an option the mapping names, or no value (`none`).
3525    Read(Priority),
3526    /// An option the mapping does not name, which is never read as a level or as `none`.
3527    Unmapped(String),
3528}
3529
3530/// How fast this source writes, and how long it waits out a rate-limit refusal.
3531///
3532/// Configurable because a GitHub Enterprise installation sets its own limits and an
3533/// operator who has already been refused may want to go slower still — not because the
3534/// defaults are guesses.
3535#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3536#[serde(default, deny_unknown_fields)]
3537pub struct PacingConfig {
3538    /// Shortest interval between two content-creating mutations, in milliseconds.
3539    ///
3540    /// Zero sends them as fast as they are asked for, which is what a fixture server on
3541    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
3542    pub min_mutation_interval_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
3543    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
3544    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
3545    /// zero while there is a budget to spend, because a schedule of zero-length waits
3546    /// consumes none of it and so never ends.
3547    pub retry_backoff_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` refuses a non-progressing zero and bounds the rest before the private validated `Pacing` is built.
3548    /// Total time one call may spend waiting out rate limits, in milliseconds.
3549    ///
3550    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
3551    /// the bound is what makes this a wait rather than a hang.
3552    pub retry_budget_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
3553}
3554
3555/// The largest any pacing setting may be, in milliseconds.
3556///
3557/// One hour. GitHub's own harshest published bound on content-generating requests works
3558/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
3559/// anything a real limit asks for, and past it the settings stop describing pacing at all:
3560/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
3561/// and an interval beyond it is a command that never sends its second mutation. It also
3562/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
3563/// what a `Duration` can hold on every platform.
3564pub const MAX_PACING_MS: u64 = 3_600_000;
3565
3566/// [`PacingConfig`] with every default resolved and every value checked, which is what the
3567/// source holds.
3568///
3569/// Unset, `min_mutation_interval_ms` spaces nothing, and every request is paced by GitHub's
3570/// secondary windows alone — see the `pacing` module; set, it spaces content-creating mutations
3571/// on top of those windows, [`MIN_MUTATION_INTERVAL_MS`] being the spacing that holds a sustained
3572/// burst under the per-minute bound by spacing alone; and zero sends every request as fast as it
3573/// is asked for, windows included.
3574#[derive(Debug, Clone, Copy)]
3575struct Pacing {
3576    /// Whether requests are held for GitHub's secondary windows: always, unless
3577    /// `min_mutation_interval_ms` is set to exactly zero, which has always asked for no
3578    /// pacing at all — what a fixture server on loopback wants and no board on github.com does.
3579    windows: bool,
3580    min_mutation_interval: Duration,
3581    retry_backoff: Duration,
3582    retry_budget: Duration,
3583}
3584
3585impl Pacing {
3586    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
3587    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3588        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
3589            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
3590                message: format!(
3591                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
3592                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
3593                     GitHub's own harshest published limit"
3594                ),
3595            }),
3596            Some(value) => Ok(Duration::from_millis(value)),
3597            None => Ok(Duration::from_millis(default)),
3598        };
3599        let retry_backoff = bounded(
3600            config.retry_backoff_ms,
3601            RETRY_BACKOFF_MS,
3602            "retry_backoff_ms",
3603        )?;
3604        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
3605        if retry_backoff.is_zero() && !retry_budget.is_zero() {
3606            return Err(SourceError::Config {
3607                message: format!(
3608                    "pacing.retry_backoff_ms of source {instance} is 0 while \
3609                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
3610                     none of that budget, so it would retry a refusal forever. Set a backoff of \
3611                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
3612                     waiting at all",
3613                    retry_budget.as_millis()
3614                ),
3615            });
3616        }
3617        Ok(Self {
3618            windows: config.min_mutation_interval_ms != Some(0),
3619            min_mutation_interval: bounded(
3620                config.min_mutation_interval_ms,
3621                0,
3622                "min_mutation_interval_ms",
3623            )?,
3624            retry_backoff,
3625            retry_budget,
3626        })
3627    }
3628}
3629
3630/// Factory for [`GitHubProjectsSource`].
3631#[derive(Debug, Clone, Copy, Default)]
3632pub struct Plugin;
3633
3634impl SourcePlugin for Plugin {
3635    fn kind(&self) -> &'static str {
3636        KIND
3637    }
3638    /// The endpoint, the owner and the board's number: what names one board.
3639    fn remote_identity(&self, config: &Value) -> Option<String> {
3640        let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
3641        Some(format!(
3642            "{}\n{}\n{}",
3643            config.endpoint, config.owner, config.project_number
3644        ))
3645    }
3646    fn config_schema(&self) -> Schema {
3647        schema_for!(GitHubProjectsConfig)
3648    }
3649    fn build(
3650        &self,
3651        name: &SourceName,
3652        config: &Value,
3653        secrets: &dyn SecretResolver,
3654    ) -> Result<Box<dyn TaskSource>, SourceError> {
3655        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
3656    }
3657
3658    fn build_with_clock(
3659        &self,
3660        name: &SourceName,
3661        config: &Value,
3662        secrets: &dyn SecretResolver,
3663        clock: SharedClock,
3664    ) -> Result<Box<dyn TaskSource>, SourceError> {
3665        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
3666    }
3667}
3668
3669/// The client `cell` holds, building it with `build` the first time it is asked for.
3670///
3671/// A failed build is the request's failure, with the message the source's construction gave it
3672/// before clients were deferred.
3673// llmlint: ignore[changed_behavior_has_e2e] A client build fails only when the TLS backend cannot initialise, which nothing outside the process can induce: a certificate bundle of garbage still builds one. That the build is deferred is proven end to end by `store_startup::a_command_builds_no_client_for_a_source_it_sends_nothing`.
3674fn built(
3675    cell: &std::sync::OnceLock<Client>,
3676    build: impl FnOnce() -> Result<Client, SourceError>,
3677) -> Result<&Client, SourceError> {
3678    if let Some(client) = cell.get() {
3679        return Ok(client);
3680    }
3681    let client = build()?;
3682    Ok(cell.get_or_init(|| client))
3683}
3684
3685impl Plugin {
3686    /// Build a source recording every request it sends into an accounting the caller holds.
3687    ///
3688    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
3689    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
3690    /// session total rather than two — see [`accounting`] and
3691    /// [`GitHubProjectsSource::recording_into`].
3692    ///
3693    /// # Errors
3694    ///
3695    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
3696    /// [`SourceError::Config`] for configuration this plugin cannot use and
3697    /// [`SourceError::Auth`] for a credential it cannot find.
3698    pub fn build_recording_into(
3699        &self,
3700        name: &SourceName,
3701        config: &Value,
3702        secrets: &dyn SecretResolver,
3703        ledger: Arc<Accounting>,
3704    ) -> Result<Box<dyn TaskSource>, SourceError> {
3705        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
3706    }
3707
3708    fn build_recording_with_clock(
3709        &self,
3710        name: &SourceName,
3711        config: &Value,
3712        secrets: &dyn SecretResolver,
3713        ledger: Arc<Accounting>,
3714        clock: SharedClock,
3715    ) -> Result<Box<dyn TaskSource>, SourceError> {
3716        let config: GitHubProjectsConfig =
3717            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
3718                message: format!("source {name}: {e}"),
3719            })?;
3720        let prefix = format!("source {name}: ");
3721        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
3722            .map_err(|error| match error {
3723                // The shared `StatusMapping::distinct` names the source itself.
3724                SourceError::Config { message } if message.starts_with(&prefix) => {
3725                    SourceError::Config { message }
3726                }
3727                SourceError::Config { message } => SourceError::Config {
3728                    message: format!("{prefix}{message}"),
3729                },
3730                SourceError::Auth { message } => SourceError::Auth {
3731                    message: format!("source {name}: {message}"),
3732                },
3733                other => other,
3734            })?;
3735        source.clock = clock;
3736        Ok(Box::new(source))
3737    }
3738}
3739
3740/// Where a status category lands on this board, once configuration is resolved.
3741#[derive(Debug, Clone, PartialEq, Eq)]
3742enum StatusTarget {
3743    /// Not usable against this instance for this kind, and why.
3744    Disabled(UnmappedStatus),
3745    /// The board's `Status` option of this name.
3746    Column(ColumnName),
3747    /// A closed issue, with both its board option and the reason that says which closed it means.
3748    // llmlint: ignore[invalid_states_unrepresentable] The reason is fixed by the category — `done` closes as completed, `cancelled` as not planned — and this private enum is built in one place, `BoardStatuses::resolve`, which pairs each from the category's own slot. Carrying the reason on the target is what lets every write site that holds only a target derive its `stateInput` from that one resolved model rather than re-deriving it from a category and risking a disagreement with the mapping.
3749    Terminal(ColumnName, ClosedState),
3750}
3751
3752/// Every status category, in the order the vocabulary declares them.
3753///
3754/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
3755/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
3756/// added to the shared vocabulary fails to compile until it is named there, and this
3757/// crate's suite reconciles this list against that enum's own derived schema, which is
3758/// generated from the variants rather than written beside them. The schema is what
3759/// catches a list left one short — a list checking only the positions it already holds
3760/// would pass while every mapping indexed by the new position panicked.
3761pub const CATEGORIES: [StatusCategory; 8] = [
3762    StatusCategory::Draft,
3763    StatusCategory::Backlog,
3764    StatusCategory::Todo,
3765    StatusCategory::Queued,
3766    StatusCategory::InProgress,
3767    StatusCategory::Done,
3768    StatusCategory::Cancelled,
3769    StatusCategory::Unknown,
3770];
3771
3772/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
3773#[must_use]
3774pub const fn category_position(category: StatusCategory) -> usize {
3775    match category {
3776        StatusCategory::Draft => 0,
3777        StatusCategory::Backlog => 1,
3778        StatusCategory::Todo => 2,
3779        StatusCategory::Queued => 3,
3780        StatusCategory::InProgress => 4,
3781        StatusCategory::Done => 5,
3782        StatusCategory::Cancelled => 6,
3783        StatusCategory::Unknown => 7,
3784    }
3785}
3786
3787/// The spelling a status category is configured and reported under.
3788fn category_name(category: StatusCategory) -> &'static str {
3789    match category {
3790        StatusCategory::Draft => "draft",
3791        StatusCategory::Backlog => "backlog",
3792        StatusCategory::Todo => "todo",
3793        StatusCategory::Queued => "queued",
3794        StatusCategory::InProgress => "in-progress",
3795        StatusCategory::Done => "done",
3796        StatusCategory::Cancelled => "cancelled",
3797        StatusCategory::Unknown => "unknown",
3798    }
3799}
3800
3801/// A shipped default's option name.
3802///
3803/// The literals below are this file's own and non-blank, and they are validated by the
3804/// one constructor a configured name goes through rather than beside it.
3805fn shipped_column(name: &'static str) -> ColumnName {
3806    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
3807}
3808
3809/// The shipped default for one category this instance's `status_mapping` does not mention,
3810/// for either kind.
3811fn shipped_default(category: StatusCategory) -> StatusTarget {
3812    match category {
3813        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
3814        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
3815        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
3816        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
3817        StatusCategory::Done => {
3818            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
3819        }
3820        StatusCategory::Cancelled => {
3821            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
3822        }
3823        StatusCategory::Draft | StatusCategory::Unknown => {
3824            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
3825        }
3826    }
3827}
3828
3829/// The two kinds a status is written and read for, each with its own half of the mapping.
3830const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
3831
3832/// This instance's complete category-to-target mapping for each kind, read in both
3833/// directions.
3834///
3835/// One target per category per kind, held at that category's own [`category_position`], so
3836/// a category missing from the mapping, named twice in it, or filed out of order is a state
3837/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
3838/// targets are options of the board's one `Status` field.
3839#[derive(Debug, Clone)]
3840struct BoardStatuses {
3841    tasks: [StatusTarget; CATEGORIES.len()],
3842    projects: [StatusTarget; CATEGORIES.len()],
3843}
3844
3845impl BoardStatuses {
3846    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
3847    /// would read back from one option.
3848    ///
3849    /// A category the mapping does not mention keeps its shipped default for both kinds; one
3850    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
3851    /// omits unmapped rather than defaulted.
3852    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
3853        let resolve_kind =
3854            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
3855                // `CATEGORIES[position] == category` for every category — the crate's suite
3856                // asserts it — so mapping the list in order fills each category's own slot.
3857                let mut targets = CATEGORIES.map(shipped_default);
3858                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
3859                    if !configured.mentions(category) {
3860                        continue;
3861                    }
3862                    *slot = match configured.name_for(category, kind) {
3863                        Err(why) => StatusTarget::Disabled(why),
3864                        Ok(name) => {
3865                            let option = ColumnName::try_from(name.as_str().to_owned())
3866                                .map_err(|message| SourceError::Config { message })?;
3867                            match category {
3868                                StatusCategory::Done => {
3869                                    StatusTarget::Terminal(option, ClosedState::Completed)
3870                                }
3871                                StatusCategory::Cancelled => {
3872                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
3873                                }
3874                                _ => StatusTarget::Column(option),
3875                            }
3876                        }
3877                    };
3878                }
3879                StatusMapping::distinct(
3880                    instance,
3881                    kind,
3882                    CATEGORIES
3883                        .iter()
3884                        .zip(&targets)
3885                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
3886                )?;
3887                Ok(targets)
3888            };
3889        Ok(Self {
3890            tasks: resolve_kind(ItemKind::Task)?,
3891            projects: resolve_kind(ItemKind::Project)?,
3892        })
3893    }
3894
3895    /// Every category's target for `kind`, in category order.
3896    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
3897        match kind {
3898            ItemKind::Task => &self.tasks,
3899            ItemKind::Project => &self.projects,
3900        }
3901    }
3902
3903    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
3904        &self.targets(kind)[category_position(category)]
3905    }
3906
3907    /// The category a board option name reports for `kind`, or `None` when nothing of that
3908    /// kind maps to it.
3909    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
3910        CATEGORIES.into_iter().find(|category| {
3911            self.target(kind, *category)
3912                .option()
3913                .is_some_and(|name| name.eq_ignore_ascii_case(option))
3914        })
3915    }
3916
3917    /// Every option name either kind maps a category to, each once ignoring case, in
3918    /// category order with a task's name before a project's — what the guarded setup asks
3919    /// the `Status` field to hold.
3920    fn wanted(&self) -> Vec<String> {
3921        let mut wanted: Vec<String> = Vec::new();
3922        for category in CATEGORIES {
3923            for kind in STATUS_KINDS {
3924                if let Some(name) = self.target(kind, category).option()
3925                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
3926                {
3927                    wanted.push(name.to_owned());
3928                }
3929            }
3930        }
3931        wanted
3932    }
3933
3934    /// The status an item of `kind` reports, from the three things a read of it says: its
3935    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
3936    ///
3937    /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
3938    /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
3939    /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
3940    /// produce, so it is read permissively rather than refused — reads are faithful, and
3941    /// refusals belong on writes.
3942    ///
3943    /// **Its name follows the closure too, unless the option agrees with it.** The option's
3944    /// own name is kept when this kind's mapping reads that option as the same category, or
3945    /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
3946    /// named `Shipped`. An option the mapping reads as another category contradicts the
3947    /// closure, and so does no option at all: the name is then the option the mapping gives
3948    /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
3949    /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
3950    /// it. An `unknown` closure keeps the option, or `Closed` without one.
3951    ///
3952    /// An open item's option reads through its own kind's mapping, and an option that mapping
3953    /// does not name reads as `Unknown` under its own name.
3954    ///
3955    /// One function of those three rather than of a response, so a narrow status write can
3956    /// answer what a re-read would report by applying it to the state it has just written.
3957    fn status(
3958        &self,
3959        kind: ItemKind,
3960        option: Option<&str>,
3961        closed: bool,
3962        reason: Option<&str>,
3963    ) -> Status {
3964        if closed {
3965            let category = closed_category(reason);
3966            let agrees = option.filter(|option| {
3967                category == StatusCategory::Unknown
3968                    || self
3969                        .category_of(kind, option)
3970                        .is_none_or(|read| read == category)
3971            });
3972            let name = agrees.or_else(|| self.target(kind, category).option());
3973            let fallback = match category {
3974                StatusCategory::Done => "Done",
3975                StatusCategory::Cancelled => "Cancelled",
3976                _ => "Closed",
3977            };
3978            return Status {
3979                category,
3980                name: name.or(option).unwrap_or(fallback).to_owned(),
3981            };
3982        }
3983        let name = option.unwrap_or("Open").to_owned();
3984        Status {
3985            category: self
3986                .category_of(kind, &name)
3987                .unwrap_or(StatusCategory::Unknown),
3988            name,
3989        }
3990    }
3991
3992    /// The option a closed item's `Status` has to hold for its card to agree with its
3993    /// closure, and the category that closure reads as — or `None` when the item is open,
3994    /// its closure reads `unknown`, or its option already agrees.
3995    ///
3996    /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
3997    /// in line, each to the one option the mapping gives its category — an item with no
3998    /// option, or at one the mapping does not name, included.
3999    fn contradicted(
4000        &self,
4001        kind: ItemKind,
4002        option: Option<&str>,
4003        closed: bool,
4004        reason: Option<&str>,
4005    ) -> Option<(ClosedAs, &str)> {
4006        if !closed {
4007            return None;
4008        }
4009        let closed_as = match closed_category(reason) {
4010            StatusCategory::Done => ClosedAs::Done,
4011            StatusCategory::Cancelled => ClosedAs::Cancelled,
4012            _ => return None,
4013        };
4014        let wanted = self.target(kind, closed_as.category()).option()?;
4015        if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
4016            return None;
4017        }
4018        Some((closed_as, wanted))
4019    }
4020}
4021
4022/// The category a closed issue reads as, from the reason it was closed with.
4023///
4024/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
4025/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
4026/// is one this source cannot read a category from.
4027fn closed_category(reason: Option<&str>) -> StatusCategory {
4028    match reason {
4029        None | Some("COMPLETED") => StatusCategory::Done,
4030        Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
4031        Some(_) => StatusCategory::Unknown,
4032    }
4033}
4034
4035impl BoardStatuses {
4036    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
4037    /// case; a kind lacking none is left out.
4038    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
4039        STATUS_KINDS
4040            .into_iter()
4041            .filter_map(|kind| {
4042                let missing: Vec<String> = self
4043                    .targets(kind)
4044                    .iter()
4045                    .filter_map(StatusTarget::option)
4046                    .filter(|wanted| {
4047                        !existing
4048                            .iter()
4049                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4050                    })
4051                    .map(str::to_owned)
4052                    .collect();
4053                (!missing.is_empty()).then_some(KindMissing { kind, missing })
4054            })
4055            .collect()
4056    }
4057}
4058
4059impl StatusTarget {
4060    /// The board option this target selects, or `None` for an unmapped one.
4061    fn option(&self) -> Option<&str> {
4062        match self {
4063            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
4064            Self::Disabled(_) => None,
4065        }
4066    }
4067}
4068
4069// llmlint: ignore-block[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate] Every `createIssue` names one of these, and which one is the rule — a reader who reaches the type from `create_and_file_issue` gets the rule in one sentence here without the method's refusals, which stay on `creation_target`, the rule's one executable source; `tests/plugin.rs` drives every arm of it against the loopback board.
4070/// One repository this source can create an issue in, as `owner/name`.
4071///
4072/// Every `createIssue` this source sends names one of these: the item's own single
4073/// `repositories` entry — on a source declared private, only when it reads private — else
4074/// its parent project issue's repository, else the configured
4075/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
4076/// that choice and says what it refuses before `createIssue`.
4077// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
4078#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
4079#[serde(try_from = "String", into = "String")]
4080struct RepositoryTarget {
4081    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4082    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4083}
4084
4085impl RepositoryTarget {
4086    fn parse(value: &str) -> Result<Self, SourceError> {
4087        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
4088            message: format!(
4089                "repository must be spelled owner/name; {value:?} names no repository"
4090            ),
4091        })?;
4092        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
4093            return Err(SourceError::Config {
4094                message: format!(
4095                    "repository must be spelled owner/name with a GitHub login and one \
4096                     repository name; {value:?} is not"
4097                ),
4098            });
4099        }
4100        Ok(Self {
4101            owner: owner.to_owned(),
4102            name: name.to_owned(),
4103        })
4104    }
4105
4106    /// The one host whose repositories this source creates issues in, spelled once: it is
4107    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
4108    const HOST: &str = "github.com";
4109
4110    fn origin(&self) -> String {
4111        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
4112    }
4113
4114    /// The repository a normalized origin names, or why it is none this source can create
4115    /// an issue in: another host, or more or fewer than `owner/name` under this one.
4116    fn from_origin(origin: &Repository) -> Result<Self, String> {
4117        let not_here = || {
4118            format!(
4119                "{} is not a {}/owner/name repository",
4120                origin.as_str(),
4121                Self::HOST
4122            )
4123        };
4124        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
4125        if host != Self::HOST {
4126            return Err(not_here());
4127        }
4128        Self::parse(rest).map_err(|_| not_here())
4129    }
4130
4131    fn slug(&self) -> String {
4132        format!("{}/{}", self.owner, self.name)
4133    }
4134}
4135
4136/// Read back from a kept context as the `owner/name` it was written as, refused unless
4137/// [`RepositoryTarget::parse`] takes it.
4138impl TryFrom<String> for RepositoryTarget {
4139    type Error = String;
4140
4141    fn try_from(slug: String) -> Result<Self, Self::Error> {
4142        Self::parse(&slug).map_err(|error| error.to_string())
4143    }
4144}
4145
4146impl From<RepositoryTarget> for String {
4147    fn from(target: RepositoryTarget) -> Self {
4148        target.slug()
4149    }
4150}
4151
4152/// How long GitHub's issue search may go on missing a comment after it was made or edited,
4153/// which a read of the comments made since an instant sets its watermark back by: four times the
4154/// thirty seconds the credentialed lane has watched it lag.
4155pub const COMMENT_SEARCH_LAG: std::time::Duration = std::time::Duration::from_secs(120);
4156
4157/// A source which reads GitHub afresh for every operation.
4158pub struct GitHubProjectsSource {
4159    /// This source's configured name, used both to tell a far end naming this source
4160    /// from one naming a system it knows nothing about, and to name the instance a
4161    /// status refusal is about.
4162    name: SourceName,
4163    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
4164    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
4165    repository: Option<RepositoryTarget>,
4166    /// The logins whose repositories a create may file in; empty restricts nothing.
4167    creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after each entry passes GitHub's login grammar.
4168    endpoint: Url,
4169    token: SecretString,
4170    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
4171    statuses: BoardStatuses,
4172    /// Where each priority lands on this board, or `None` when this instance holds none.
4173    priorities: Option<PriorityMapping>,
4174    /// The metadata values this instance projects onto board text fields, in configured order.
4175    metadata_fields: Vec<MetadataField>,
4176    /// Built on the first request rather than with the source, as [`Self::client`] says.
4177    client: std::sync::OnceLock<Client>,
4178    /// Built on the first attachment request, as [`Self::asset_client`] says.
4179    asset_client: std::sync::OnceLock<Client>,
4180    /// The markup the engine handed this source for its bodies' headings, applied to every
4181    /// visible body it composes and stripped from every one it reads back: see
4182    /// [`TaskSource::attach_body_markup`].
4183    body_markup: std::sync::OnceLock<onetaskgraph_plugin_api::SharedBodyMarkup>,
4184    /// Every item this source has created in this command, in the order it created them —
4185    /// dropped by [`TaskSource::end_command`].
4186    ///
4187    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
4188    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
4189    /// a copy resolving a dependency on an item it had just created refused it as not
4190    /// found. A board read is completed from this — an item remembered here and absent from
4191    /// the read is added back, because the board really does hold it and only the read is
4192    /// behind.
4193    ///
4194    /// It is not a cache of a user's work: nothing is remembered that this process did not
4195    /// itself just write, it lives and dies with the process, and it is never consulted for
4196    /// an item this source did not create.
4197    created: Mutex<Vec<Resolved>>,
4198    /// Every item that already existed and that this source has written in this command, as
4199    /// it wrote it — dropped by [`TaskSource::end_command`].
4200    ///
4201    /// The other half of [`Self::created`], held on the same terms and for the reason a
4202    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
4203    /// filter is an index behind a write this process made moments ago, so a query matching
4204    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
4205    /// is remembered that this process did not itself just write.
4206    updated: Mutex<Vec<Resolved>>,
4207    /// Every issue this source has added a comment to or edited a comment of in this command
4208    /// — dropped by [`TaskSource::end_command`].
4209    ///
4210    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
4211    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
4212    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
4213    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
4214    /// from before — until the index caught up. Each id here is a candidate of every such
4215    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
4216    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
4217    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
4218    /// process wrote is still found only once the index has it.
4219    commented: Mutex<Vec<NativeId>>,
4220    /// How fast this source writes, and how long it waits out a refusal.
4221    pacing: Pacing,
4222    /// When the last content-creating mutation finished, or the moment the furthest-out
4223    /// reserved slot releases the next one, whichever is later — so the one after it can be
4224    /// spaced from that, when `pacing.min_mutation_interval_ms` asks for a spacing. See
4225    /// [`GitHubProjectsSource::paced`] for why completion rather than release is what it is
4226    /// measured from.
4227    last_mutation: Mutex<Option<Duration>>,
4228    /// The `Date` GitHub stated on the last answer this source read: its own time, which the
4229    /// watermark of a comment read is set back from. See [`TaskSource::served_at`].
4230    last_served: Mutex<Option<DateTime<Utc>>>,
4231    /// GitHub's secondary windows as this source has charged them; see the `pacing` module.
4232    windows: Mutex<pacing::Windows>,
4233    /// The tasks a project holds, as a copy's batched read of the project and every one of them
4234    /// found them — see [`TaskSource::read_ahead_for_copy`] — for the length of one command, so
4235    /// a listing of that project's tasks is answered without a read of its own.
4236    task_children: Mutex<BTreeMap<NativeId, Vec<Resolved>>>,
4237    /// The web address of each issue this source created in the current command, as its
4238    /// `createIssue` answered it — what [`TaskSource::written_location`] answers, without a
4239    /// request.
4240    written: Mutex<BTreeMap<NativeId, String>>,
4241    /// What a copy has held back of the items it created, between
4242    /// [`TaskSource::begin_deferred_writes`] and [`TaskSource::finish_writes`] — `None` while
4243    /// nothing is being held back, when every write lands whole as it is made.
4244    deferred: Mutex<Option<Deferred>>,
4245    /// Each existing issue's update held back while writes are, in the order it was made: sent
4246    /// together by [`Self::send_held_updates`] before any other request goes out, so nothing
4247    /// this source sends or reads ever sees the board without them.
4248    held_updates: Mutex<Vec<HeldUpdate>>,
4249    /// The first page of comments a comment-activity read read of each candidate it confirmed,
4250    /// held for the one comment listing of that task that follows in this command — see
4251    /// `batch::hold_comment_pages`.
4252    held_comments: Mutex<BTreeMap<NativeId, Page<Comment>>>,
4253    /// The board field writes and issue updates a batch of targeted updates is holding back,
4254    /// while one runs — see `batch::TaskWrites`.
4255    task_writes: Mutex<Option<batch::TaskWrites>>,
4256    /// The change token each task a targeted update wrote in the current command holds after
4257    /// that write, by the task's id with its board item's id — spelled from the write's own
4258    /// last response, or from the read made right after it where no response showed the whole
4259    /// issue: what [`TaskSource::written_task`] answers as its `version`. Any later mutation
4260    /// naming the task or its board item drops its entry before it is sent, so an entry is
4261    /// never one write behind.
4262    /// A response that showed the issue whole but not every part of its token — a draft, or a
4263    /// page of labels or field values that did not end — records `None`, which a read would
4264    /// not improve on.
4265    written_tokens: Mutex<BTreeMap<NativeId, (String, Option<String>)>>,
4266    clock: SharedClock,
4267    /// Whether the configuration declares this source private, as
4268    /// [`TaskSource::declare_visibility`] told it: what decides where a new issue whose one
4269    /// named repository reads public is created — see [`Self::creation_rule`].
4270    declared_private: std::sync::atomic::AtomicBool,
4271    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
4272    /// The board as this process last read it, for the length of one command — dropped by
4273    /// [`TaskSource::end_command`].
4274    ///
4275    /// A copy of a project used to re-read the whole board, paged, before writing each of
4276    /// its items, which is by far the largest part of a copy's request count and none of
4277    /// its work. Nothing else changes this board while a command runs — this source's own
4278    /// writes are the only writer — so one read answers them all.
4279    ///
4280    /// It is not a store of a user's work and it is not the cache the no-persistence
4281    /// invariant forbids: it lives and dies with the process exactly as `created` does,
4282    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
4283    /// an item this command created and then depends on resolves whether or not GitHub's
4284    /// own eventually-consistent read has caught up. A write to an item already on the
4285    /// board updates the entry here too, so what this holds is the last read plus this
4286    /// process's own writes rather than a snapshot taken before them.
4287    board_cache: Mutex<Option<Board>>,
4288    /// Every issue this board's own search reported, for the length of one command — dropped
4289    /// by [`TaskSource::end_command`].
4290    ///
4291    /// The second half of a board read, and cached for the same reason and on the same
4292    /// terms as the first: it lives and dies with the process, nothing is written down, and
4293    /// a write this process makes updates the entry here exactly as it updates the one in
4294    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
4295    /// that lists this board's projects and its tasks pays for one search rather than two.
4296    search_cache: Mutex<Option<Vec<Resolved>>>,
4297    /// What each narrowed question GitHub was asked answered, keyed by that question, for
4298    /// the length of one command — dropped by [`TaskSource::end_command`].
4299    ///
4300    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
4301    /// and dies with the process, nothing is written down, a write this process makes
4302    /// updates the entry here as it updates the other two, and every answer is completed
4303    /// with this process's own writes each time it is given. A command that asks the same
4304    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
4305    /// write — pays for it once, which is what the whole-board read it replaced gave it.
4306    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
4307    search_next: Mutex<BTreeMap<String, Option<String>>>,
4308    /// Records already resolved in this command, reused by writes and for comment identity.
4309    /// Explicit item reads still reach GitHub. Nothing is persisted, and
4310    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
4311    /// its item as a person has since left it.
4312    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4313    /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
4314    /// answered as holding nothing — each taken by the next read of that item by id and by
4315    /// nothing else, dropped by any mutation naming it, and cleared by
4316    /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
4317    /// read-before-write, made in the same request as the others' rather than on its own.
4318    read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
4319    /// The origins [`TaskSource::read_ahead_origins_for_copy`] was asked about and has not sent
4320    /// yet: held for the [`TaskSource::read_ahead_for_copy`] that follows it, which sends them —
4321    /// beside the members of the one project it names, when it names one — or for the next
4322    /// request this source makes, whichever comes first.
4323    pending_origins: Mutex<Vec<String>>,
4324    /// Each project's members as a lookup carrying them read them in this command, trimmed to
4325    /// their classification and repositories, taken by [`TaskSource::member_classifications`]
4326    /// of that project and by nothing else, and cleared by [`TaskSource::end_command`].
4327    member_classes: Mutex<BTreeMap<NativeId, Vec<MemberClass>>>,
4328    /// Every item a listing of one project's members carried whole in this command — the
4329    /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
4330    /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
4331    /// it again. No other read is answered from it, so a read by id outside a copy's
4332    /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
4333    /// item, and cleared by [`TaskSource::end_command`].
4334    carried: Mutex<BTreeMap<NativeId, Resolved>>,
4335    /// Items an issue read by reference reached, held for the one status write each was read
4336    /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
4337    /// what such a write needs. Dropped by [`TaskSource::end_command`].
4338    held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4339    /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
4340    /// asked for under, with the project that selector named — for the length of one command,
4341    /// dropped by [`TaskSource::end_command`].
4342    ///
4343    /// A caller pages through a project's tasks one engine page at a time, and every page
4344    /// is cut from the whole list of them, so without this each page walked every
4345    /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
4346    /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
4347    /// dies with the process, nothing is written down, a write this process makes updates or
4348    /// removes the entry here as it does there, and every answer is completed with this
4349    /// process's own writes each time it is given.
4350    children_cache: Mutex<ProjectChildren>,
4351    /// The board's own id and field definitions as this process last read them on their
4352    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
4353    ///
4354    /// What a write needs of the board and its item does not say, read once per command
4355    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
4356    /// lives and dies with the process and nothing is written down. It holds no item and so
4357    /// can answer no question about one — see [`Self::board_fields`].
4358    fields_cache: Mutex<Option<BoardFields>>,
4359    /// Each destination repository's node id, resolved once per repository
4360    /// rather than per issue created.
4361    ///
4362    /// A repository's node id does not change, and re-reading it for every issue of a copy
4363    /// spent one request per item on an answer this source already had. It is a map rather
4364    /// than one entry because a copy files each item in the repository its own
4365    /// `repositories` field names, so a plan across five repositories asks GitHub five
4366    /// times and not once per item.
4367    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
4368    /// What every request this source sends is recorded into.
4369    ///
4370    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
4371    /// a request leaves this crate, so nothing has to be switched on for a session to be
4372    /// counted. It is shared rather than owned so a caller accounting for a whole session —
4373    /// its own schema verification, board lookups, residue sweep and cleanup beside this
4374    /// source's reads and writes — adds up one accounting instead of two. See
4375    /// [`accounting`] for what a record carries and what a session's spend is and is not.
4376    ledger: Arc<Accounting>,
4377    /// What [`expected`] holds for a write the store's cache expects: the journal while one is
4378    /// open, the issues primed from the cache, and the comments known on each.
4379    expected: expected::Held,
4380    /// What [`projection`] holds for a producer's projection: a kept placement awaiting its
4381    /// setup-context token, and where the last create was placed.
4382    projection: projection::Held,
4383}
4384
4385/// The board item a single field update's answer names, refused unless it is `item_id`.
4386fn field_update_item<'a>(data: &'a Value, item_id: &str) -> Result<&'a Value, SourceError> {
4387    let returned = data
4388        .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
4389        .ok_or_else(|| SourceError::Malformed {
4390            message: "GitHub field update returned no project item".into(),
4391        })?;
4392    if required_str(returned, "id")? != item_id {
4393        return Err(SourceError::Malformed {
4394            message: "GitHub field update returned the wrong project item".into(),
4395        });
4396    }
4397    Ok(returned)
4398}
4399
4400/// GitHub's closed single-select color vocabulary.
4401#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
4402#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
4403pub enum StatusOptionColor {
4404    /// Gray.
4405    Gray,
4406    /// Blue.
4407    Blue,
4408    /// Green.
4409    Green,
4410    /// Yellow.
4411    Yellow,
4412    /// Purple.
4413    Purple,
4414    /// Red.
4415    Red,
4416    /// Orange.
4417    Orange,
4418    /// Pink.
4419    Pink,
4420}
4421
4422/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
4423/// applies its additions.
4424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4425pub enum SetupMode {
4426    /// Read without mutation.
4427    Plan,
4428    /// Apply and verify.
4429    Apply,
4430}
4431
4432/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
4433/// against it goes on compiling.
4434pub type StatusOptionsMode = SetupMode;
4435
4436/// The explicit result of the requested operation.
4437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4438#[serde(rename_all = "kebab-case")]
4439pub enum StatusOptionsOutcome {
4440    /// A read-only plan.
4441    Planned,
4442    /// Apply found nothing missing.
4443    Unchanged,
4444    /// Additions were applied and verified.
4445    Applied,
4446}
4447
4448/// A GitHub single-select option's opaque GraphQL node identifier.
4449#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4450#[serde(transparent)]
4451pub struct StatusOptionId(#[schemars(length(min = 1))] String);
4452
4453impl TryFrom<String> for StatusOptionId {
4454    type Error = String;
4455
4456    fn try_from(id: String) -> Result<Self, Self::Error> {
4457        if id.trim().is_empty() {
4458            return Err("a GitHub Status option id cannot be blank".to_owned());
4459        }
4460        Ok(Self(id))
4461    }
4462}
4463
4464/// One existing or proposed option in a guarded Status-field update.
4465#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4466pub struct StatusOption {
4467    /// GitHub's stable id.
4468    pub id: StatusOptionId,
4469    /// The visible option name.
4470    pub name: ColumnName,
4471    /// GitHub's single-select color token.
4472    pub color: StatusOptionColor,
4473    /// The option description, including an empty one.
4474    pub description: String,
4475}
4476
4477/// One board item's Status assignment, retained as recovery data.
4478#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4479pub struct StatusAssignment {
4480    /// The project item id whose assignment this is.
4481    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
4482    // carried verbatim as operator recovery data; introducing a semantic type would claim
4483    // validation rules GitHub does not publish and no operation here interprets.
4484    pub item_id: String,
4485    /// The selected option, absent when the item has no status.
4486    #[serde(skip_serializing_if = "Option::is_none")]
4487    pub option: Option<AssignedStatusOption>,
4488}
4489
4490/// The inseparable id and name of an assigned option.
4491#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4492pub struct AssignedStatusOption {
4493    /// GitHub's stable id.
4494    pub id: StatusOptionId,
4495    /// The visible name.
4496    pub name: ColumnName,
4497}
4498
4499/// The plan and verified outcome of reconciling configured Status options.
4500#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4501pub struct StatusOptionsReport {
4502    /// The configured source name.
4503    pub source: SourceName,
4504    /// Configured option names absent before the operation.
4505    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
4506    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
4507    // serialized string here preserves the report's intentionally simple public contract.
4508    pub missing: Vec<String>,
4509    /// What the requested operation did.
4510    pub outcome: StatusOptionsOutcome,
4511    /// The complete option list observed before any mutation.
4512    pub existing: Vec<StatusOption>,
4513}
4514
4515#[derive(Debug, Clone, PartialEq, Eq)]
4516struct StatusSnapshot {
4517    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4518    // passed back as the mutation's project identity; a newtype could enforce no stronger
4519    // invariant because GitHub publishes no grammar for it.
4520    board_id: String,
4521    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4522    // passed back as the mutation's field identity; a newtype could enforce no stronger
4523    // invariant because GitHub publishes no grammar for it.
4524    field_id: String,
4525    options: Vec<StatusOption>,
4526    assignments: Vec<StatusAssignment>,
4527}
4528
4529/// The name of the board field a status is held in.
4530const STATUS_FIELD: &str = "Status";
4531
4532/// Every item's value of each field `report` names, as it stood before the setup wrote
4533/// anything — what a person puts back when the setup is refused part way.
4534fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
4535    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
4536        .fields
4537        .iter()
4538        .map(|field| (field.field.name(), before.assignments(field.field)))
4539        .collect();
4540    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
4541        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
4542    })
4543}
4544
4545/// One board field the guarded setup reads and writes — every one it reads, and the only
4546/// ones it writes.
4547#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
4548pub enum BoardField {
4549    /// The single-select `Status` field every instance's `status_mapping` resolves into.
4550    Status,
4551    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
4552    Priority,
4553}
4554
4555impl BoardField {
4556    /// The field's name on the board.
4557    #[must_use]
4558    pub const fn name(self) -> &'static str {
4559        match self {
4560            Self::Status => STATUS_FIELD,
4561            Self::Priority => PRIORITY_FIELD,
4562        }
4563    }
4564
4565    /// The field a board calls `name`, or `None` for one this setup does not own.
4566    fn named(name: &str) -> Option<Self> {
4567        [Self::Status, Self::Priority]
4568            .into_iter()
4569            .find(|field| field.name() == name)
4570    }
4571}
4572
4573/// What the guarded setup did to one field.
4574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4575#[serde(rename_all = "kebab-case")]
4576pub enum FieldOutcome {
4577    /// A read-only plan.
4578    Planned,
4579    /// Apply found the field there with every configured option.
4580    Unchanged,
4581    /// Missing options were added to the field that was there, and verified.
4582    Applied,
4583    /// The field was not there; it was created holding the configured options, and verified.
4584    Created,
4585}
4586
4587/// One field's plan, or its verified outcome.
4588#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4589pub struct FieldReport {
4590    /// Which field.
4591    pub field: BoardField,
4592    /// Whether the board had the field before the operation.
4593    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
4594    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
4595    // "outcome", "existing"}` — so folding one into the other would change a published JSON
4596    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
4597    // one constructor, and it derives `outcome` from `exists` in one match.
4598    pub exists: bool,
4599    /// Configured option names the field lacked before the operation — every one of them,
4600    /// in the order a new field lists them, when the field was not there at all.
4601    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4602    // mapping name and has therefore already passed its nonblank validation; the serialized
4603    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
4604    pub missing: Vec<String>,
4605    /// For the `Status` field, which item kind each missing name is configured for: one
4606    /// entry per kind `status_mapping` names a missing option for, task before project, each
4607    /// listing that kind's missing names in category order. A name both kinds use is in
4608    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
4609    /// `Priority`, which only a task holds.
4610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4611    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4612    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
4613    #[schemars(!skip_serializing_if)]
4614    pub kinds: Vec<KindMissing>,
4615    /// What the requested operation did.
4616    pub outcome: FieldOutcome,
4617    /// The field's complete option list observed before any mutation; empty when the field
4618    /// was not there.
4619    pub existing: Vec<StatusOption>,
4620}
4621
4622/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
4623#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4624pub struct KindMissing {
4625    /// The kind these names are configured for.
4626    pub kind: ItemKind,
4627    /// The names that kind maps a category to and the field lacked, in category order.
4628    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4629    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
4630    // intentionally simple public contract.
4631    pub missing: Vec<String>,
4632}
4633
4634/// The plan and verified outcome of setting up every field a source's configuration names.
4635#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4636pub struct FieldsReport {
4637    /// The configured source name.
4638    pub source: SourceName,
4639    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
4640    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
4641    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
4642    // per field would change a published JSON shape. The states the list could hold and the
4643    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
4644    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
4645    pub fields: Vec<FieldReport>,
4646    /// Each board text field the source's `metadata_fields` projects a value onto, in
4647    /// configured order. Empty — and left out of the JSON — when it projects none.
4648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4649    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4650    // both SDKs model an absent list as an empty one rather than as `null`.
4651    #[schemars(!skip_serializing_if)]
4652    pub metadata_fields: Vec<MetadataFieldReport>,
4653}
4654
4655/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
4656/// its GraphQL type when it has none — which is what a projected field's conflict reports.
4657///
4658/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
4659/// no conflict.
4660// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
4661// refuses exactly what `try_from` below does.
4662#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4663#[serde(transparent)]
4664pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
4665
4666/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
4667/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
4668/// neither SDK's validator can be relied on to support.
4669const NON_TEXT_FIELD_TYPE_PATTERN: &str =
4670    r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
4671
4672impl TryFrom<String> for NonTextFieldType {
4673    type Error = String;
4674
4675    fn try_from(kind: String) -> Result<Self, Self::Error> {
4676        if kind.is_empty() || kind.starts_with(char::is_whitespace) {
4677            return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
4678        }
4679        if kind == "TEXT" {
4680            return Err("a TEXT field is no conflict for a projected text field".to_owned());
4681        }
4682        Ok(Self(kind))
4683    }
4684}
4685
4686impl std::fmt::Display for NonTextFieldType {
4687    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4688        formatter.write_str(&self.0)
4689    }
4690}
4691
4692/// One projected metadata text field's plan, or its verified outcome.
4693///
4694/// The setup creates a missing one as a text field and never alters a field that is there:
4695/// a same-named field of another type is reported in `conflict` and left as it is.
4696#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4697pub struct MetadataFieldReport {
4698    /// The board field's name.
4699    pub field: String, // llmlint: ignore[invalid_states_unrepresentable] Copied from a configuration entry `MetadataField::resolve` already validated; the serialized string is the report's intentionally simple public contract, as `FieldReport::missing`'s names are.
4700    /// The metadata key the value is read from.
4701    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
4702    /// The object keys walked inside that key's value; empty for the key's own value.
4703    pub path: Vec<String>,
4704    /// Whether the board had a text field of that name before the operation.
4705    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` mirrors `FieldReport`'s published shape; `GitHubProjectsSource::fields` is the one constructor and derives `outcome` from `exists` and `conflict` in one match.
4706    pub exists: bool,
4707    /// The type of a field of that name that is not a text field, which the setup leaves as
4708    /// it is; absent — and left out of the JSON — when there is none.
4709    #[serde(default, skip_serializing_if = "Option::is_none")]
4710    pub conflict: Option<NonTextFieldType>,
4711    /// What the requested operation did: `planned`; `created` for a field that was not
4712    /// there; `unchanged` for one that was, and for a conflict, which is never changed.
4713    pub outcome: FieldOutcome,
4714}
4715
4716/// Which options one field is configured with, in the order a new field would list them.
4717struct FieldPlan {
4718    field: BoardField,
4719    wanted: Vec<String>,
4720}
4721
4722/// One single-select field as the guarded setup snapshots it.
4723#[derive(Debug, Clone, PartialEq, Eq)]
4724struct SnapshotField {
4725    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4726    // passed back as the mutation's field identity; a newtype could enforce no stronger
4727    // invariant because GitHub publishes no grammar for it.
4728    field_id: String,
4729    options: Vec<StatusOption>,
4730}
4731
4732/// Every single-select field of a board and every item's value of each.
4733#[derive(Debug, Clone, PartialEq, Eq)]
4734struct BoardSnapshot {
4735    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4736    // passed back as the mutation's project identity; a newtype could enforce no stronger
4737    // invariant because GitHub publishes no grammar for it.
4738    board_id: String,
4739    fields: BTreeMap<BoardField, SnapshotField>,
4740    /// Each board item's id, and its value of each field this setup owns that it holds one of.
4741    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
4742}
4743
4744impl BoardSnapshot {
4745    /// Every item's value of `field`, in board order — the recovery data a drift refusal
4746    /// carries.
4747    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
4748        self.items
4749            .iter()
4750            .map(|(item_id, values)| StatusAssignment {
4751                item_id: item_id.clone(),
4752                option: values.get(&field).cloned(),
4753            })
4754            .collect()
4755    }
4756}
4757
4758impl GitHubProjectsSource {
4759    /// `content` as this source sends it, with the markup the engine handed it applied.
4760    fn marked(&self, content: &str) -> String {
4761        match self.body_markup.get() {
4762            Some(markup) if !content.is_empty() => markup.written(content),
4763            _ => content.to_owned(),
4764        }
4765    }
4766
4767    /// The visible body and the metadata slot of `body` as [`metadata_body`] splits them,
4768    /// the visible body without the markup [`Self::marked`] applies.
4769    fn visible(
4770        &self,
4771        body: Option<String>,
4772    ) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
4773        let (visible, slot) = metadata_body(body)?;
4774        Ok((
4775            match (visible, self.body_markup.get()) {
4776                (Some(visible), Some(markup)) => {
4777                    Some(markup.read(&visible)).filter(|read| !read.is_empty())
4778                }
4779                (visible, _) => visible,
4780            },
4781            slot,
4782        ))
4783    }
4784
4785    /// Report missing configured Status options and, when `apply` is true, add them with
4786    /// a whole-list mutation that preserves every existing id and verifies the result.
4787    ///
4788    /// # Errors
4789    ///
4790    /// Refuses a board without a single-select `Status` field. A post-write difference in
4791    /// any pre-existing option id or item assignment is refused with the complete pre-write
4792    /// assignment snapshot in the diagnostic for recovery.
4793    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
4794    // successful mutation, both drift refusals, source selection, missing Status, casing,
4795    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
4796    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
4797    // responses from entering the defensive malformed-response branches below.
4798    pub async fn status_options(
4799        &self,
4800        mode: StatusOptionsMode,
4801    ) -> Result<StatusOptionsReport, SourceError> {
4802        let before = self.status_snapshot().await?;
4803        // A terminal category's option is as configured as an open one's: a terminal
4804        // write validates it before closing and refuses when the board lacks it. Both
4805        // kinds' names are options of the one field, so both are asked for.
4806        let missing = self
4807            .statuses
4808            .wanted()
4809            .into_iter()
4810            .filter(|wanted| {
4811                !before
4812                    .options
4813                    .iter()
4814                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4815            })
4816            .collect::<Vec<_>>();
4817        let report = StatusOptionsReport {
4818            source: self.name.clone(),
4819            missing: missing.clone(),
4820            outcome: match (mode, missing.is_empty()) {
4821                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
4822                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
4823                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
4824            },
4825            existing: before.options.clone(),
4826        };
4827        if mode == StatusOptionsMode::Plan || missing.is_empty() {
4828            return Ok(report);
4829        }
4830        let mut options = before
4831            .options
4832            .iter()
4833            .map(|option| {
4834                json!({
4835                    "id": option.id, "name": option.name, "color": option.color,
4836                    "description": option.description,
4837                })
4838            })
4839            .collect::<Vec<_>>();
4840        options.extend(missing.iter().map(|name| {
4841            json!({
4842                "name": name, "color": "GRAY", "description": ""
4843            })
4844        }));
4845        self.graphql(
4846            graphql::STATUS_OPTIONS_UPDATE,
4847            json!({"input": {
4848                "fieldId": before.field_id,
4849                "singleSelectOptions": options,
4850            }}),
4851        )
4852        .await?;
4853        let after = self.status_snapshot().await?;
4854        let options_preserved = before
4855            .options
4856            .iter()
4857            .all(|old| after.options.iter().any(|new| new == old));
4858        let additions_present = missing.iter().all(|wanted| {
4859            after
4860                .options
4861                .iter()
4862                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4863        });
4864        if !options_preserved || !additions_present || after.assignments != before.assignments {
4865            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
4866                SourceError::Malformed {
4867                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
4868                }
4869            })?;
4870            return Err(SourceError::Refused {
4871                message: format!(
4872                    "GitHub changed a pre-existing Status option id, name, color or description, or an item assignment after the guarded update; the pre-write item assignment snapshot is:\n{recovery}"
4873                ),
4874            });
4875        }
4876        Ok(report)
4877    }
4878
4879    /// A fresh snapshot of the Status field and every board item's assignment of it.
4880    ///
4881    /// # Errors
4882    ///
4883    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
4884    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
4885        // Status alone, as this operation has always read it: a `Priority` field is another
4886        // operation's, so nothing about it can refuse this one.
4887        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
4888        let field = board
4889            .fields
4890            .remove(&BoardField::Status)
4891            .ok_or_else(|| self.no_status_field())?;
4892        Ok(StatusSnapshot {
4893            assignments: board.assignments(BoardField::Status),
4894            board_id: board.board_id,
4895            field_id: field.field_id,
4896            options: field.options,
4897        })
4898    }
4899
4900    /// The refusal a board with no `Status` field is answered with by the guarded setup.
4901    fn no_status_field(&self) -> SourceError {
4902        SourceError::Refused {
4903            message: format!("source {} board has no Status field", self.name),
4904        }
4905    }
4906
4907    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
4908    // the real CLI loopback journey, including pagination. The individual malformed guards
4909    // are defensive validation of a schema-pinned third-party response, not separate user
4910    // journeys; drift and missing-field failures cover the operation's recovery behavior.
4911    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
4912    /// every board item's value of each, walked to the end of the board's items. A field not
4913    /// in `owned` is read past whatever it holds.
4914    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
4915        let mut after: Option<String> = None;
4916        let mut snapshot: Option<BoardSnapshot> = None;
4917        loop {
4918            let data = self
4919                .graphql(
4920                    graphql::STATUS_OPTIONS_SNAPSHOT,
4921                    json!({
4922                        "owner": self.owner, "number": self.project_number,
4923                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
4924                    }),
4925                )
4926                .await?;
4927            let board = data
4928                .pointer("/owner/projectV2")
4929                .filter(|board| board.is_object())
4930                .ok_or_else(|| SourceError::Refused {
4931                    message: format!(
4932                        "source {} has no accessible GitHub Projects board",
4933                        self.name
4934                    ),
4935                })?;
4936            if board
4937                .pointer("/fields/pageInfo/hasNextPage")
4938                .and_then(Value::as_bool)
4939                != Some(false)
4940            {
4941                return Err(SourceError::Malformed {
4942                    message:
4943                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
4944                            .into(),
4945                });
4946            }
4947            let mut fields = BTreeMap::new();
4948            // Only the fields this setup owns, by name: a node the single-select fragment did not
4949            // match carries no name, and a person's own single-select field — a `Size`, a
4950            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
4951            // `Status` or `Priority` field without its options is malformed, not absent.
4952            // llmlint: ignore[boundary_inputs_validated] The field page this loop reads is validated as complete immediately above: any `fields.pageInfo.hasNextPage` other than `false` is refused as malformed before a node is read, so an incomplete page is never taken for the board's whole field set.
4953            for (owned, field) in board
4954                .pointer("/fields/nodes")
4955                .and_then(Value::as_array)
4956                .ok_or_else(|| SourceError::Malformed {
4957                    message: "GitHub project fields.nodes is not an array".into(),
4958                })?
4959                .iter()
4960                .filter_map(|field| {
4961                    let named = BoardField::named(field.get("name")?.as_str()?)?;
4962                    owned.contains(&named).then_some((named, field))
4963                })
4964            {
4965                let options = field
4966                    .get("options")
4967                    .and_then(Value::as_array)
4968                    .ok_or_else(|| SourceError::Malformed {
4969                        message: "GitHub single-select field options is not an array".into(),
4970                    })?
4971                    .iter()
4972                    .map(|option| {
4973                        Ok(StatusOption {
4974                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
4975                                .map_err(|message| SourceError::Malformed { message })?,
4976                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
4977                                .map_err(|message| SourceError::Malformed {
4978                                    message: format!(
4979                                        "GitHub single-select option name is invalid: {message}"
4980                                    ),
4981                                })?,
4982                            color: serde_json::from_value(
4983                                option.get("color").cloned().unwrap_or(Value::Null),
4984                            )
4985                            .map_err(|error| {
4986                                SourceError::Malformed {
4987                                    message: format!(
4988                                        "GitHub single-select option color is invalid: {error}"
4989                                    ),
4990                                }
4991                            })?,
4992                            description: optional_str(option, "description")?
4993                                .unwrap_or_default()
4994                                .to_owned(),
4995                        })
4996                    })
4997                    .collect::<Result<Vec<_>, SourceError>>()?;
4998                let snapshot = SnapshotField {
4999                    field_id: required_nonblank_str(field, "id")?.to_owned(),
5000                    options,
5001                };
5002                // A board's field names are unique, so a second one is an answer that cannot
5003                // say which field the setup would act on — refused rather than one chosen.
5004                if fields.insert(owned, snapshot).is_some() {
5005                    return Err(SourceError::Malformed {
5006                        message: format!(
5007                            "GitHub answered two {} fields for this board",
5008                            owned.name()
5009                        ),
5010                    });
5011                }
5012            }
5013            let board_id = required_nonblank_str(board, "id")?.to_owned();
5014            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
5015                board_id,
5016                fields,
5017                items: Vec::new(),
5018            });
5019            let items = board
5020                .pointer("/items/nodes")
5021                .and_then(Value::as_array)
5022                .ok_or_else(|| SourceError::Malformed {
5023                    message: "GitHub project items.nodes is not an array".into(),
5024                })?;
5025            for item in items {
5026                let field_values =
5027                    item.get("fieldValues")
5028                        .ok_or_else(|| SourceError::Malformed {
5029                            message: "GitHub project item is missing fieldValues".into(),
5030                        })?;
5031                if field_values
5032                    .pointer("/pageInfo/hasNextPage")
5033                    .and_then(Value::as_bool)
5034                    != Some(false)
5035                {
5036                    return Err(SourceError::Malformed {
5037                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
5038                    });
5039                }
5040                let values = item
5041                    .pointer("/fieldValues/nodes")
5042                    .and_then(Value::as_array)
5043                    .ok_or_else(|| SourceError::Malformed {
5044                        message: "GitHub project item fieldValues.nodes is not an array".into(),
5045                    })?;
5046                let item_id = required_nonblank_str(item, "id")?;
5047                let mut assigned = BTreeMap::new();
5048                for value in values {
5049                    let Some(field) = value
5050                        .pointer("/field/name")
5051                        .and_then(Value::as_str)
5052                        .and_then(BoardField::named)
5053                        .filter(|field| owned.contains(field))
5054                    else {
5055                        continue;
5056                    };
5057                    let held = assigned.insert(
5058                        field,
5059                        AssignedStatusOption {
5060                            id: StatusOptionId::try_from(
5061                                required_str(value, "optionId")?.to_owned(),
5062                            )
5063                            .map_err(|message| SourceError::Malformed { message })?,
5064                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
5065                                .map_err(|message| SourceError::Malformed {
5066                                    message: format!(
5067                                        "GitHub assigned {} name is invalid: {message}",
5068                                        field.name()
5069                                    ),
5070                                })?,
5071                        },
5072                    );
5073                    // An item holds one value of a field, so a second one leaves no way to
5074                    // tell which it holds — and a verification or recovery built on either
5075                    // could restore the wrong one.
5076                    if held.is_some() {
5077                        return Err(SourceError::Malformed {
5078                            message: format!(
5079                                "GitHub answered two {} values for board item {item_id}",
5080                                field.name()
5081                            ),
5082                        });
5083                    }
5084                }
5085                current.items.push((item_id.to_owned(), assigned));
5086            }
5087            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
5088                message: "GitHub project is missing items".into(),
5089            })?;
5090            let has_next = page
5091                .pointer("/pageInfo/hasNextPage")
5092                .and_then(Value::as_bool)
5093                .ok_or_else(|| SourceError::Malformed {
5094                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
5095                })?;
5096            if !has_next {
5097                break;
5098            }
5099            let next =
5100                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
5101            validate_cursor_progress(after.as_deref(), next)?;
5102            after = Some(next.to_owned());
5103        }
5104        snapshot.ok_or_else(|| SourceError::Malformed {
5105            message: "GitHub returned no board field snapshot".into(),
5106        })
5107    }
5108    // llmlint: ignore-end[changed_behavior_has_e2e]
5109
5110    /// Report every board field this source's configuration names and, with
5111    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
5112    /// the `Priority` field when the board has none.
5113    ///
5114    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
5115    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
5116    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
5117    /// color and description: the whole option list goes back with every existing id, because
5118    /// a re-minted id clears every item's value.
5119    ///
5120    /// # Errors
5121    ///
5122    /// Refuses a board without a single-select `Status` field. After an apply the board is
5123    /// read again, and a pre-existing option or any item's value of either field that moved is
5124    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
5125    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
5126    // unchanged apply, a created field, an added option to each field, drift refusal, a board
5127    // with no Status field and a non-github-projects source through the compiled CLI against
5128    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
5129    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
5130        let owned: Vec<BoardField> = if self.priorities.is_some() {
5131            vec![BoardField::Status, BoardField::Priority]
5132        } else {
5133            vec![BoardField::Status]
5134        };
5135        let before = self.board_snapshot(&owned).await?;
5136        let mut plans = vec![FieldPlan {
5137            field: BoardField::Status,
5138            wanted: self.statuses.wanted(),
5139        }];
5140        if !before.fields.contains_key(&BoardField::Status) {
5141            return Err(self.no_status_field());
5142        }
5143        if let Some(mapping) = &self.priorities {
5144            plans.push(FieldPlan {
5145                field: BoardField::Priority,
5146                wanted: mapping.names().map(str::to_owned).collect(),
5147            });
5148        }
5149        // The snapshot reads single-select fields alone, so a field it did not find may still
5150        // be on the board under the name, of another type: creating one beside it would fail
5151        // part way, or leave two fields of one name. Asked of the board's own field list, and
5152        // only when a field is missing.
5153        if plans
5154            .iter()
5155            .any(|plan| !before.fields.contains_key(&plan.field))
5156        {
5157            let board = self.board_fields().await?;
5158            for plan in plans
5159                .iter()
5160                .filter(|plan| !before.fields.contains_key(&plan.field))
5161            {
5162                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
5163                    return Err(SourceError::Refused {
5164                        message: format!(
5165                            "source {}'s board has a {} field that is not a single-select field \
5166                             (it is a {}), so it cannot hold this source's options; next: rename \
5167                             or remove that field, then run this again",
5168                            self.name,
5169                            plan.field.name(),
5170                            optional_str(field, "__typename")?.unwrap_or("field of another type")
5171                        ),
5172                    });
5173                }
5174            }
5175        }
5176        let mut reports = Vec::new();
5177        for plan in &plans {
5178            let held = before.fields.get(&plan.field);
5179            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
5180            let mut missing: Vec<String> = Vec::new();
5181            for wanted in &plan.wanted {
5182                let present = existing
5183                    .iter()
5184                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5185                    || missing
5186                        .iter()
5187                        .any(|named| named.eq_ignore_ascii_case(wanted));
5188                if !present {
5189                    missing.push(wanted.clone());
5190                }
5191            }
5192            let kinds = match plan.field {
5193                BoardField::Status => self.statuses.missing_by_kind(&existing),
5194                BoardField::Priority => Vec::new(),
5195            };
5196            reports.push(FieldReport {
5197                field: plan.field,
5198                exists: held.is_some(),
5199                kinds,
5200                outcome: match (mode, held.is_some(), missing.is_empty()) {
5201                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
5202                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
5203                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
5204                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
5205                },
5206                missing,
5207                existing,
5208            });
5209        }
5210        // A projected field is a text field, which the single-select snapshot cannot see, so
5211        // it is asked of the board's own field list — and only when one is configured.
5212        let mut metadata_fields = Vec::new();
5213        if !self.metadata_fields.is_empty() {
5214            let board = self.board_fields().await?;
5215            for projection in &self.metadata_fields {
5216                let conflict = match Board::field(&board.fields, &projection.field)? {
5217                    None => None,
5218                    Some(field) => {
5219                        let typename = optional_str(field, "__typename")?;
5220                        let data_type = optional_str(field, "dataType")?;
5221                        if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
5222                            None
5223                        } else {
5224                            let named = data_type
5225                                .filter(|kind| *kind != "TEXT")
5226                                .or(typename)
5227                                .unwrap_or("field of another type");
5228                            Some(NonTextFieldType::try_from(named.to_owned()).map_err(
5229                                |message| SourceError::Malformed {
5230                                    message: format!(
5231                                        "GitHub answered the board's {} field with a type this \
5232                                         setup cannot read: {message}",
5233                                        &*projection.field
5234                                    ),
5235                                },
5236                            )?)
5237                        }
5238                    }
5239                };
5240                let exists =
5241                    conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
5242                metadata_fields.push(MetadataFieldReport {
5243                    field: projection.field.to_string(),
5244                    key: projection.key.to_string(),
5245                    path: projection.path.clone(),
5246                    exists,
5247                    outcome: match (mode, exists || conflict.is_some()) {
5248                        (SetupMode::Plan, _) => FieldOutcome::Planned,
5249                        (SetupMode::Apply, true) => FieldOutcome::Unchanged,
5250                        (SetupMode::Apply, false) => FieldOutcome::Created,
5251                    },
5252                    conflict,
5253                });
5254            }
5255        }
5256        let report = FieldsReport {
5257            source: self.name.clone(),
5258            fields: reports,
5259            metadata_fields,
5260        };
5261        let writes: Vec<&FieldReport> = report
5262            .fields
5263            .iter()
5264            .filter(|field| !field.missing.is_empty() || !field.exists)
5265            .collect();
5266        let creates: Vec<&MetadataFieldReport> = report
5267            .metadata_fields
5268            .iter()
5269            .filter(|field| field.outcome == FieldOutcome::Created)
5270            .collect();
5271        if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
5272            return Ok(report);
5273        }
5274        let mut landed: Vec<&str> = Vec::new();
5275        for field in &writes {
5276            let added = field
5277                .missing
5278                .iter()
5279                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
5280            let sent = match before.fields.get(&field.field) {
5281                Some(held) => {
5282                    let mut options = held
5283                        .options
5284                        .iter()
5285                        .map(|option| {
5286                            json!({
5287                                "id": option.id, "name": option.name, "color": option.color,
5288                                "description": option.description,
5289                            })
5290                        })
5291                        .collect::<Vec<_>>();
5292                    options.extend(added);
5293                    self.graphql(
5294                        graphql::STATUS_OPTIONS_UPDATE,
5295                        json!({"input": {
5296                            "fieldId": held.field_id,
5297                            "singleSelectOptions": options,
5298                        }}),
5299                    )
5300                    .await
5301                }
5302                None => {
5303                    self.graphql(
5304                        graphql::CREATE_FIELD,
5305                        json!({"input": {
5306                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
5307                            "name": field.field.name(),
5308                            "singleSelectOptions": added.collect::<Vec<_>>(),
5309                        }}),
5310                    )
5311                    .await
5312                }
5313            };
5314            // A mutation that failed does not establish that GitHub left its field as it was,
5315            // so every failure from here on carries the recovery data a drift refusal does.
5316            match sent {
5317                Ok(_) => landed.push(field.field.name()),
5318                Err(error) => {
5319                    let changed = if landed.is_empty() {
5320                        String::new()
5321                    } else {
5322                        format!("changed the {} field and then ", landed.join(" and "))
5323                    };
5324                    return Err(SourceError::Refused {
5325                        message: format!(
5326                            "the guarded field setup {changed}failed on the {} field, which it may \
5327                             have changed part way: {error}; the pre-write item assignments \
5328                             are:\n{}",
5329                            field.field.name(),
5330                            recovery(&report, &before)?
5331                        ),
5332                    });
5333                }
5334            }
5335        }
5336        for field in &creates {
5337            let sent = self
5338                .graphql(
5339                    graphql::CREATE_FIELD,
5340                    json!({"input": {
5341                        "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
5342                    }}),
5343                )
5344                .await;
5345            if let Err(error) = sent {
5346                let changed = if landed.is_empty() {
5347                    String::new()
5348                } else {
5349                    format!("changed the {} field and then ", landed.join(" and "))
5350                };
5351                return Err(SourceError::Refused {
5352                    message: format!(
5353                        "the guarded field setup {changed}failed creating the {} text field: \
5354                         {error}; run it again to create what is still missing",
5355                        field.field
5356                    ),
5357                });
5358            }
5359            landed.push(&field.field);
5360        }
5361        // The board has been written, so a verification read that fails leaves it unverified
5362        // rather than unchanged, and says what to put back.
5363        let after = match self.board_snapshot(&owned).await {
5364            Ok(after) => after,
5365            Err(error) => {
5366                return Err(SourceError::Refused {
5367                    message: format!(
5368                        "the guarded field setup changed the {} field and then could not read the \
5369                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
5370                        landed.join(" and "),
5371                        recovery(&report, &before)?
5372                    ),
5373                });
5374            }
5375        };
5376        let mut moved = Vec::new();
5377        for field in &report.fields {
5378            let name = field.field.name();
5379            let now = after
5380                .fields
5381                .get(&field.field)
5382                .map(|held| held.options.as_slice())
5383                .unwrap_or_default();
5384            if !field.existing.iter().all(|old| now.contains(old)) {
5385                moved.push(format!(
5386                    "a pre-existing {name} option id, name, color or description"
5387                ));
5388            }
5389            if !field.missing.iter().all(|wanted| {
5390                now.iter()
5391                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5392            }) {
5393                moved.push(format!("an added {name} option"));
5394            }
5395            if after.assignments(field.field) != before.assignments(field.field) {
5396                moved.push(format!("an item's {name} value"));
5397            }
5398        }
5399        if !creates.is_empty() {
5400            // Read afresh: the view held for this command is the one from before the creates.
5401            *self.fields_cache()? = None;
5402            let board = match self.board_fields().await {
5403                Ok(board) => board,
5404                Err(error) => {
5405                    return Err(SourceError::Refused {
5406                        message: format!(
5407                            "the guarded field setup changed the {} field and then could not \
5408                             read the board back to verify it: {error}; run it again to verify \
5409                             it, which creates nothing a second time",
5410                            landed.join(" and ")
5411                        ),
5412                    });
5413                }
5414            };
5415            for field in &creates {
5416                let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
5417                    held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
5418                        && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
5419                });
5420                if !text {
5421                    moved.push(format!("the created {} text field", field.field));
5422                }
5423            }
5424        }
5425        if !moved.is_empty() {
5426            return Err(SourceError::Refused {
5427                message: format!(
5428                    "GitHub changed {} after the guarded field setup; the pre-write item \
5429                     assignments are:\n{}",
5430                    moved.join(", "),
5431                    recovery(&report, &before)?
5432                ),
5433            });
5434        }
5435        Ok(report)
5436    }
5437
5438    /// Validate configuration and capture the named credential without exposing it.
5439    ///
5440    /// # Errors
5441    ///
5442    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
5443    /// [`SourceError::Auth`] when the named credential is missing or empty.
5444    pub fn new(
5445        name: &SourceName,
5446        config: GitHubProjectsConfig,
5447        secrets: &dyn SecretResolver,
5448    ) -> Result<Self, SourceError> {
5449        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
5450    }
5451
5452    /// The same, recording every request it sends into an accounting the caller holds too.
5453    ///
5454    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
5455    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
5456    /// up — passes the one it records those into, so the session total accounts for the
5457    /// whole session rather than for this source's share of it.
5458    ///
5459    /// # Errors
5460    ///
5461    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
5462    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
5463    pub fn recording_into(
5464        name: &SourceName,
5465        config: GitHubProjectsConfig,
5466        secrets: &dyn SecretResolver,
5467        ledger: Arc<Accounting>,
5468    ) -> Result<Self, SourceError> {
5469        if !valid_github_owner(&config.owner) {
5470            return Err(SourceError::Config {
5471                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
5472            });
5473        }
5474        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
5475            return Err(SourceError::Config {
5476                message: format!("project_number must be between 1 and {}", i32::MAX),
5477            });
5478        }
5479        if !valid_environment_name(&config.token_env) {
5480            return Err(SourceError::Config {
5481                message: "token_env must be a valid environment-variable name".into(),
5482            });
5483        }
5484        let repository = config
5485            .repository
5486            .as_deref()
5487            .map(RepositoryTarget::parse)
5488            .transpose()?;
5489        if let Some(entry) = config
5490            .creation_owners
5491            .iter()
5492            .find(|entry| !valid_github_owner(entry))
5493        {
5494            return Err(SourceError::Config {
5495                message: format!(
5496                    "creation_owners entry {entry:?} is not a GitHub login: a login is 1-39 ASCII \
5497                     letters, digits, or single hyphens, and cannot start or end with a hyphen"
5498                ),
5499            });
5500        }
5501        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
5502            message: format!("endpoint is not a valid URL: {e}"),
5503        })?;
5504        if endpoint.scheme() != "https"
5505            && !(endpoint.scheme() == "http"
5506                && endpoint
5507                    .host_str()
5508                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
5509        {
5510            return Err(SourceError::Config {
5511                message:
5512                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
5513                        .into(),
5514            });
5515        }
5516        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
5517            message: format!("environment variable {} is missing or empty; set it to a fine-grained GitHub token granting Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board", config.token_env),
5518        })?;
5519        Ok(Self {
5520            name: name.clone(),
5521            owner: config.owner,
5522            project_number: config.project_number,
5523            repository,
5524            creation_owners: config.creation_owners,
5525            asset_client: std::sync::OnceLock::new(),
5526            body_markup: std::sync::OnceLock::new(),
5527            endpoint,
5528            token,
5529            credential_name: config.token_env,
5530            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
5531            priorities: config
5532                .priority_mapping
5533                .map(|mapping| PriorityMapping::resolve(mapping, name))
5534                .transpose()?,
5535            metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
5536            client: std::sync::OnceLock::new(),
5537            created: Mutex::new(Vec::new()),
5538            updated: Mutex::new(Vec::new()),
5539            commented: Mutex::new(Vec::new()),
5540            pacing: Pacing::resolve(config.pacing, name)?,
5541            last_mutation: Mutex::new(None),
5542            last_served: Mutex::new(None),
5543            windows: Mutex::new(pacing::Windows::default()),
5544            task_children: Mutex::new(BTreeMap::new()),
5545            written: Mutex::new(BTreeMap::new()),
5546            deferred: Mutex::new(None),
5547            held_updates: Mutex::new(Vec::new()),
5548            held_comments: Mutex::new(BTreeMap::new()),
5549            task_writes: Mutex::new(None),
5550            written_tokens: Mutex::new(BTreeMap::new()),
5551            clock: system_clock(),
5552            declared_private: std::sync::atomic::AtomicBool::new(false),
5553            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
5554            board_cache: Mutex::new(None),
5555            search_cache: Mutex::new(None),
5556            narrowed_cache: Mutex::new(BTreeMap::new()),
5557            resolved_cache: Mutex::new(BTreeMap::new()),
5558            read_ahead: Mutex::new(BTreeMap::new()),
5559            pending_origins: Mutex::new(Vec::new()),
5560            member_classes: Mutex::new(BTreeMap::new()),
5561            carried: Mutex::new(BTreeMap::new()),
5562            held_cache: Mutex::new(BTreeMap::new()),
5563            children_cache: Mutex::new(BTreeMap::new()),
5564            search_next: Mutex::new(BTreeMap::new()),
5565            fields_cache: Mutex::new(None),
5566            repository_cache: Mutex::new(BTreeMap::new()),
5567            ledger,
5568            expected: expected::Held::default(),
5569            projection: projection::Held::default(),
5570        })
5571    }
5572
5573    /// The client every API request goes through, built on the first one.
5574    ///
5575    /// Not with the source: building a client loads the system's certificate store, which
5576    /// costs tens of milliseconds, and a command that configures this board but never sends to
5577    /// it — a read of another source — should not pay that in its process.
5578    fn client(&self) -> Result<&Client, SourceError> {
5579        built(&self.client, || {
5580            Client::builder()
5581                .user_agent("onetaskgraph")
5582                .build()
5583                .map_err(|e| SourceError::Config {
5584                    message: format!("cannot build HTTP client: {e}"),
5585                })
5586        })
5587    }
5588
5589    /// The client attachment requests go through, with its own redirect policy, built on the
5590    /// first one for the reason [`Self::client`] gives.
5591    fn asset_client(&self) -> Result<&Client, SourceError> {
5592        built(&self.asset_client, || assets::client(&self.endpoint))
5593    }
5594
5595    /// A snapshot of every request this source has sent, and what each cost.
5596    ///
5597    /// A value to hold and compare rather than a borrow of the accounting itself, so two
5598    /// of them can sit side by side. When this source was built with
5599    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
5600    /// point of building it that way.
5601    #[must_use]
5602    pub fn accounting(&self) -> accounting::Session {
5603        self.ledger.snapshot()
5604    }
5605
5606    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
5607    /// rate limit rather than handing it straight back as an error.
5608    ///
5609    /// Retrying is safe for every document here, including the mutations, and the reason
5610    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
5611    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
5612    /// this replays has already taken effect. An outcome this source cannot know — the
5613    /// send failed, or the body could not be read, so the mutation may well have landed —
5614    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
5615    /// attempt. A duplicate write would come from replaying one of those, and none is
5616    /// replayed.
5617    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5618        // Updates held back go out before any other request, so whatever it reads or writes
5619        // sees the board as though each had been sent when it was made.
5620        Box::pin(self.send_held_updates()).await?;
5621        // Origins held back for a read ahead that never came are sent before anything else.
5622        if query != graphql::ORIGIN_LOOKUPS && query != graphql::ORIGIN_LOOKUPS_WITH_MEMBERS {
5623            Box::pin(self.send_pending_origins(None)).await?;
5624        }
5625        let journaling = self.expected.journaling();
5626        let spelling = self.spelling();
5627        if !journaling && !spelling {
5628            return self.graphql_sent(query, variables).await;
5629        }
5630        // While a write the store's cache expects runs, its mutations select the whole issue
5631        // they leave behind, so the response vouches for what the cache keeps — and every
5632        // request is recorded, so the write's parts are reported from their own responses.
5633        // A targeted update sends a twin of each selecting the issue's token parts inline, at
5634        // the same price, so the version it answers is the one its own last response spells.
5635        let (query, variables) = match (journaling, graphql::whole_twin(query)) {
5636            (true, Some(twin)) => (twin, expected::whole_variables(variables)),
5637            _ => match graphql::token_twin(query).filter(|_| spelling) {
5638                Some(twin) => (twin, token_variables(variables)),
5639                None => (query, variables),
5640            },
5641        };
5642        let sent = self.graphql_sent(query, variables.clone()).await;
5643        if spelling && let Ok(data) = &sent {
5644            self.record_written_token(query, &variables, data);
5645        }
5646        if journaling {
5647            self.journal(query, &variables, &sent).await;
5648        }
5649        sent
5650    }
5651
5652    /// Whether a targeted update of this source is running, whose mutations are each sent as
5653    /// the twin selecting the whole issue (see [`Self::written_tokens`]).
5654    fn spelling(&self) -> bool {
5655        SPELLING
5656            .try_with(|source| *source == std::ptr::from_ref(self) as usize)
5657            .unwrap_or(false)
5658    }
5659
5660    /// Record the change token the issue a mutation's response shows whole holds now, as
5661    /// [`TaskSource::written_task`] answers it; a response showing no whole issue records
5662    /// nothing.
5663    fn record_written_token(&self, document: &str, variables: &Value, data: &Value) {
5664        let (issue, _) = expected::read_back(document, variables, data);
5665        if let Some(issue) = issue {
5666            self.record_written_issue(issue);
5667        }
5668    }
5669
5670    /// Record the change token `issue`, as a write's own answer shows it whole, holds now — or,
5671    /// where that answer lacks a part of one, that it was shown and spells none.
5672    fn record_written_issue(&self, issue: &Value) {
5673        // An answer naming the issue by its id alone did not show it, so it says nothing.
5674        if issue.get("updatedAt").is_none() {
5675            return;
5676        }
5677        let Some(id) = issue.get("id").and_then(Value::as_str) else {
5678            return;
5679        };
5680        let (item, token) = match self.written_token_of(issue) {
5681            Some((item, token)) => (item, Some(token)),
5682            None => {
5683                // Answered whole, but without every part of a token: its board item's id, where
5684                // it says one, so a later write naming that item still drops the entry.
5685                let item = issue
5686                    .pointer("/projectItems/nodes")
5687                    .and_then(Value::as_array)
5688                    .and_then(|nodes| self.board_entry(nodes))
5689                    .and_then(|entry| entry.get("id"))
5690                    .and_then(Value::as_str)
5691                    .unwrap_or_default()
5692                    .to_owned();
5693                (item, None)
5694            }
5695        };
5696        if let Ok(mut tokens) = self.written_tokens.lock() {
5697            tokens.insert(NativeId(id.to_owned()), (item, token));
5698        }
5699    }
5700
5701    /// Record `token` as the change token the task `item` holds after this command's write.
5702    fn remember_written_token(&self, item: &Resolved, token: String) {
5703        if let Ok(mut tokens) = self.written_tokens.lock() {
5704            tokens.insert(item.id.clone(), (item.item_id.clone(), Some(token)));
5705        }
5706    }
5707
5708    /// The change token the task `id` holds after this command's latest write of it, when that
5709    /// write's own response — or the read made right after it — showed one.
5710    fn written_token(&self, id: &NativeId) -> Option<String> {
5711        self.written_answer(id).flatten()
5712    }
5713
5714    /// What this command's latest write of the task `id` showed of it: `Some` once a response
5715    /// showed the issue whole — its token, or `None` where that response could not spell one —
5716    /// and `None` where no response did.
5717    fn written_answer(&self, id: &NativeId) -> Option<Option<String>> {
5718        self.written_tokens
5719            .lock()
5720            .ok()?
5721            .get(id)
5722            .map(|(_, token)| token.clone())
5723    }
5724
5725    /// Drop the written token of every task `variables` names, by its id or its board item's,
5726    /// before a mutation that may move it is sent.
5727    fn forget_written_tokens(&self, variables: &Value) {
5728        let Ok(mut tokens) = self.written_tokens.lock() else {
5729            return;
5730        };
5731        if tokens.is_empty() {
5732            return;
5733        }
5734        let named: Vec<&str> = variables
5735            .as_object()
5736            .into_iter()
5737            .flat_map(|arguments| arguments.values())
5738            .flat_map(|input| {
5739                WRITTEN_NAMES
5740                    .iter()
5741                    .filter_map(|key| input.get(key).and_then(Value::as_str))
5742            })
5743            .collect();
5744        tokens.retain(|id, (item, _)| {
5745            !named
5746                .iter()
5747                .any(|name| *name == id.0 || *name == item.as_str())
5748        });
5749    }
5750
5751    /// One document sent, retried while GitHub asks for a wait, exactly as asked.
5752    async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5753        if is_mutation(query) {
5754            self.forget_written_tokens(&variables);
5755        }
5756        if is_mutation(query)
5757            && ![
5758                graphql::ADD_COMMENT,
5759                graphql::UPDATE_COMMENT,
5760                graphql::DELETE_COMMENT,
5761                graphql::ADD_COMMENT_WHOLE,
5762                graphql::UPDATE_COMMENT_WHOLE,
5763            ]
5764            .contains(&query)
5765        {
5766            let mut cache = self.resolved_cache()?;
5767            let mut ahead = self.read_ahead()?;
5768            let mut carried = self.carried()?;
5769            for argument in ["input", "second", "third", "clear"] {
5770                if let Some(input) = variables.get(argument) {
5771                    let names = |id: &NativeId, item_id: Option<&str>| {
5772                        ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
5773                            input
5774                                .get(key)
5775                                .and_then(Value::as_str)
5776                                .is_some_and(|value| value == id.0 || Some(value) == item_id)
5777                        })
5778                    };
5779                    cache.retain(|id, item| !names(id, Some(&item.item_id)));
5780                    ahead.retain(|id, item| {
5781                        !names(id, item.as_ref().map(|item| item.item_id.as_str()))
5782                    });
5783                    carried.retain(|id, item| !names(id, Some(&item.item_id)));
5784                }
5785            }
5786        }
5787        let doing = operation_description(query);
5788        let mutation = is_mutation(query);
5789        let charge = pacing::Charge::graphql(query, &variables);
5790        let mut waited = Duration::ZERO;
5791        let mut waits = 0_u32;
5792        let mut backoff = self.pacing.retry_backoff;
5793        loop {
5794            let ticket = self.pace(charge, mutation).await;
5795            // Every write but a create is asked for immediately before it is sent — a create's
5796            // fields, its relationships, its content and the delete that takes it back included
5797            // — so a copy that owns the create renews its hold first, and one that has lost it
5798            // sends nothing more. Asked again before a retry, which is sent later.
5799            if mutation
5800                && !query.contains("createIssue(")
5801                && let Err(refused) = <dyn onetaskgraph_plugin_api::WriteGuard>::check().await
5802            {
5803                self.paced(ticket, mutation);
5804                return Err(refused);
5805            }
5806            let attempt = self.send_once(query, &variables, waits + 1).await;
5807            self.paced(ticket, mutation);
5808            let remaining = self.pacing.retry_budget.saturating_sub(waited);
5809            let (wait, cause) = match attempt {
5810                Ok(data) => return Ok(data),
5811                Err(Attempt::Failed(error)) => return Err(error),
5812                Err(Attempt::Conflicted(said)) => {
5813                    // A create is never replayed: see `CONFLICT_WORDINGS`.
5814                    if query.contains("createIssue(") || backoff.is_zero() || backoff > remaining {
5815                        return Err(SourceError::Unavailable {
5816                            message: format!(
5817                                "GitHub refused this source while {doing} as a temporary \
5818                                 conflict ({said}); it retried {} over {} and the conflict \
5819                                 did not clear within the {} one call may spend waiting. \
5820                                 next: run the command again once nothing else is writing to \
5821                                 the same items",
5822                                plural(waits, "time"),
5823                                seconds(waited),
5824                                seconds(self.pacing.retry_budget),
5825                            ),
5826                        });
5827                    }
5828                    (backoff, pacing::cause::BACKOFF)
5829                }
5830                Err(Attempt::Limited(limited)) => {
5831                    let (wait, cause) = limited.wait(backoff);
5832                    // A wait of nothing spends none of the budget, so it is exhaustion rather
5833                    // than a retry. `Pacing::resolve` rules out every way of configuring one
5834                    // except a budget of zero, where reporting the first refusal is the ask.
5835                    if wait.is_zero() || wait > remaining {
5836                        return Err(limited.exhausted(
5837                            doing,
5838                            waits,
5839                            waited,
5840                            wait,
5841                            self.pacing.retry_budget,
5842                        ));
5843                    }
5844                    (wait, cause)
5845                }
5846            };
5847            self.clock.sleep_because(wait, cause).await;
5848            waited += wait;
5849            waits += 1;
5850            backoff = backoff.saturating_mul(2);
5851        }
5852    }
5853
5854    /// Hold one request charging `charge` until every secondary window it charges has room for
5855    /// the whole of it — and, for a mutation of an instance configured with
5856    /// `pacing.min_mutation_interval_ms`, until that interval has passed since the last one
5857    /// finished — then count it, answering the ticket [`Self::paced`] finishes it with.
5858    ///
5859    /// The moment is decided and counted under the lock and waited out outside it, so two
5860    /// callers take two places rather than the same one, and no lock is held across an await.
5861    /// A request with room goes out at once: nothing here waits that a window does not ask for.
5862    async fn pace(&self, charge: pacing::Charge, mutation: bool) -> pacing::Ticket {
5863        let (wait, cause, ticket) = {
5864            // A poisoned lock here costs pacing, not correctness, and refusing the write over
5865            // it would turn an earlier failure into a second one for no gain.
5866            let mut windows = self
5867                .windows
5868                .lock()
5869                .unwrap_or_else(std::sync::PoisonError::into_inner);
5870            let now = self.clock.now();
5871            let (mut at, mut cause) = if self.pacing.windows {
5872                windows.room(now, charge)
5873            } else {
5874                (now, None)
5875            };
5876            if mutation && !self.pacing.min_mutation_interval.is_zero() {
5877                let mut last = self
5878                    .last_mutation
5879                    .lock()
5880                    .unwrap_or_else(std::sync::PoisonError::into_inner);
5881                // `checked_add` rather than `+`: adding durations can panic on overflow, and
5882                // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
5883                if let Some(earliest) = last
5884                    .and_then(|previous| previous.checked_add(self.pacing.min_mutation_interval))
5885                    && earliest > at
5886                {
5887                    at = earliest;
5888                    cause = Some(pacing::cause::MIN_INTERVAL);
5889                }
5890                *last = Some(at);
5891            }
5892            let ticket = windows.charge(at, charge);
5893            (at.saturating_sub(now), cause, ticket)
5894        };
5895        if let (false, Some(cause)) = (wait.is_zero(), cause) {
5896            self.clock.sleep_because(wait, cause).await;
5897        }
5898        ticket
5899    }
5900
5901    /// Record that the request `ticket` was charged for has finished, so each window — and
5902    /// the configured spacing — counts it from here rather than from the moment it left.
5903    ///
5904    /// This source can only choose when a request *departs*; GitHub's limiter counts when it
5905    /// *arrives*, and the two differ by whatever the request spent in transit. Counting from
5906    /// departure would hand the limiter a window that looked roomier than it is, so a source
5907    /// pacing exactly at a limit could still be seen arriving over it — which is how a copy
5908    /// paced well inside a board's threshold was once refused by it on a slower machine while
5909    /// passing on a quick one. The previous request had arrived before its response came back,
5910    /// so counting from here is never earlier than the limiter does, whatever transit costs
5911    /// and on whatever platform: the safe side of a limit that punishes being wrong by refusing
5912    /// reads for the next fifty minutes.
5913    ///
5914    /// A failed attempt is finished too: a request refused by the limiter still arrived, and
5915    /// one that never left costs only room nobody needed.
5916    fn paced(&self, ticket: pacing::Ticket, mutation: bool) {
5917        let now = self.clock.now();
5918        self.windows
5919            .lock()
5920            .unwrap_or_else(std::sync::PoisonError::into_inner)
5921            .finish(ticket, now);
5922        if mutation && !self.pacing.min_mutation_interval.is_zero() {
5923            let mut last = self
5924                .last_mutation
5925                .lock()
5926                .unwrap_or_else(std::sync::PoisonError::into_inner);
5927            // `max` rather than an assignment: a concurrent caller may already have reserved a
5928            // slot further out, and completing this request must never pull that slot back in.
5929            *last = Some(last.map_or(now, |reserved| reserved.max(now)));
5930        }
5931    }
5932
5933    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
5934    /// failure that waiting cannot help — and recorded, whichever of the three it was.
5935    ///
5936    /// This is the one place a request leaves this crate, which is why the accounting is
5937    /// here rather than at each of the callers: a read path added later is counted without
5938    /// anybody remembering to count it, and
5939    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
5940    /// when one is not.
5941    async fn send_once(
5942        &self,
5943        query: &str,
5944        variables: &Value,
5945        number: u32,
5946    ) -> Result<Value, Attempt> {
5947        let executed = self.ledger.priced_document(query);
5948        let Attempted {
5949            result,
5950            limits,
5951            reported_cost,
5952            response_outcome,
5953        } = self.attempt(executed.as_ref(), variables).await;
5954        // No `otherwise` name: every document this source sends is one of its own, and the
5955        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
5956        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
5957        let outcome = match &result {
5958            Ok(_) => accounting::Outcome::Answered,
5959            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
5960            Err(Attempt::Failed(_) | Attempt::Conflicted(_)) => accounting::Outcome::Refused,
5961        };
5962        self.ledger
5963            .record(sending.finished(outcome, limits).with_execution(
5964                Some(executed.into_owned()),
5965                response_outcome,
5966                number,
5967            ));
5968        result
5969    }
5970
5971    /// The attempt itself, with what its response said about the rate limit alongside.
5972    ///
5973    /// The two are returned together rather than recorded here because every one of the
5974    /// early exits below is a different outcome, and a record written at each of them is a
5975    /// record one of them can be added without.
5976    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
5977        let mut limits = accounting::RateLimit::default();
5978        let mut reported_cost = None;
5979        let mut response_outcome = accounting::ResponseOutcome::TransportError;
5980        let result = self
5981            .attempted(
5982                query,
5983                variables,
5984                &mut limits,
5985                &mut reported_cost,
5986                &mut response_outcome,
5987            )
5988            .await;
5989        Attempted {
5990            result,
5991            limits,
5992            reported_cost,
5993            response_outcome,
5994        }
5995    }
5996
5997    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
5998    async fn attempted(
5999        &self,
6000        query: &str,
6001        variables: &Value,
6002        limits: &mut accounting::RateLimit,
6003        reported_cost: &mut Option<u64>,
6004        response_outcome: &mut accounting::ResponseOutcome,
6005    ) -> Result<Value, Attempt> {
6006        let response = self
6007            .client()
6008            .map_err(Attempt::Failed)?
6009            .post(self.endpoint.clone())
6010            .bearer_auth(self.token.expose_secret())
6011            .json(&json!({"query": query, "variables": variables}))
6012            .send()
6013            .await
6014            .map_err(|e| {
6015                Attempt::Failed(SourceError::Unavailable {
6016                    message: format!("GitHub GraphQL request failed: {}", transport_error(&e)),
6017                })
6018            })?;
6019        let status = response.status();
6020        if !status.is_success() {
6021            *response_outcome = accounting::ResponseOutcome::HttpError;
6022        }
6023        let header = |name: &str| whole_seconds(response.headers().get(name));
6024        let served = response
6025            .headers()
6026            .get(reqwest::header::DATE)
6027            .and_then(|value| value.to_str().ok())
6028            .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
6029            .map(|at| at.with_timezone(&Utc));
6030        *limits = accounting::RateLimit::read(|name| {
6031            response
6032                .headers()
6033                .get(name)
6034                .and_then(|value| value.to_str().ok())
6035                .map(str::to_owned)
6036        });
6037        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
6038        // that are not text at all — is "not known to be exhausted". This never makes a
6039        // response a refusal on its own: it says which limiter a refusal is attributed to
6040        // and where its hint comes from, so a value this cannot read costs a hint rather
6041        // than an answer.
6042        let exhausted = response
6043            .headers()
6044            .get("x-ratelimit-remaining")
6045            .and_then(|value| value.to_str().ok())
6046            == Some("0");
6047        // `retry-after` is what GitHub asks for when it asks; when it does not and the
6048        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
6049        // which is the same question answered as an absolute time. Nothing else here is a
6050        // hint, and a schedule is what answers a refusal that carries none.
6051        let retry_after = header("retry-after");
6052        let hinted_by = if retry_after.is_some() || !exhausted {
6053            Hint::RetryAfter
6054        } else {
6055            Hint::PrimaryReset
6056        };
6057        let hint = retry_after.or_else(|| {
6058            exhausted
6059                .then(|| header("x-ratelimit-reset"))
6060                .flatten()
6061                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
6062        });
6063        // Read before it is parsed, because the evidence which tells a secondary rate
6064        // limit from a rejected credential is in the body of a response whose status says
6065        // only "forbidden" — and a non-success response was never parsed at all.
6066        let body = response.text().await.map_err(|e| {
6067            Attempt::Failed(SourceError::Unavailable {
6068                message: format!(
6069                    "GitHub GraphQL response could not be read: {}",
6070                    transport_error(&e)
6071                ),
6072            })
6073        })?;
6074        *reported_cost = serde_json::from_str::<Value>(&body)
6075            .ok()
6076            .as_ref()
6077            .and_then(|body| body.pointer("/data/rateLimit/cost"))
6078            .and_then(Value::as_u64);
6079        let parsed = serde_json::from_str::<Value>(&body).ok();
6080        *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
6081        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
6082            return Err(Attempt::Limited(Limited {
6083                limiter,
6084                hint,
6085                hinted_by,
6086            }));
6087        }
6088        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
6089            return Err(Attempt::Failed(SourceError::Auth {
6090                message: format!(
6091                    "GitHub rejected the configured credential with HTTP {status}; grant it Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board"
6092                ),
6093            }));
6094        }
6095        if !status.is_success() {
6096            return Err(Attempt::Failed(SourceError::Unavailable {
6097                message: format!("GitHub GraphQL returned HTTP {status}"),
6098            }));
6099        }
6100        let said = refusal_wording(status, &body);
6101        let normalized = said.to_ascii_lowercase();
6102        if CONFLICT_WORDINGS
6103            .iter()
6104            .any(|wording| normalized.contains(wording))
6105        {
6106            return Err(Attempt::Conflicted(said));
6107        }
6108        let answered = if query == graphql::ISSUES_BY_REFERENCE {
6109            self.answer_unresolved(&body)
6110        } else if [
6111            graphql::CREATE_ISSUES,
6112            graphql::SEARCH_BATCH,
6113            graphql::ADD_COMMENTS,
6114            graphql::WRITE_TASKS,
6115        ]
6116        .contains(&query)
6117        {
6118            answer_with_errors(&body).map_or_else(|| self.answer(&body), Ok)
6119        } else {
6120            self.answer(&body)
6121        };
6122        let mut data = answered.map_err(Attempt::Failed)?;
6123        if let Some(served) = served {
6124            *self
6125                .last_served
6126                .lock()
6127                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(served);
6128            stamp_served(&mut data, &served.to_rfc3339());
6129        }
6130        Ok(data)
6131    }
6132
6133    /// What one successful HTTP response says, once its GraphQL errors are read.
6134    /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
6135    /// for this credential — a repository it cannot see, an issue number it does not hold —
6136    /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
6137    /// that reference rather than a failure of the others. Any other error refuses the read,
6138    /// as there.
6139    fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
6140        let parsed: Option<Value> = serde_json::from_str(body).ok();
6141        // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
6142        // `issue`, and the answer holds `null` at exactly that path: anything else — another
6143        // kind of error, one naming no slot, or one beside a value it says is not there — is a
6144        // refusal of the whole read.
6145        let unresolved = |data: &Value, error: &Value| {
6146            error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
6147                && error
6148                    .get("path")
6149                    .and_then(Value::as_array)
6150                    .is_some_and(|path| {
6151                        let alias = path.first().and_then(Value::as_str).and_then(|alias| {
6152                            alias
6153                                .strip_prefix('r')
6154                                .and_then(|slot| slot.parse::<usize>().ok())
6155                                .filter(|slot| *slot < REFERENCE_BATCH)
6156                                .map(|_| alias)
6157                        });
6158                        match (alias, path.get(1).and_then(Value::as_str), path.len()) {
6159                            (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
6160                            (Some(alias), Some("issue"), 2) => data
6161                                .get(alias)
6162                                .and_then(|repository| repository.get("issue"))
6163                                .is_some_and(Value::is_null),
6164                            _ => false,
6165                        }
6166                    })
6167        };
6168        let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
6169            parsed.get("data").is_some_and(|data| {
6170                data.is_object()
6171                    && parsed
6172                        .get("errors")
6173                        .and_then(Value::as_array)
6174                        .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
6175            })
6176        });
6177        match parsed {
6178            Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
6179            _ => self.answer(body),
6180        }
6181    }
6182
6183    fn answer(&self, body: &str) -> Result<Value, SourceError> {
6184        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
6185            message: format!("GitHub returned invalid JSON: {e}"),
6186        })?;
6187        let errors = body
6188            .get("errors")
6189            .map(|value| {
6190                value.as_array().ok_or_else(|| SourceError::Malformed {
6191                    message: "GitHub response errors is not an array".into(),
6192                })
6193            })
6194            .transpose()?;
6195        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
6196            let messages = errors
6197                .iter()
6198                .filter_map(|e| e.get("message").and_then(Value::as_str))
6199                .collect::<Vec<_>>()
6200                .join("; ");
6201            let message = if messages.is_empty() {
6202                "GitHub returned GraphQL errors".into()
6203            } else {
6204                messages
6205            };
6206            let normalized = message.to_ascii_lowercase();
6207            if normalized.contains("resource not accessible") || normalized.contains("scope") {
6208                return Err(SourceError::Auth {
6209                    message: format!(
6210                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
6211                        self.credential_name
6212                    ),
6213                });
6214            }
6215            return Err(SourceError::Refused { message });
6216        }
6217        body.get("data")
6218            .filter(|data| data.is_object())
6219            .cloned()
6220            .ok_or_else(|| SourceError::Malformed {
6221                message: "GitHub response has no data object".into(),
6222            })
6223    }
6224
6225    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
6226    // GraphQL cannot independently page them inside the outer item page. This source page is
6227    // deliberately bounded at that published maximum; the live drift journey exercises it.
6228    async fn board_page(
6229        &self,
6230        items_after: Option<&str>,
6231        items_first: u32,
6232    ) -> Result<Value, SourceError> {
6233        let data = self
6234            .graphql(
6235                graphql::BOARD,
6236                json!({"owner":self.owner,"number":self.project_number,
6237                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
6238                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
6239            )
6240            .await?;
6241        data.pointer("/owner/projectV2")
6242            .filter(|v| !v.is_null())
6243            .cloned()
6244            .ok_or_else(|| SourceError::Refused {
6245                message: format!(
6246                    "GitHub project {}/{} was not found or is not visible to the token",
6247                    self.owner, self.project_number
6248                ),
6249            })
6250    }
6251
6252    /// The search that finds the issues of this board, narrowed by `also` when it is
6253    /// given.
6254    ///
6255    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
6256    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
6257    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
6258    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
6259    /// from a task by the `parent` field each issue carries rather than by the search.
6260    fn board_search(&self, also: Option<&str>) -> String {
6261        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
6262        match also {
6263            Some(also) => format!("{scope} {also}"),
6264            None => scope,
6265        }
6266    }
6267
6268    /// One issue this source reached directly, as the board item a read of the board would
6269    /// have produced — or `None` when this board does not hold it.
6270    ///
6271    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
6272    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
6273    /// item's own id, that item's field values, and the issue as its content. One resolver
6274    /// for both routes is what makes an issue read through a search, through its own node
6275    /// id, or through its project's sub-issues report the same title, the same status, the
6276    /// same labels and the same qualified id.
6277    ///
6278    /// An issue with no entry for *this* board is not this source's to report, which is
6279    /// what keeps an id naming some other repository's issue from being answered as an item
6280    /// of this board. That answer is given about an **exhausted** connection and never
6281    /// about an unread page: the entry is looked for on the page in hand, and only if that
6282    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
6283    /// rest of it.
6284    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
6285        self.resolve_issue_as(issue, true).await
6286    }
6287
6288    /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
6289    /// and writes only when `remember` says to.
6290    async fn resolve_issue_as(
6291        &self,
6292        issue: &Value,
6293        remember: bool,
6294    ) -> Result<Option<Resolved>, SourceError> {
6295        if optional_str(issue, "__typename")? != Some("Issue") {
6296            return Ok(None);
6297        }
6298        let memberships = issue
6299            .get("projectItems")
6300            .ok_or_else(|| SourceError::Malformed {
6301                message: "GitHub issue is missing projectItems".into(),
6302            })?;
6303        let nodes = memberships
6304            .get("nodes")
6305            .and_then(Value::as_array)
6306            .ok_or_else(|| SourceError::Malformed {
6307                message: "GitHub issue projectItems.nodes is not an array".into(),
6308            })?;
6309        let held = match self.board_entry(nodes) {
6310            Some(held) => held.clone(),
6311            None => {
6312                let info = memberships
6313                    .get("pageInfo")
6314                    .ok_or_else(|| SourceError::Malformed {
6315                        message: "GitHub issue projectItems has no pageInfo".into(),
6316                    })?;
6317                // The page held no entry for this board. Whether that means the issue is
6318                // not on it is a question about the rest of the connection, and only a
6319                // connection with no rest answers it here.
6320                if !required_bool(info, "hasNextPage")? {
6321                    return Ok(None);
6322                }
6323                let cursor = required_str(info, "endCursor")?;
6324                validate_cursor_progress(None, cursor)?;
6325                let issue_id = required_str(issue, "id")?;
6326                match self.board_membership(issue_id, cursor).await? {
6327                    Some(held) => held,
6328                    None => return Ok(None),
6329                }
6330            }
6331        };
6332        // The membership's own `updatedAt` rides along: it is the board half of the item's change
6333        // token, which a field change moves where the issue's own time does not.
6334        let item = json!({
6335            "id": required_str(&held, "id")?,
6336            "updatedAt": held.get("updatedAt"),
6337            "project": held.get("project"),
6338            "fieldValues": held.get("fieldValues"),
6339            "content": issue,
6340        });
6341        if remember {
6342            self.resolve(&item)
6343        } else {
6344            self.resolve_unremembered(&item)
6345        }
6346    }
6347
6348    /// This board's own entry among one page of an issue's `Issue.projectItems`.
6349    ///
6350    /// One spelling of *which membership is this board's*, so the page a read carries and
6351    /// the pages [`Self::board_membership`] walks are searched by the same rule.
6352    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
6353        nodes.iter().find(|node| {
6354            node.pointer("/project/number").and_then(Value::as_u64)
6355                == Some(u64::from(self.project_number))
6356        })
6357    }
6358
6359    /// The rest of one issue's board memberships, from `after`, for this board's entry.
6360    ///
6361    /// The recovery read: a page of memberships that holds no entry for this board says
6362    /// nothing about the memberships past it, so the connection is walked to exhaustion
6363    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
6364    /// positive answer — the whole connection was read and no entry named this board —
6365    /// rather than a failure, and the walk is held to
6366    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
6367    /// with a cursor that does not advance is refused instead of spun on.
6368    async fn board_membership(
6369        &self,
6370        issue: &str,
6371        after: &str,
6372    ) -> Result<Option<Value>, SourceError> {
6373        let mut after = after.to_owned();
6374        loop {
6375            let data = self
6376                .graphql(
6377                    graphql::ISSUE_BOARD_ITEMS,
6378                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
6379                           "nestedFirst":NESTED_PAGE_SIZE}),
6380                )
6381                .await?;
6382            let Some(connection) = data
6383                .pointer("/node/projectItems")
6384                .filter(|value| !value.is_null())
6385            else {
6386                // The id resolved to nothing, or to something with no memberships to walk —
6387                // which is the same answer as a connection holding no entry for this board.
6388                return Ok(None);
6389            };
6390            let nodes = connection
6391                .get("nodes")
6392                .and_then(Value::as_array)
6393                .ok_or_else(|| SourceError::Malformed {
6394                    message: "GitHub issue projectItems.nodes is not an array".into(),
6395                })?;
6396            if let Some(held) = self.board_entry(nodes) {
6397                return Ok(Some(held.clone()));
6398            }
6399            let info = connection
6400                .get("pageInfo")
6401                .ok_or_else(|| SourceError::Malformed {
6402                    message: "GitHub issue projectItems has no pageInfo".into(),
6403                })?;
6404            let next = required_bool(info, "hasNextPage")?
6405                .then(|| required_str(info, "endCursor"))
6406                .transpose()?;
6407            match next {
6408                Some(next) => {
6409                    validate_cursor_progress(Some(&after), next)?;
6410                    after = next.to_owned();
6411                }
6412                None => return Ok(None),
6413            }
6414        }
6415    }
6416
6417    /// One page of a board-scoped issue search, and where the next page resumes.
6418    async fn search_page(
6419        &self,
6420        search: &str,
6421        first: u32,
6422        after: Option<&str>,
6423    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
6424        let data = self
6425            .graphql(
6426                graphql::SEARCH_ISSUES,
6427                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
6428                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
6429                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6430            )
6431            .await?;
6432        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
6433            message: "GitHub search response has no search connection".into(),
6434        })?;
6435        let mut found = Vec::new();
6436        for node in connection
6437            .get("nodes")
6438            .and_then(Value::as_array)
6439            .ok_or_else(|| SourceError::Malformed {
6440                message: "GitHub search nodes is not an array".into(),
6441            })?
6442        {
6443            if let Some(resolved) = self.resolve_issue(node).await? {
6444                found.push(resolved);
6445            }
6446        }
6447        let info = connection
6448            .get("pageInfo")
6449            .ok_or_else(|| SourceError::Malformed {
6450                message: "GitHub search connection has no pageInfo".into(),
6451            })?;
6452        let next = required_bool(info, "hasNextPage")?
6453            .then(|| required_str(info, "endCursor"))
6454            .transpose()?
6455            .map(str::to_owned);
6456        if let Some(next) = &next {
6457            validate_cursor_progress(after, next)?;
6458        }
6459        Ok((found, next))
6460    }
6461
6462    /// Every issue this board holds, completed with what this run wrote.
6463    ///
6464    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
6465    /// is an index and is eventually consistent, so an issue this run created seconds ago
6466    /// can be absent from it, and a project listed straight after being written would
6467    /// otherwise be missing from its own board. What is added back is only what this
6468    /// process itself wrote, out of [`Self::created`], which lives and dies with the
6469    /// process.
6470    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6471        let found = self.searched_issues().await?;
6472        self.completed_with_written(found, |_| true)
6473    }
6474
6475    /// Every issue this board's own search reports, walked to exhaustion, read once per
6476    /// source.
6477    ///
6478    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
6479    /// needs it too and the two would otherwise walk the same search twice in one command.
6480    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
6481    /// is.
6482    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6483        let cached = self.search_cache()?.clone();
6484        if let Some(held) = cached {
6485            return Ok(held);
6486        }
6487        let mut after: Option<String> = None;
6488        let mut found = Vec::new();
6489        let search = self.board_search(None);
6490        loop {
6491            let (page, next) = self
6492                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6493                .await?;
6494            found.extend(page);
6495            match next {
6496                Some(next) => after = Some(next),
6497                None => break,
6498            }
6499        }
6500        *self.search_cache()? = Some(found.clone());
6501        Ok(found)
6502    }
6503
6504    /// Every issue this board's own search reports with `also` beside its scope, walked to
6505    /// exhaustion — held for no later read, which may not be narrowed alike.
6506    async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6507        let mut after: Option<String> = None;
6508        let mut found = Vec::new();
6509        let search = self.board_search(Some(also));
6510        loop {
6511            let (page, next) = self
6512                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6513                .await?;
6514            found.extend(page);
6515            match next {
6516                Some(next) => after = Some(next),
6517                None => return Ok(found),
6518            }
6519        }
6520    }
6521
6522    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
6523    fn search_cache(
6524        &self,
6525    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
6526        self.search_cache
6527            .lock()
6528            .map_err(|_| SourceError::Unavailable {
6529                message: "this source's view of the board's issues was left inconsistent by an \
6530                      earlier failure; next: run the command again"
6531                    .into(),
6532            })
6533    }
6534
6535    /// `found`, with everything this run wrote that `keep` accepts and the read did not
6536    /// report.
6537    ///
6538    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
6539    /// at all: the search index is behind, and a node read of an item filed moments ago can
6540    /// be too.
6541    fn completed_with_written(
6542        &self,
6543        mut found: Vec<Resolved>,
6544        keep: impl Fn(&Resolved) -> bool,
6545    ) -> Result<Vec<Resolved>, SourceError> {
6546        for own in self.created()?.iter().filter(|own| keep(own)) {
6547            if !found.iter().any(|item| item.id == own.id) {
6548                found.push(own.clone());
6549            }
6550        }
6551        Ok(found)
6552    }
6553
6554    /// What resolving one node id reached.
6555    ///
6556    /// Three answers rather than an `Option`, because a board *draft* is none of the other
6557    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
6558    /// is completed by a read of the draft itself rather than reported as nothing.
6559    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
6560        let asked = self
6561            .graphql(
6562                graphql::ISSUE,
6563                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
6564                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6565            )
6566            .await;
6567        let data = match asked {
6568            Ok(data) => data,
6569            // A string that is not a node id at all is not a failure to report: it is an id
6570            // this board does not hold, which is what every read of one already answers.
6571            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
6572            Err(error) => return Err(error),
6573        };
6574        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6575            return Ok(Reached::Nothing);
6576        };
6577        if optional_str(node, "__typename")? == Some("DraftIssue") {
6578            return Ok(Reached::Draft);
6579        }
6580        Ok(match self.resolve_issue(node).await? {
6581            Some(item) => Reached::Held(Box::new(item)),
6582            None => Reached::Nothing,
6583        })
6584    }
6585
6586    /// One item of this board by its own id, whatever kind it is.
6587    ///
6588    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
6589    /// run wrote is read first, because a node read of an item created moments ago can
6590    /// still be behind the board field values written onto it — see [`Self::created`].
6591    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6592        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
6593            return Ok(Some(own.clone()));
6594        }
6595        if let Some(held) = self.read_ahead()?.remove(id) {
6596            return Ok(held);
6597        }
6598        match self.reach(id).await? {
6599            Reached::Held(item) => Ok(Some(*item)),
6600            Reached::Nothing => Ok(None),
6601            Reached::Draft => self.draft_by_id(id).await,
6602        }
6603    }
6604
6605    /// [`Self::item_by_id`] for a document: one [`graphql::DOCUMENT_IN_PROJECT`] read, which
6606    /// answers the project it is filed under and that project's members beside it.
6607    ///
6608    /// Each is held for this command as a read of it would have been: the project for the
6609    /// next read of it by its id, and — when its page of members is the whole of them — the
6610    /// members for the next listing of that project. So a write held to who may read the
6611    /// document, which reads all three, is answered by this one request.
6612    async fn document_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6613        if self.created()?.iter().any(|own| own.id == *id) || self.read_ahead()?.contains_key(id) {
6614            return self.item_by_id(id).await;
6615        }
6616        let asked = self
6617            .graphql(
6618                graphql::DOCUMENT_IN_PROJECT,
6619                json!({"id":id.0,"first":MAX_PAGE_SIZE,"memberFirst":MEMBER_PAGE_SIZE,
6620                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6621                       "duplicates":true}),
6622            )
6623            .await;
6624        let data = match asked {
6625            Ok(data) => data,
6626            Err(error) if unresolvable_node(&error) => return Ok(None),
6627            Err(error) => return Err(error),
6628        };
6629        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6630            return Ok(None);
6631        };
6632        if optional_str(node, "__typename")? == Some("DraftIssue") {
6633            return self.draft_by_id(id).await;
6634        }
6635        let Some(item) = self.resolve_issue(node).await? else {
6636            return Ok(None);
6637        };
6638        // An answer for another issue than the one asked about says nothing of that one.
6639        if item.id != *id {
6640            return Err(SourceError::Malformed {
6641                message: format!(
6642                    "GitHub answered issue {} when asked for document {}",
6643                    item.id.0, id.0
6644                ),
6645            });
6646        }
6647        if let Some(project) = node.get("project").filter(|value| !value.is_null())
6648            && let Some(parent) = self
6649                .resolve_issue(project)
6650                .await?
6651                .filter(|parent| Some(&parent.id) == item.parent.as_ref())
6652        {
6653            let members = project.get("subIssues").filter(|value| !value.is_null());
6654            let whole = members
6655                .and_then(|members| members.pointer("/pageInfo/hasNextPage"))
6656                .and_then(Value::as_bool)
6657                == Some(false);
6658            if whole {
6659                let nodes = members
6660                    .and_then(|members| members.get("nodes"))
6661                    .and_then(Value::as_array)
6662                    .ok_or_else(|| SourceError::Malformed {
6663                        message: "GitHub subIssues.nodes is not an array".into(),
6664                    })?;
6665                let mut children = Vec::new();
6666                for member in nodes {
6667                    if let Some(resolved) = self.resolve_issue(member).await? {
6668                        children.push(resolved);
6669                    }
6670                }
6671                // Held as the project's members only when every one resolved and says it is
6672                // filed under it: a page with any other answers nothing of all the members.
6673                if children.len() == nodes.len()
6674                    && children
6675                        .iter()
6676                        .all(|child| child.parent.as_ref() == Some(&parent.id))
6677                {
6678                    self.children_cache()?
6679                        .insert(parent.id.clone(), (parent.id.clone(), children));
6680                }
6681            }
6682            self.read_ahead()?.insert(parent.id.clone(), Some(parent));
6683        }
6684        Ok(Some(item))
6685    }
6686
6687    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
6688    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
6689    /// than one request per id.
6690    ///
6691    /// What this run wrote answers first, as it does there, and only the rest is read. One id
6692    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
6693    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
6694    /// id at a time, so that id is answered as not held and the others as themselves; a draft
6695    /// is completed by a read of the draft, exactly as there.
6696    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
6697        let mut found: Vec<Option<Option<Resolved>>> = {
6698            let created = self.created()?;
6699            ids.iter()
6700                .map(|id| {
6701                    created
6702                        .iter()
6703                        .find(|own| own.id == *id)
6704                        .map(|own| Some(own.clone()))
6705                })
6706                .collect()
6707        };
6708        let unread: Vec<NativeId> = ids
6709            .iter()
6710            .zip(&found)
6711            .filter(|(_, found)| found.is_none())
6712            .map(|(id, _)| id.clone())
6713            .collect();
6714        let mut read = Vec::with_capacity(unread.len());
6715        if let [one] = unread.as_slice() {
6716            read.push(self.item_by_id(one).await?);
6717        } else {
6718            for batch in unread.chunks(DETAIL_BATCH) {
6719                let data = match self
6720                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
6721                    .await
6722                {
6723                    Ok(data) => data,
6724                    Err(error) if unresolvable_node(&error) => {
6725                        for id in batch {
6726                            read.push(self.item_by_id(id).await?);
6727                        }
6728                        continue;
6729                    }
6730                    Err(error) => return Err(error),
6731                };
6732                for (slot, id) in batch.iter().enumerate() {
6733                    let node =
6734                        data.get(format!("i{slot}"))
6735                            .ok_or_else(|| SourceError::Malformed {
6736                                message: format!(
6737                                    "GitHub answered a batch read with no item for {}",
6738                                    id.0
6739                                ),
6740                            })?;
6741                    read.push(if node.is_null() {
6742                        None
6743                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
6744                        self.draft_by_id(id).await?
6745                    } else {
6746                        if optional_str(node, "__typename")? == Some("Issue")
6747                            && required_str(node, "id")? != id.0
6748                        {
6749                            return Err(SourceError::Malformed {
6750                                message: format!(
6751                                    "GitHub answered the read of {} with issue {}",
6752                                    id.0,
6753                                    required_str(node, "id")?
6754                                ),
6755                            });
6756                        }
6757                        self.resolve_issue(node).await?
6758                    });
6759                }
6760            }
6761        }
6762        let mut read = read.into_iter();
6763        Ok(found
6764            .iter_mut()
6765            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
6766            .collect())
6767    }
6768
6769    fn read_ahead(
6770        &self,
6771    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
6772        self.read_ahead
6773            .lock()
6774            .map_err(|_| SourceError::Unavailable {
6775                message:
6776                    "items read ahead for a copy were left inconsistent; run the command again"
6777                        .into(),
6778            })
6779    }
6780
6781    fn resolved_cache(
6782        &self,
6783    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
6784        self.resolved_cache
6785            .lock()
6786            .map_err(|_| SourceError::Unavailable {
6787                message: "resolved item records were left inconsistent; run the command again"
6788                    .into(),
6789            })
6790    }
6791
6792    /// Reuse a record this invocation already resolved. The mutation sender invalidates
6793    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
6794    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6795        let cached = self.resolved_cache()?.get(id).cloned();
6796        if let Some(item) = cached {
6797            return Ok(Some(item));
6798        }
6799        self.item_by_id(id).await
6800    }
6801
6802    /// Take out the item an issue read by reference reached for `id`, when one is held: the
6803    /// status write it was read for is the one caller, so no other read or write is ever
6804    /// answered from a read that selected only what that write needs.
6805    fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6806        Ok(self
6807            .held_cache
6808            .lock()
6809            .map_err(|_| SourceError::Unavailable {
6810                message: "items read by issue reference were left inconsistent; run the \
6811                          command again"
6812                    .into(),
6813            })?
6814            .remove(id))
6815    }
6816
6817    /// One board draft by its own id, with the board item it sits in — or `None` when no
6818    /// item of this board is that draft's.
6819    ///
6820    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
6821    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
6822    /// links a draft to one board item, so the page this read carries is the whole of that
6823    /// connection, and a page that reports more than it holds is refused rather than read
6824    /// as an answer about memberships nobody read.
6825    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6826        let data = self
6827            .graphql(
6828                graphql::DRAFT,
6829                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
6830                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
6831            )
6832            .await?;
6833        // Gone between the two reads is an answer — the draft is no longer there. Anything
6834        // else than the draft [`Self::reach`] was just told this id is, is not one.
6835        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
6836            return Ok(None);
6837        };
6838        if optional_str(draft, "__typename")? != Some("DraftIssue") {
6839            return Err(SourceError::Malformed {
6840                message: format!(
6841                    "GitHub answered {} as a draft and then as something else",
6842                    id.0
6843                ),
6844            });
6845        }
6846        if required_str(draft, "id")? != id.0 {
6847            return Err(SourceError::Malformed {
6848                message: format!("GitHub answered a different draft for {}", id.0),
6849            });
6850        }
6851        let memberships = draft
6852            .get("projectV2Items")
6853            .ok_or_else(|| SourceError::Malformed {
6854                message: format!("GitHub draft {} is missing projectV2Items", id.0),
6855            })?;
6856        let nodes = memberships
6857            .get("nodes")
6858            .and_then(Value::as_array)
6859            .ok_or_else(|| SourceError::Malformed {
6860                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
6861            })?;
6862        let info = memberships
6863            .get("pageInfo")
6864            .ok_or_else(|| SourceError::Malformed {
6865                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
6866            })?;
6867        // Read whether or not this board's entry is on the page: a page claiming more than
6868        // the one item GitHub links a draft to is a malformed answer either way.
6869        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
6870            return Err(SourceError::Malformed {
6871                message: format!(
6872                    "GitHub draft {} reports more board items than the one GitHub links a draft \
6873                     to",
6874                    id.0
6875                ),
6876            });
6877        }
6878        if let Some(node) = nodes.first()
6879            && node
6880                .pointer("/project/number")
6881                .and_then(Value::as_u64)
6882                .is_none()
6883        {
6884            return Err(SourceError::Malformed {
6885                message: format!(
6886                    "GitHub draft {} board item has no numeric project number",
6887                    id.0
6888                ),
6889            });
6890        }
6891        let Some(held) = self.board_entry(nodes) else {
6892            return Ok(None);
6893        };
6894        if required_str(
6895            held.get("project").ok_or_else(|| SourceError::Malformed {
6896                message: format!("GitHub draft {} board item has no project", id.0),
6897            })?,
6898            "id",
6899        )? != self.board_fields().await?.id.as_str()
6900        {
6901            return Ok(None);
6902        }
6903        let item = json!({
6904            "id": required_str(held, "id")?,
6905            "project": held.get("project"),
6906            "fieldValues": held.get("fieldValues"),
6907            "content": draft,
6908        });
6909        self.resolve(&item)
6910    }
6911
6912    /// The board's own id and field definitions, for a write whose item does not carry
6913    /// them — never its items.
6914    ///
6915    /// A board this command has already listed supplies them, since it read them beside its
6916    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
6917    /// is consulted about which items the board holds: see the module documentation for
6918    /// why a question about one known item is answered by reading that item.
6919    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
6920        if let Some(board) = self.board_cache()?.as_ref() {
6921            return Ok(BoardFields {
6922                id: BoardId::parse(&board.id)?,
6923                fields: board.fields.clone(),
6924            });
6925        }
6926        if let Some(held) = self.fields_cache()?.clone() {
6927            return Ok(held);
6928        }
6929        let data = self
6930            .graphql(
6931                graphql::BOARD_FIELDS,
6932                json!({"owner":self.owner,"number":self.project_number,
6933                       "nestedFirst":NESTED_PAGE_SIZE}),
6934            )
6935            .await?;
6936        self.fields_read(&data)
6937    }
6938
6939    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
6940    /// the rest of this command.
6941    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
6942        let board = data
6943            .pointer("/boardFields/projectV2")
6944            .filter(|value| !value.is_null())
6945            .ok_or_else(|| SourceError::Refused {
6946                message: format!(
6947                    "GitHub project {}/{} was not found or is not visible to the token",
6948                    self.owner, self.project_number
6949                ),
6950            })?;
6951        let read = BoardFields {
6952            id: BoardId::parse(required_str(board, "id")?)?,
6953            fields: board.get("fields").cloned().unwrap_or(Value::Null),
6954        };
6955        *self.fields_cache()? = Some(read.clone());
6956        Ok(read)
6957    }
6958
6959    /// Read what creating an issue in `repository` needs and this command has not read yet —
6960    /// the board's fields and the repository's node id — in one request when it needs both.
6961    ///
6962    /// When either is already known this sends nothing, and the other is read by its own
6963    /// document where it is asked for, so no create reads anything twice.
6964    async fn creation_context(
6965        &self,
6966        repository: &RepositoryTarget,
6967        incoming: &Incoming<'_>,
6968    ) -> Result<(), SourceError> {
6969        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
6970        if fields_known || self.repository_cache()?.contains_key(repository) {
6971            return Ok(());
6972        }
6973        // A placement an earlier process kept is taken only while the project issue this create
6974        // has just read shows its setup-context token unchanged; see `projection`.
6975        if self.placed_by_kept(repository, incoming.parent) {
6976            return Ok(());
6977        }
6978        let data = self
6979            .graphql(
6980                graphql::CREATION_CONTEXT,
6981                json!({"owner":self.owner,"number":self.project_number,
6982                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
6983                       "repositoryName":repository.name}),
6984            )
6985            .await?;
6986        self.fields_read(&data)?;
6987        self.repository_read(&data, repository, incoming)?;
6988        Ok(())
6989    }
6990
6991    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
6992    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
6993        self.fields_cache
6994            .lock()
6995            .map_err(|_| SourceError::Unavailable {
6996                message: "this source's view of the board's fields was left inconsistent by an \
6997                      earlier failure; next: run the command again"
6998                    .into(),
6999            })
7000    }
7001
7002    /// What a write to `item` needs of the board, read off that item when it says enough and
7003    /// off [`Self::board_fields`] when it does not.
7004    ///
7005    /// A node read of an item names its board and carries the definition of every field it
7006    /// holds a value of — so an item naming its board, holding a value of the origin field
7007    /// when the write carries an origin, and, when the write carries a status, holding a
7008    /// `Status` value, needs no read of the board at all. **Nothing the item does not say is
7009    /// guessed:** a field it holds no value of may still be on the board, and a view reading it
7010    /// as absent would refuse a write the board can take or skip a field write the board
7011    /// needs, so such an item — and a create, which has no item yet — takes the board's fields
7012    /// from their own read instead. A write carrying no origin to an item holding none — every
7013    /// write out of a source declared private — writes no origin whatever the board defines,
7014    /// so it needs no definition of that field.
7015    async fn fields_for(
7016        &self,
7017        item: Option<&Resolved>,
7018        writes_origin: bool,
7019        writes_status: bool,
7020        selects_priority: bool,
7021    ) -> Result<BoardFields, SourceError> {
7022        if let Some(board) = item.and_then(Resolved::carried_board) {
7023            return Ok(board);
7024        }
7025        if let Some(item) = item
7026            && let Some(board_id) = item.named_board()
7027            && (!writes_origin || item.defines(ORIGIN_FIELD))
7028            && (!writes_status || item.defines("Status"))
7029            && (!selects_priority || item.defines(PRIORITY_FIELD))
7030            && self
7031                .metadata_fields
7032                .iter()
7033                .all(|projection| item.defines(&projection.field))
7034        {
7035            return Ok(BoardFields {
7036                id: board_id,
7037                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7038            });
7039        }
7040        self.board_fields().await
7041    }
7042
7043    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
7044    /// when that id names nothing here with a sub-issue relationship to walk.
7045    ///
7046    /// `None` and an empty answer are different: `None` is *this is not an issue of this
7047    /// GitHub*, which is what sends a project selector on to be read as a name, and an
7048    /// empty vector is a project that holds nothing.
7049    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7050        let mut after: Option<String> = None;
7051        let mut children = Vec::new();
7052        // The project issue as the first page carried it, held once the walk has ended.
7053        let mut parent: Option<Resolved> = None;
7054        loop {
7055            let asked = self
7056                .graphql(
7057                    graphql::SUB_ISSUES,
7058                    json!({"id":id.0,"memberFirst":MEMBER_PAGE_SIZE,"after":after,
7059                           "nestedFirst":NESTED_PAGE_SIZE,
7060                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
7061                )
7062                .await;
7063            let data = match asked {
7064                Ok(data) => data,
7065                // A string that is not a node id at all is not a failure to report: it is
7066                // the ordinary answer to a selector naming a project by its name.
7067                Err(error) if unresolvable_node(&error) => return Ok(None),
7068                Err(error) => return Err(error),
7069            };
7070            let Some(connection) = data
7071                .pointer("/node/subIssues")
7072                .filter(|value| !value.is_null())
7073            else {
7074                // No such node, or one with no sub-issue relationship — a board draft is
7075                // the one this board can really hold.
7076                return Ok(None);
7077            };
7078            if after.is_none()
7079                && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
7080            {
7081                parent = self
7082                    .resolve_issue(node)
7083                    .await?
7084                    .filter(|resolved| resolved.id == *id);
7085            }
7086            for node in connection
7087                .get("nodes")
7088                .and_then(Value::as_array)
7089                .ok_or_else(|| SourceError::Malformed {
7090                    message: "GitHub subIssues.nodes is not an array".into(),
7091                })?
7092            {
7093                if let Some(resolved) = self.resolve_issue(node).await? {
7094                    children.push(resolved);
7095                }
7096            }
7097            let info = connection
7098                .get("pageInfo")
7099                .ok_or_else(|| SourceError::Malformed {
7100                    message: "GitHub subIssues connection has no pageInfo".into(),
7101                })?;
7102            let next = required_bool(info, "hasNextPage")?
7103                .then(|| required_str(info, "endCursor"))
7104                .transpose()?;
7105            match next {
7106                Some(next) => {
7107                    validate_cursor_progress(after.as_deref(), next)?;
7108                    after = Some(next.to_owned());
7109                }
7110                None => {
7111                    // Held only once the walk has ended, so one failing part way holds
7112                    // nothing and every read it would have answered is made.
7113                    // The project issue the first page carried answers the next read of the
7114                    // project by its id in this command, as a read of it made now would: what
7115                    // classifying a project for a write reads straight after its members.
7116                    if let Some(parent) = &parent {
7117                        self.read_ahead()?
7118                            .entry(parent.id.clone())
7119                            .or_insert_with(|| Some(parent.clone()));
7120                    }
7121                    let mut carried = self.carried()?;
7122                    for item in parent.into_iter().chain(children.iter().cloned()) {
7123                        carried.insert(item.id.clone(), item);
7124                    }
7125                    return Ok(Some(children));
7126                }
7127            }
7128        }
7129    }
7130
7131    fn carried(
7132        &self,
7133    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
7134        self.carried.lock().map_err(|_| SourceError::Unavailable {
7135            message: "items a project's listing carried were left inconsistent; run the \
7136                      command again"
7137                .into(),
7138        })
7139    }
7140
7141    /// Which issue of this board a project *name* is, or `None` when none is.
7142    ///
7143    /// One bounded query which filters on that name at the server, rather than a walk of
7144    /// every issue the board holds. The name is compared again here: the qualifier narrows
7145    /// what GitHub sends, and this source decides what it names.
7146    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
7147        let search = self.board_search(Some(&title_qualifier(name)));
7148        let mut after = None;
7149        loop {
7150            let (candidates, next) = self
7151                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7152                .await?;
7153            if let Some(item) = candidates.into_iter().find(|item| {
7154                item.kind == BoardKind::Work(ItemKind::Project)
7155                    && item.title.eq_ignore_ascii_case(name)
7156            }) {
7157                return Ok(Some(item.id));
7158            }
7159            match next {
7160                Some(next) => after = Some(next),
7161                None => return Ok(None),
7162            }
7163        }
7164    }
7165
7166    /// Everything filed under one project of this board: the sub-issues of the issue that
7167    /// project is.
7168    ///
7169    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
7170    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
7171    /// gains projects, or as another project gains tasks.
7172    ///
7173    /// A qualified id names the issue and is asked for its sub-issues directly: one
7174    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
7175    /// read as a project *name*, which costs the one bounded search
7176    /// [`Self::project_by_name`] makes.
7177    ///
7178    /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
7179    /// — and each answer is still cut to the items whose parent is this project, so one this
7180    /// process has since filed elsewhere is not reported here, and completed with what this
7181    /// process filed under it.
7182    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
7183        let held = self.children_cache()?.get(selector).cloned();
7184        let (project, children) = match held {
7185            Some(held) => held,
7186            None => {
7187                let answered = match self.sub_issues(selector).await? {
7188                    Some(children) => (selector.clone(), children),
7189                    None => match self.project_by_name(&selector.0).await? {
7190                        Some(project) => {
7191                            let children = self.sub_issues(&project).await?.unwrap_or_default();
7192                            (project, children)
7193                        }
7194                        None => return Ok(Vec::new()),
7195                    },
7196                };
7197                self.children_cache()?
7198                    .insert(selector.clone(), answered.clone());
7199                answered
7200            }
7201        };
7202        let mut children: Vec<Resolved> = children
7203            .into_iter()
7204            .filter(|child| child.parent.as_ref() == Some(&project))
7205            .collect();
7206        for own in self.updated()?.iter() {
7207            if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
7208                children.push(own.clone());
7209            }
7210        }
7211        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
7212    }
7213
7214    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
7215    /// completed with what this run wrote — the candidates a comment-activity read confirms.
7216    ///
7217    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
7218    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
7219    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
7220    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
7221    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
7222    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
7223    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
7224    /// rather than silently narrowing a caller's answer.
7225    ///
7226    /// The instant is written to the second, rounded down, which can only widen what the
7227    /// search returns; confirmation against each candidate's own comments is what makes the
7228    /// answer exact. The search is an index that lags a write by a second or two — the module
7229    /// documentation records it — so a caller that asks again from its last instant should
7230    /// overlap the two by more than that.
7231    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
7232        let found = self.searched(&updated_qualifier(since)).await?;
7233        self.completed_with_written(found, |_| true)
7234    }
7235
7236    /// Every issue of this board GitHub's issue search reports for the board-scoped search
7237    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
7238    ///
7239    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
7240    /// own record is the fresher of the two.
7241    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
7242        let search = self.board_search(Some(also));
7243        let mut after: Option<String> = None;
7244        let mut found = Vec::new();
7245        loop {
7246            let (page, next) = self
7247                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7248                .await?;
7249            found.extend(page);
7250            match next {
7251                Some(next) => after = Some(next),
7252                None => return Ok(found),
7253            }
7254        }
7255    }
7256
7257    /// A bounded task answer; the versioned cursor carries the connection position, how
7258    /// many rows of the page starting there were already handed out, and the own-write ids
7259    /// already observed, including across a new source instance.
7260    ///
7261    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
7262    /// sliced from the pages it needs; why is the module documentation's paging contract.
7263    async fn search_tasks(
7264        &self,
7265        query: &TaskQuery,
7266        page: &PageRequest,
7267        also: &str,
7268    ) -> Result<Page<Task>, SourceError> {
7269        let mut position = match &page.cursor {
7270            None => SearchPosition::default(),
7271            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
7272                .ok()
7273                .filter(|position| {
7274                    position.version == SEARCH_CURSOR_VERSION
7275                        && position.connection.valid_resume(position.offset)
7276                })
7277                .ok_or_else(|| SourceError::Config {
7278                    message: "page cursor is invalid".into(),
7279                })?,
7280        };
7281        let search = self.board_search(Some(also));
7282        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
7283        let own = self.with_own_writes(Vec::new())?;
7284        // An issue this process commented on is a candidate of a comment-activity read
7285        // whether or not the search has caught up with the comment; see `Self::commented`.
7286        let commented = match query.commented_since {
7287            Some(_) => self.commented()?.clone(),
7288            None => Vec::new(),
7289        };
7290        for id in own.iter().map(|item| &item.id).chain(&commented) {
7291            if !position.own.contains(id) {
7292                position.own.push(id.clone());
7293            }
7294        }
7295        let mut tasks = Vec::new();
7296        while !position.connection.exhausted() && tasks.len() < limit {
7297            let first = SEARCH_PAGE_SIZE;
7298            // Page size is part of the key: a short cached answer cannot answer a wider ask.
7299            let key =
7300                serde_json::to_string(&("page", &search, &position.connection.after(), first))
7301                    .expect("search page key is serializable");
7302            let cached = if query.commented_since.is_none() {
7303                self.narrowed_cache()?.get(&key).cloned()
7304            } else {
7305                None
7306            };
7307            let (found, next) = match cached {
7308                Some(found) => {
7309                    let next = self
7310                        .search_next
7311                        .lock()
7312                        .map_err(|_| SourceError::Unavailable {
7313                            message:
7314                                "search pagination was left inconsistent; run the command again"
7315                                    .into(),
7316                        })?
7317                        .get(&key)
7318                        .cloned()
7319                        .flatten();
7320                    (found, next)
7321                }
7322                None => {
7323                    let (found, next) = self
7324                        .search_page(&search, first, position.connection.after())
7325                        .await?;
7326                    if query.commented_since.is_none() {
7327                        self.search_next
7328                            .lock()
7329                            .map_err(|_| SourceError::Unavailable {
7330                                message:
7331                                    "search pagination was left inconsistent; run the command again"
7332                                        .into(),
7333                            })?
7334                            .insert(key.clone(), next.clone());
7335                        self.narrowed_cache()?.insert(key, found.clone());
7336                    }
7337                    (found, next)
7338                }
7339            };
7340            let rows = found.len();
7341            // Every candidate of the page this read confirms by its comments is read together.
7342            let candidates =
7343                self.comment_candidates(found.iter().skip(position.offset), query, &query.project)?;
7344            self.hold_comment_pages(&candidates).await?;
7345            for mut item in found.into_iter().skip(position.offset) {
7346                if tasks.len() == limit {
7347                    break;
7348                }
7349                position.offset += 1;
7350                if position.own.contains(&item.id) {
7351                    if position.seen.contains(&item.id) {
7352                        continue;
7353                    }
7354                    position.seen.push(item.id.clone());
7355                    // The search's own copy of an issue this process only commented on is as
7356                    // good as a node read of it, since its comments are read either way.
7357                    let only_commented = commented.contains(&item.id)
7358                        && !own.iter().any(|written| written.id == item.id);
7359                    if !only_commented {
7360                        let updated_at = item.updated_at;
7361                        let Some(written) = self.search_written(&own, &item.id).await? else {
7362                            continue;
7363                        };
7364                        item = written;
7365                        item.updated_at = item.updated_at.max(updated_at);
7366                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
7367                    }
7368                }
7369                if item.kind == BoardKind::Work(ItemKind::Task) {
7370                    let task = item.task()?;
7371                    if task_matches(&task, query, &query.project)
7372                        && self.commented_since(&item, query.commented_since).await?
7373                    {
7374                        tasks.push(task);
7375                    }
7376                }
7377            }
7378            if position.offset < rows {
7379                continue;
7380            }
7381            position.offset = 0;
7382            position.connection = match next {
7383                Some(after) => SearchConnection::Continuing {
7384                    after: Cursor(after),
7385                },
7386                None => SearchConnection::Exhausted {},
7387            };
7388        }
7389        if position.connection.exhausted() {
7390            for id in position.own.clone() {
7391                if position.seen.contains(&id) {
7392                    continue;
7393                }
7394                if tasks.len() == limit {
7395                    break;
7396                }
7397                position.seen.push(id.clone());
7398                let Some(item) = self.search_written(&own, &id).await? else {
7399                    continue;
7400                };
7401                if item.kind == BoardKind::Work(ItemKind::Task) {
7402                    let task = item.task()?;
7403                    if task_matches(&task, query, &query.project)
7404                        && self.commented_since(&item, query.commented_since).await?
7405                    {
7406                        tasks.push(task);
7407                    }
7408                }
7409            }
7410        }
7411        let more = !position.connection.exhausted()
7412            || position.own.iter().any(|id| !position.seen.contains(id));
7413        Ok(Page {
7414            items: tasks,
7415            next: more.then(|| {
7416                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
7417            }),
7418        })
7419    }
7420
7421    /// A resumed process has the ids but no write records; resolve only a record the
7422    /// current page needs, by its uncached node read rather than the lagging search index.
7423    async fn search_written(
7424        &self,
7425        own: &[Resolved],
7426        id: &NativeId,
7427    ) -> Result<Option<Resolved>, SourceError> {
7428        match own.iter().find(|item| item.id == *id) {
7429            Some(item) => Ok(Some(item.clone())),
7430            None => self.item_by_id(id).await,
7431        }
7432    }
7433
7434    /// The candidates for a task query carrying a text, metadata or origin predicate, read
7435    /// without enumerating the board — or `None` for a query carrying none of the three, which
7436    /// keeps the reads it always had.
7437    ///
7438    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
7439    /// because it names at most a handful of items. Text and metadata are answered by one
7440    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
7441    /// further by `updated:>=` when the query also asks for comment activity, since both
7442    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
7443    /// process afterwards by the same predicates [`task_matches`] applies to every read.
7444    ///
7445    /// Completed with what this process wrote, its own record winning over the index's copy
7446    /// of the same item: see [`Self::with_own_writes`].
7447    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
7448        self.send_pending_origins(None).await?;
7449        let asked = match (&query.origin, narrowing_qualifiers(query)) {
7450            (Some(origin), _) => Narrowing::Origin(origin.clone()),
7451            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
7452                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
7453                None => qualifiers,
7454            }),
7455            (None, None) => return Ok(None),
7456        };
7457        // A question about comment activity is asked afresh every time, as it always was: it
7458        // is the one a caller polls from one source while waiting for the index, and an
7459        // answer held from the first poll would be the answer to every later one.
7460        let key = query.commented_since.is_none().then(|| asked.key());
7461        let cached = match &key {
7462            Some(key) => self.narrowed_cache()?.get(key).cloned(),
7463            None => None,
7464        };
7465        let found = match cached {
7466            Some(found) => found,
7467            None => {
7468                let found = match &asked {
7469                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
7470                    Narrowing::Search(also) => self.searched(also).await?,
7471                };
7472                if let Some(key) = key {
7473                    self.narrowed_cache()?.insert(key, found.clone());
7474                }
7475                found
7476            }
7477        };
7478        self.with_own_writes(found).map(Some)
7479    }
7480
7481    /// The candidates for a project or unscoped document query carrying a searchable text,
7482    /// read without enumerating the board — or `None` for a query with no text or a blank one,
7483    /// which keeps the read it always had.
7484    ///
7485    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
7486    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
7487    /// costs is the issues that match and never the board. Its answer is held for the command
7488    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
7489    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
7490    /// substring rule, exactly as an item of the wider read was, and is completed with what this
7491    /// process wrote: see [`Self::with_own_writes`].
7492    async fn text_searched(
7493        &self,
7494        text: Option<&TextQuery>,
7495    ) -> Result<Option<Vec<Resolved>>, SourceError> {
7496        let Some(also) = text_qualifiers(text) else {
7497            return Ok(None);
7498        };
7499        let key = Narrowing::Search(also.clone()).key();
7500        let cached = self.narrowed_cache()?.get(&key).cloned();
7501        let found = match cached {
7502            Some(found) => found,
7503            None => {
7504                let found = self.searched(&also).await?;
7505                self.narrowed_cache()?.insert(key, found.clone());
7506                found
7507            }
7508        };
7509        self.with_own_writes(found).map(Some)
7510    }
7511
7512    /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
7513    /// [`Self::narrowed`] reads for a task query carrying that origin — one
7514    /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
7515    /// what this process wrote — each confirmed against its own origin entry.
7516    ///
7517    /// What answers a project or document listing by origin, so finding every copy of one plan
7518    /// costs the items that carry its origin and never a page of the board.
7519    async fn copied_from(
7520        &self,
7521        origin: &str,
7522        kind: BoardKind,
7523    ) -> Result<Vec<Resolved>, SourceError> {
7524        let wanted = TaskQuery {
7525            origin: Some(origin.to_owned()),
7526            ..TaskQuery::default()
7527        };
7528        Ok(self
7529            .narrowed(&wanted)
7530            .await?
7531            .unwrap_or_default()
7532            .into_iter()
7533            .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
7534            .collect())
7535    }
7536
7537    /// Every item of this board that may carry `origin` — a superset of those that do — found
7538    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
7539    ///
7540    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
7541    /// which reads the field every carrier holds, whichever release wrote it — and the
7542    /// board-scoped issue search for the same id as a phrase in the body, where this source
7543    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
7544    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
7545    /// query's, exactly.
7546    ///
7547    /// Both connections are walked to exhaustion, each from its own cursor. One that has
7548    /// already ended is sent its last cursor again, which answers an empty page, so the one
7549    /// document serves every page of either. What the two leave is stated in the module
7550    /// documentation: a carrier another process added within the last second or two, before
7551    /// either index has it.
7552    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
7553        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
7554        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
7555        let mut items_after: Option<String> = None;
7556        let mut search_after: Option<String> = None;
7557        let mut found: Vec<Resolved> = Vec::new();
7558        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
7559            if !found.iter().any(|held| held.id == resolved.id) {
7560                found.push(resolved);
7561            }
7562        };
7563        loop {
7564            let data = self
7565                .graphql(
7566                    graphql::ORIGIN_LOOKUP,
7567                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
7568                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
7569                           "itemsAfter":items_after,"searchAfter":search_after,
7570                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7571                           "duplicates":true}),
7572                )
7573                .await?;
7574            let items = data
7575                .pointer("/originItems/projectV2/items")
7576                .filter(|value| !value.is_null())
7577                .ok_or_else(|| SourceError::Refused {
7578                    message: format!(
7579                        "GitHub project {}/{} was not found or is not visible to the token",
7580                        self.owner, self.project_number
7581                    ),
7582                })?;
7583            for item in optional_nodes(Some(items), "project items")?
7584                .into_iter()
7585                .flatten()
7586            {
7587                // The board's own items list its drafts too, and a draft is not an issue: no
7588                // narrowed read answers with one, whatever its origin field holds.
7589                if let Some(resolved) = self.resolve(item)?
7590                    && resolved.content_kind == ContentKind::Issue
7591                {
7592                    keep(resolved, &mut found);
7593                }
7594            }
7595            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
7596                message: "GitHub search response has no search connection".into(),
7597            })?;
7598            for node in optional_nodes(Some(searched), "search")?
7599                .into_iter()
7600                .flatten()
7601            {
7602                if let Some(resolved) = self.resolve_issue(node).await? {
7603                    keep(resolved, &mut found);
7604                }
7605            }
7606            let items_next = resumed(items, items_after.as_deref())?;
7607            let search_next = resumed(searched, search_after.as_deref())?;
7608            if !items_next.has_more() && !search_next.has_more() {
7609                return Ok(found);
7610            }
7611            items_after = items_next.cursor();
7612            search_after = search_next.cursor();
7613        }
7614    }
7615
7616    /// `found`, with every item this process created or wrote in its place, and every one of
7617    /// them the read did not report added.
7618    ///
7619    /// This process's own record wins over the read's copy of the same item, because a read
7620    /// of an item written moments ago can still be behind what was written onto it — the
7621    /// origin field included, which is the one a narrowed read is confirmed against — and a
7622    /// read that still names an item under a predicate this process's write moved it out of
7623    /// must not return it. The one thing the read knows that the record cannot is when GitHub
7624    /// last saw the item change, which is what a comment-activity read rules a candidate out
7625    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
7626    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
7627    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
7628        // A board draft is not an issue, so no narrowed read returns one, and this process
7629        // having written one does not make it an answer either.
7630        let own: Vec<Resolved> = self
7631            .created()?
7632            .iter()
7633            .chain(self.updated()?.iter())
7634            .filter(|own| own.content_kind == ContentKind::Issue)
7635            .cloned()
7636            .collect();
7637        for mut own in own {
7638            self.resolved_cache()?.insert(own.id.clone(), own.clone());
7639            match found.iter_mut().find(|read| read.id == own.id) {
7640                Some(read) => {
7641                    own.updated_at = own.updated_at.max(read.updated_at);
7642                    *read = own;
7643                }
7644                None => found.push(own),
7645            }
7646        }
7647        Ok(found)
7648    }
7649
7650    /// Whether `item` has a comment created or last edited at or after `since` — always, when
7651    /// there is no instant to hold it to.
7652    ///
7653    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
7654    /// or after the instant moved it there: an issue not updated since holds no such comment,
7655    /// and its comments are never asked for — unless this process commented on it in this
7656    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
7657    /// only as far as the first that matches. A board draft is not an issue and has no
7658    /// comments, so it never matches.
7659    async fn commented_since(
7660        &self,
7661        item: &Resolved,
7662        since: Option<DateTime<Utc>>,
7663    ) -> Result<bool, SourceError> {
7664        let Some(since) = since else {
7665            return Ok(true);
7666        };
7667        if item.content_kind == ContentKind::DraftIssue {
7668            return Ok(false);
7669        }
7670        // An `updatedAt` this process's own record or a lagging index holds can predate a
7671        // comment this process wrote since, so only an issue it did not comment on is ruled
7672        // out by one.
7673        if item.updated_at.is_some_and(|updated| updated < since)
7674            && !self.commented()?.contains(&item.id)
7675        {
7676            return Ok(false);
7677        }
7678        let query = TaskQuery {
7679            commented_since: Some(since),
7680            ..TaskQuery::default()
7681        };
7682        let mut after: Option<String> = None;
7683        // A first page read beside its fellow candidates answers as this read's own would.
7684        let held = self.held_comments()?.get(&item.id).cloned();
7685        if let Some(first) = held {
7686            if query.comments_match(&first.items) {
7687                return Ok(true);
7688            }
7689            match first.next {
7690                Some(next) => after = Some(next.0),
7691                None => return Ok(false),
7692            }
7693        }
7694        loop {
7695            let data = self
7696                .graphql(
7697                    graphql::ISSUE_COMMENTS,
7698                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
7699                )
7700                .await?;
7701            let Some(connection) = data
7702                .get("node")
7703                .filter(|value| !value.is_null())
7704                .and_then(|node| node.get("comments"))
7705                .filter(|value| !value.is_null())
7706            else {
7707                // Removed since the search reported it: no longer an issue with comments.
7708                return Ok(false);
7709            };
7710            let comments = optional_nodes(Some(connection), "issue comments")?
7711                .into_iter()
7712                .flatten()
7713                .map(comment_from)
7714                .collect::<Result<Vec<_>, _>>()?;
7715            if query.comments_match(&comments) {
7716                return Ok(true);
7717            }
7718            match next_cursor(connection)? {
7719                Some(next) => {
7720                    validate_cursor_progress(after.as_deref(), &next.0)?;
7721                    after = Some(next.0);
7722                }
7723                None => return Ok(false),
7724            }
7725        }
7726    }
7727
7728    /// Every item on the board: the union of both enumerations GitHub offers of one.
7729    ///
7730    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
7731    /// board **draft** and reads the board's own fields beside its items, and only the search
7732    /// reports an item that connection is behind on. The module documentation is where the lag and the
7733    /// measurements behind it are written down.
7734    ///
7735    /// A search result is admitted on the same terms as any other issue this source reaches
7736    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
7737    /// names *this* board — so an issue the index still believes is here after it was taken
7738    /// off is refused rather than reported.
7739    ///
7740    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
7741    /// which is what the cache could otherwise have broken.
7742    async fn board(&self) -> Result<Board, SourceError> {
7743        self.board_completed_by(None).await
7744    }
7745
7746    /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
7747    /// that half could add is then answered only if it holds.
7748    async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
7749        let cached = self.board_cache()?.clone();
7750        let mut board = match cached {
7751            Some(board) => board,
7752            None => {
7753                let read = self.read_board().await?;
7754                *self.board_cache()? = Some(read.clone());
7755                read
7756            }
7757        };
7758        let searched = match also {
7759            None => self.searched_issues().await?,
7760            Some(also) => self.searched_narrowly(also).await?,
7761        };
7762        // A complete sweep asks for every page of this view. Reconcile ids once per
7763        // view, rather than scanning the board for every search result on every page.
7764        let mut ids: std::collections::HashSet<_> =
7765            board.items.iter().map(|item| item.id.clone()).collect();
7766        for held in searched {
7767            if ids.insert(held.id.clone()) {
7768                board.items.push(held);
7769            }
7770        }
7771        for own in self.created()?.iter() {
7772            if ids.insert(own.id.clone()) {
7773                board.items.push(own.clone());
7774            }
7775        }
7776        Ok(board)
7777    }
7778
7779    /// This process's own view of the board, or the refusal a poisoned lock is.
7780    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
7781        self.board_cache
7782            .lock()
7783            .map_err(|_| SourceError::Unavailable {
7784                message: "this source's view of the board was left inconsistent by an earlier \
7785                      failure; next: run the command again"
7786                    .into(),
7787            })
7788    }
7789
7790    /// Bring this process's own view of the board up to an item it has just written.
7791    ///
7792    /// A created item goes to `created`, which is what completes a board read GitHub's own
7793    /// eventual consistency has left behind. An item that was already there is replaced
7794    /// where it sits, so a second write of it in the same command reads its real parent
7795    /// rather than the one it had before the first write.
7796    ///
7797    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
7798    /// that wins: an item this same run created is held in `created` and not in the cached
7799    /// board, and `board` completes the cached board *from* `created`, so replacing only
7800    /// the cached copy of such an item replaces nothing and the read still reports the
7801    /// title it was created with. The search is the third, and it is the one an item the
7802    /// board's own projection is behind on sits in *alone* — which is exactly the item this
7803    /// source is least able to re-read, so leaving it out would put the stale title back on
7804    /// the only items the completion in [`Self::board`] exists for.
7805    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
7806        self.resolved_cache()?.insert(item.id.clone(), item.clone());
7807        if created {
7808            self.created()?.push(item);
7809            return Ok(());
7810        }
7811        {
7812            let mut own = self.created()?;
7813            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
7814                *held = item;
7815                return Ok(());
7816            }
7817        }
7818        {
7819            let mut own = self.updated()?;
7820            match own.iter_mut().find(|held| held.id == item.id) {
7821                Some(held) => *held = item.clone(),
7822                None => own.push(item.clone()),
7823            }
7824        }
7825        if let Some(board) = self.board_cache()?.as_mut()
7826            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
7827        {
7828            *held = item.clone();
7829        }
7830        if let Some(found) = self.search_cache()?.as_mut()
7831            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
7832        {
7833            *held = item.clone();
7834        }
7835        for found in self.narrowed_cache()?.values_mut() {
7836            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
7837                *held = item.clone();
7838            }
7839        }
7840        for (_, found) in self.children_cache()?.values_mut() {
7841            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
7842                *held = item.clone();
7843            }
7844        }
7845        Ok(())
7846    }
7847
7848    /// Forget one item this process has just deleted, from every half of its own view.
7849    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
7850        if let Some(Deferred::Held { created, .. }) = self.deferred()?.as_mut() {
7851            created.retain(|held| held.content != *id);
7852        }
7853        self.resolved_cache()?.remove(id);
7854        self.read_ahead()?.remove(id);
7855        self.carried()?.remove(id);
7856        self.created()?.retain(|own| own.id != *id);
7857        self.updated()?.retain(|own| own.id != *id);
7858        self.commented()?.retain(|own| own != id);
7859        if let Some(board) = self.board_cache()?.as_mut() {
7860            board.items.retain(|item| item.id != *id);
7861        }
7862        if let Some(found) = self.search_cache()?.as_mut() {
7863            found.retain(|item| item.id != *id);
7864        }
7865        for found in self.narrowed_cache()?.values_mut() {
7866            found.retain(|item| item.id != *id);
7867        }
7868        for (_, found) in self.children_cache()?.values_mut() {
7869            found.retain(|item| item.id != *id);
7870        }
7871        Ok(())
7872    }
7873
7874    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
7875    fn narrowed_cache(
7876        &self,
7877    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
7878        self.narrowed_cache
7879            .lock()
7880            .map_err(|_| SourceError::Unavailable {
7881                message: "this source's view of a narrowed read was left inconsistent by an \
7882                      earlier failure; next: run the command again"
7883                    .into(),
7884            })
7885    }
7886
7887    /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
7888    /// is.
7889    /// The tasks `project` holds, as a copy's batched read found them this command, when it did.
7890    fn held_task_children(&self, project: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7891        Ok(self.task_children()?.get(project).cloned())
7892    }
7893
7894    fn task_children(
7895        &self,
7896    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Vec<Resolved>>>, SourceError> {
7897        self.task_children
7898            .lock()
7899            .map_err(|_| SourceError::Unavailable {
7900                message: "this source's record of a project's tasks was left inconsistent by an \
7901                          earlier failure; next: run the command again"
7902                    .into(),
7903            })
7904    }
7905
7906    fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
7907        self.children_cache
7908            .lock()
7909            .map_err(|_| SourceError::Unavailable {
7910                message: "this source's view of a project's tasks was left inconsistent by an \
7911                      earlier failure; next: run the command again"
7912                    .into(),
7913            })
7914    }
7915
7916    /// Every page of the board, read from GitHub.
7917    async fn read_board(&self) -> Result<Board, SourceError> {
7918        let mut after: Option<String> = None;
7919        let mut items = Vec::new();
7920        let mut board;
7921        loop {
7922            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
7923            for item in page
7924                .pointer("/items/nodes")
7925                .and_then(Value::as_array)
7926                .ok_or_else(|| SourceError::Malformed {
7927                    message: "GitHub project items.nodes is not an array".into(),
7928                })?
7929            {
7930                if let Some(resolved) = self.resolve(item)? {
7931                    items.push(resolved);
7932                }
7933            }
7934            let info = page
7935                .pointer("/items/pageInfo")
7936                .ok_or_else(|| SourceError::Malformed {
7937                    message: "GitHub project items have no pageInfo".into(),
7938                })?;
7939            let has_next = required_bool(info, "hasNextPage")?;
7940            let next = has_next
7941                .then(|| required_str(info, "endCursor"))
7942                .transpose()?;
7943            board = page.clone();
7944            match next {
7945                Some(next) => {
7946                    validate_cursor_progress(after.as_deref(), next)?;
7947                    after = Some(next.to_owned());
7948                }
7949                None => break,
7950            }
7951        }
7952        Ok(Board {
7953            id: required_str(&board, "id")?.to_owned(),
7954            fields: board.get("fields").cloned().unwrap_or(Value::Null),
7955            items,
7956        })
7957    }
7958
7959    /// The existing items this source has written, for completing a narrowed read that is
7960    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
7961    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
7962        self.updated.lock().map_err(|_| SourceError::Unavailable {
7963            message: "this source's record of what it wrote in this run was left inconsistent \
7964                      by an earlier failure; next: run the command again"
7965                .into(),
7966        })
7967    }
7968
7969    /// The issues this source has commented on in this command; see
7970    /// [`Self::commented`](GitHubProjectsSource::commented).
7971    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
7972        self.commented.lock().map_err(|_| SourceError::Unavailable {
7973            message: "this source's record of what it commented on in this run was left \
7974                      inconsistent by an earlier failure; next: run the command again"
7975                .into(),
7976        })
7977    }
7978
7979    /// Called only once GitHub has answered the comment write, so an issue whose comment
7980    /// failed is never made a candidate a later read would pay a node read for.
7981    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
7982        // A comment page held for this command is no longer what the issue holds.
7983        self.held_comments()?.remove(issue);
7984        let mut commented = self.commented()?;
7985        if !commented.contains(issue) {
7986            commented.push(issue.clone());
7987        }
7988        Ok(())
7989    }
7990
7991    /// The items this source has created, for completing a board read that is behind.
7992    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
7993        self.created.lock().map_err(|_| SourceError::Unavailable {
7994            message: "this source's record of what it created in this run was left \
7995                      inconsistent by an earlier failure; next: run the command again"
7996                .into(),
7997        })
7998    }
7999
8000    /// One board item as this source reports it, or `None` for content it ignores.
8001    ///
8002    /// A pull request is neither a project nor a task — it is somebody's change, not a
8003    /// unit of plan — and an item whose content the token cannot see has nothing to
8004    /// report at all.
8005    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8006        let resolved = self.resolve_unremembered(item)?;
8007        if let Some(resolved) = &resolved {
8008            self.resolved_cache()?
8009                .insert(resolved.id.clone(), resolved.clone());
8010        }
8011        Ok(resolved)
8012    }
8013
8014    /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
8015    /// write's own response needs, which says nothing this process should answer a later read
8016    /// with.
8017    fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8018        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
8019            message: "GitHub project item is missing content".into(),
8020        })?;
8021        if content.is_null() {
8022            return Ok(None);
8023        }
8024        let content_kind = match required_str(content, "__typename")? {
8025            "Issue" => ContentKind::Issue,
8026            "DraftIssue" => ContentKind::DraftIssue,
8027            _ => return Ok(None),
8028        };
8029        let field_values = item
8030            .get("fieldValues")
8031            .ok_or_else(|| SourceError::Malformed {
8032                message: "GitHub project item is missing fieldValues".into(),
8033            })?;
8034        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
8035        let nodes = field_values
8036            .get("nodes")
8037            .and_then(Value::as_array)
8038            .ok_or_else(|| SourceError::Malformed {
8039                message: "GitHub project item fieldValues.nodes is not an array".into(),
8040            })?;
8041        if let Some(labels) = content.get("labels") {
8042            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
8043        }
8044        let raw_body = optional_str(content, "body")?.map(str::to_owned);
8045        let (body, slot) = self.visible(raw_body.clone())?;
8046        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
8047            .map(|id| NativeId(id.to_owned()));
8048        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
8049        // to read one from; it is a task, and never a project.
8050        let sub_issues = match content_kind {
8051            ContentKind::Issue => sub_issue_total(content)?,
8052            ContentKind::DraftIssue => 0,
8053        };
8054        let content_id = required_str(content, "id")?;
8055        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8056            message: format!("GitHub issue {content_id}: {message}"),
8057        })?;
8058        let raw_title = required_str(content, "title")?;
8059        let kind = BoardKind::of(raw_title, parent.is_some(), sub_issues, marked);
8060        // The title a person wrote, which for a document is the one without the prefix —
8061        // the same way `content` above is the body without this source's metadata slot.
8062        let title = match kind {
8063            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
8064            BoardKind::Work(_) => raw_title.to_owned(),
8065        };
8066        let own_repository = content
8067            .pointer("/repository/nameWithOwner")
8068            .and_then(Value::as_str)
8069            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
8070            .transpose()
8071            .map_err(|message| SourceError::Malformed { message })?;
8072        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
8073            Repository::from_metadata(&slot)
8074                .map_err(|message| SourceError::Malformed { message })?
8075        } else {
8076            own_repository.clone().into_iter().collect()
8077        };
8078        let classification = Classification::from_metadata(&slot)
8079            .map_err(|message| SourceError::Malformed { message })?;
8080        let id = NativeId(content_id.to_owned());
8081        // Read only for a task, because only a task has either list: a project or a
8082        // document holding one of these keys holds nothing this source reports, and the
8083        // keys are left out of its caller-visible metadata all the same.
8084        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
8085            let listed = |key: &str| {
8086                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
8087                    .map_err(|message| SourceError::Malformed { message })
8088            };
8089            (
8090                listed(TaskRef::DELIVERS_KEY)?,
8091                listed(TaskRef::DELIVERED_BY_KEY)?,
8092            )
8093        } else {
8094            (Vec::new(), Vec::new())
8095        };
8096        let (option, closed, reason) = Self::status_parts(nodes, content)?;
8097        let priority = self.held_priority(nodes)?;
8098        let item_labels = labels(content)?;
8099        // Present when the item was reached through its own issue, whose board entry
8100        // names the board; a read of the board's own items has the board already. An
8101        // empty id names nothing a field write could address, so it is read as absent and
8102        // the write goes back to reading the board.
8103        let board_id = item
8104            .pointer("/project/id")
8105            .and_then(Value::as_str)
8106            .filter(|id| !id.is_empty());
8107        let resolved = Resolved {
8108            item_id: required_str(item, "id")?.to_owned(),
8109            id,
8110            content_kind,
8111            kind,
8112            title,
8113            body: body.filter(|value| !value.is_empty()),
8114            raw_body,
8115            status: self
8116                .statuses
8117                .status(kind.status_kind(), option, closed, reason),
8118            option: option.map(str::to_owned),
8119            priority,
8120            closed,
8121            reason: closed.then(|| reason.map(str::to_owned)).flatten(),
8122            delivers,
8123            delivered_by,
8124            labels: item_labels.clone(),
8125            parent,
8126            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
8127            projected: self.held_projections(nodes)?,
8128            number: match content_kind {
8129                ContentKind::Issue => Some(issue_number(content)?),
8130                // A draft is filed in no repository, so nothing ever numbered it:
8131                // `DraftIssue` declares no `number` at all, exactly as it declares no
8132                // `subIssuesSummary` the branch above reads.
8133                ContentKind::DraftIssue => None,
8134            },
8135            url: optional_str(content, "url")?.map(str::to_owned),
8136            created_at: optional_time(content, "createdAt")?,
8137            updated_at: optional_time(content, "updatedAt")?,
8138            item_updated_at: optional_time(item, "updatedAt")?,
8139            comment_count: content
8140                .pointer("/commentCount/totalCount")
8141                .and_then(Value::as_u64),
8142            relationship_events: content
8143                .pointer("/relationshipEvents/totalCount")
8144                .and_then(Value::as_u64),
8145            // The issue node is stamped wherever it was read; a board item a read of the issue
8146            // put together from its memberships is not.
8147            served_at: match optional_time(content, SERVED_AT)? {
8148                Some(served) => Some(served),
8149                None => optional_time(item, SERVED_AT)?,
8150            },
8151            // Both connections were read to their end above — one reporting a further page is
8152            // refused — so these are every field value and every label the item holds. A
8153            // response that did not select the labels at all says nothing of them, and gives
8154            // no facets.
8155            facets: content.get("labels").is_some().then(|| {
8156                change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
8157            }),
8158            own_repository,
8159            repositories,
8160            classification,
8161            slot,
8162            board_id: board_id.map(str::to_owned),
8163            fields: field_definitions(nodes),
8164            board_fields: Self::carried_board_fields(content, board_id)?,
8165            blocked_by: carried_blocked_by(content)?,
8166        };
8167        Ok(Some(resolved))
8168    }
8169
8170    /// The field definitions of the board `board_id` names — the project this issue's own
8171    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
8172    /// `None` when the read carried none, carried no entry for that board, or the board item
8173    /// named no board, which a write then answers by reading the board's fields itself.
8174    ///
8175    /// Matched by the board's node id and never by its number alone: a project number is
8176    /// unique only within its owner, so another owner's board numbered alike can sit on the
8177    /// same page, and its field and option ids address nothing on this one.
8178    fn carried_board_fields(
8179        content: &Value,
8180        board_id: Option<&str>,
8181    ) -> Result<Option<Value>, SourceError> {
8182        let (Some(nodes), Some(board_id)) = (
8183            content.pointer("/boards/nodes").and_then(Value::as_array),
8184            board_id,
8185        ) else {
8186            return Ok(None);
8187        };
8188        let Some(board) = nodes.iter().find_map(|node| {
8189            let project = node.get("project")?;
8190            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
8191        }) else {
8192            return Ok(None);
8193        };
8194        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
8195            return Ok(None);
8196        };
8197        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
8198        Ok(Some(fields.clone()))
8199    }
8200
8201    /// What one board item's `Priority` field says, through this instance's mapping.
8202    ///
8203    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
8204    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
8205    /// option the mapping does not name is kept as itself — never read as a level or as
8206    /// `none` — for a read of the task to report by name.
8207    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
8208        let Some(mapping) = &self.priorities else {
8209            return Ok(HeldPriority::Read(Priority::None));
8210        };
8211        // A value of the field that names no option — a text field someone called `Priority` —
8212        // is malformed rather than `none`: reading it as no priority would let the next copy
8213        // clear one a person set.
8214        let Some(option) = field_values
8215            .iter()
8216            .find(|value| {
8217                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
8218            })
8219            .map(|value| required_str(value, "name"))
8220            .transpose()?
8221        else {
8222            return Ok(HeldPriority::Read(Priority::None));
8223        };
8224        Ok(mapping.priority_of(option).map_or_else(
8225            || HeldPriority::Unmapped(option.to_owned()),
8226            HeldPriority::Read,
8227        ))
8228    }
8229
8230    /// What each projected metadata field holds for one board item, by field name — every text
8231    /// value GitHub answers, an empty one included.
8232    fn held_projections(
8233        &self,
8234        field_values: &[Value],
8235    ) -> Result<BTreeMap<String, String>, SourceError> {
8236        let mut held = BTreeMap::new();
8237        for projection in &self.metadata_fields {
8238            if let Some(text) = text_field(field_values, &projection.field)? {
8239                held.insert(projection.field.to_string(), text);
8240            }
8241        }
8242        Ok(held)
8243    }
8244
8245    /// What one board item's status is read from: its `Status` option, whether its issue
8246    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
8247    /// three into the status it reports.
8248    fn status_parts<'a>(
8249        field_values: &'a [Value],
8250        content: &'a Value,
8251    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
8252        let option = field_values
8253            .iter()
8254            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
8255            .map(|value| required_str(value, "name"))
8256            .transpose()?;
8257        let closed = optional_str(content, "state")? == Some("CLOSED");
8258        Ok((option, closed, optional_str(content, "stateReason")?))
8259    }
8260
8261    /// The board Status option this write selects, or the refusal that says why not.
8262    ///
8263    /// The mapped option is required for both open and terminal targets. A terminal write
8264    /// validates it before changing either representation, so it can never fall back to
8265    /// closing an issue whose board cannot display the matching status.
8266    ///
8267    /// Answers the field's id, the option's id, and the option's name as the board spells
8268    /// it — which is the name a read of the item reports once it sits there.
8269    fn column_for(
8270        &self,
8271        fields: &Value,
8272        kind: ItemKind,
8273        category: StatusCategory,
8274        target: &StatusTarget,
8275    ) -> Result<Option<(String, String, String)>, SourceError> {
8276        let Some(wanted) = target.option() else {
8277            return Ok(None);
8278        };
8279        let missing = |detail: &str| SourceError::Refused {
8280            message: format!(
8281                "{} status {} of source {} needs the board Status option {wanted:?}, and \
8282                 {detail}; next: add that option to the board, which `onetaskgraph sources \
8283                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
8284                 it has",
8285                kind.marker(),
8286                category_name(category),
8287                self.name,
8288                self.name,
8289                category_name(category),
8290                kind.marker()
8291            ),
8292        };
8293        let Some(field) = Board::field(fields, "Status")? else {
8294            return Err(missing("this board has no Status field"));
8295        };
8296        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8297            return Err(missing(
8298                "this board's Status field is not a single-select field",
8299            ));
8300        }
8301        let option = field
8302            .get("options")
8303            .and_then(Value::as_array)
8304            .and_then(|options| {
8305                options.iter().find(|option| {
8306                    option
8307                        .get("name")
8308                        .and_then(Value::as_str)
8309                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8310                })
8311            });
8312        match option {
8313            None => Err(missing("this board does not have it")),
8314            Some(option) => Ok(Some((
8315                required_str(field, "id")?.to_owned(),
8316                required_str(option, "id")?.to_owned(),
8317                required_str(option, "name")?.to_owned(),
8318            ))),
8319        }
8320    }
8321
8322    /// The refusal a status that closes an issue is answered with over a board draft.
8323    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
8324        SourceError::Refused {
8325            message: format!(
8326                "status {} of source {} closes the item's issue, and GitHub draft items have \
8327                 no open or closed state",
8328                category_name(category),
8329                self.name
8330            ),
8331        }
8332    }
8333
8334    /// What a status write to one item needs of the board: the board's id and the
8335    /// definition of its `Status` field, read off the item when the item says both.
8336    ///
8337    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
8338    /// and its `Status` value carries that field's definition, options and all. An item that
8339    /// does not say — no board id, or no `Status` value to read the field off — takes them
8340    /// from [`Self::board_fields`], which reads no item.
8341    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8342        if let Some(board) = item.carried_board() {
8343            return Ok(board);
8344        }
8345        if item.defines("Status")
8346            && let Some(board_id) = item.named_board()
8347        {
8348            return Ok(BoardFields {
8349                id: board_id,
8350                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8351            });
8352        }
8353        self.board_fields().await
8354    }
8355
8356    /// Set one task's or one project's status and nothing else; see
8357    /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
8358    async fn set_status(
8359        &self,
8360        id: &NativeId,
8361        kind: ItemKind,
8362        category: StatusCategory,
8363    ) -> Result<Option<Status>, SourceError> {
8364        // Refused before anything is read, in the words a write of the same status is.
8365        let target = self.resolved_target(kind, category)?;
8366        // An item read by issue reference carries only what this write needs, so what it
8367        // becomes is never remembered for a later read to be answered from.
8368        let held = self.take_held(id)?;
8369        let by_reference = held.is_some();
8370        let bound = match held {
8371            Some(item) => Some(item),
8372            None => self.bound_item(id).await?,
8373        };
8374        let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
8375            return Ok(None);
8376        };
8377        let board = self.status_board(&item).await?;
8378        let (field, option, name) = self
8379            .column_for(&board.fields, kind, category, &target)?
8380            .ok_or_else(|| SourceError::Malformed {
8381                message: format!(
8382                    "status {} of source {} names no board Status option",
8383                    category_name(category),
8384                    self.name
8385                ),
8386            })?;
8387        if item.status.category == category && item.option.as_deref() == Some(&name) {
8388            return Ok(Some(item.status));
8389        }
8390        match &target {
8391            StatusTarget::Terminal(_, reason) => {
8392                if item.content_kind == ContentKind::DraftIssue {
8393                    return Err(self.closes_a_draft(category));
8394                }
8395                // The option first, so a refused selection leaves an open issue open rather
8396                // than closed behind a card that still reads open.
8397                self.set_item_field(
8398                    board.id.as_str(),
8399                    &item.item_id,
8400                    &field,
8401                    json!({"singleSelectOptionId": option}),
8402                )
8403                .await?;
8404                // An issue already closed in a way that reads as this category keeps its state
8405                // and its reason: only its card was out of line, and closing it again would
8406                // overwrite a reason — a duplicate's — that says more than this write does.
8407                let already = item.closed && item.status.category == category;
8408                if !already {
8409                    self.update_content(
8410                        ContentKind::Issue,
8411                        &item.id,
8412                        json!({"stateInput": state_input(Some(&target))}),
8413                    )
8414                    .await?;
8415                }
8416                let reason = if already {
8417                    item.reason.clone()
8418                } else {
8419                    Some(reason.reason().to_owned())
8420                };
8421                item.closed = true;
8422                item.status = self
8423                    .statuses
8424                    .status(kind, Some(&name), true, reason.as_deref());
8425                item.option = Some(name);
8426                item.reason = reason;
8427            }
8428            StatusTarget::Column(_) => {
8429                // An option is what an open item's status is, so a closed issue is reopened
8430                // first — sitting closed in the column, it would read back as closed. A draft has
8431                // no state to reopen.
8432                if item.content_kind == ContentKind::Issue && item.closed {
8433                    self.update_content(
8434                        ContentKind::Issue,
8435                        &item.id,
8436                        json!({"stateInput": state_input(Some(&target))}),
8437                    )
8438                    .await?;
8439                    item.closed = false;
8440                }
8441                self.set_item_field(
8442                    board.id.as_str(),
8443                    &item.item_id,
8444                    &field,
8445                    json!({"singleSelectOptionId": option}),
8446                )
8447                .await?;
8448                item.status = self.statuses.status(kind, Some(&name), false, None);
8449                item.option = Some(name);
8450            }
8451            StatusTarget::Disabled(_) => {
8452                unreachable!("resolved_target refused a disabled status")
8453            }
8454        }
8455        let status = item.status.clone();
8456        if !by_reference {
8457            self.remember_written(item, false)?;
8458        }
8459        Ok(Some(status))
8460    }
8461
8462    /// Replace one task's `delivered_by` and nothing else; see
8463    /// [`TaskSource::set_delivered_by`].
8464    ///
8465    /// One update of the body, which differs from the body GitHub holds only inside the
8466    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
8467    async fn replace_delivered_by(
8468        &self,
8469        id: &NativeId,
8470        delivered_by: &[TaskRef],
8471    ) -> Result<Option<()>, SourceError> {
8472        let entries = TaskRef::listed(
8473            TaskRef::DELIVERED_BY_KEY,
8474            id,
8475            Some(&self.name),
8476            delivered_by.to_vec(),
8477        )
8478        .map_err(|message| SourceError::Refused { message })?;
8479        let Some(mut item) = self
8480            .bound_item(id)
8481            .await?
8482            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8483        else {
8484            return Ok(None);
8485        };
8486        let mut slot = item.slot.clone();
8487        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
8488        self.write_slot(&mut item, &slot).await?;
8489        item.delivered_by = entries;
8490        self.remember_written(item, false)?;
8491        Ok(Some(()))
8492    }
8493
8494    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
8495    /// see [`TaskSource::set_task_metadata`].
8496    ///
8497    /// `None` when this board holds no item by that id, or holds one of another kind. The
8498    /// answer is the item as this source now reads it, so what a caller is told the key
8499    /// holds is what the slot holds.
8500    ///
8501    /// A key already holding the value is answered without a write, compared as JSON rather
8502    /// than as the body's bytes: a slot a person spelled with other whitespace would
8503    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
8504    async fn set_slot_key(
8505        &self,
8506        id: &NativeId,
8507        kind: BoardKind,
8508        key: &MetadataKey,
8509        value: &Value,
8510    ) -> Result<Option<Resolved>, SourceError> {
8511        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8512            return Ok(None);
8513        };
8514        if item.slot.get(key.as_str()) == Some(value) {
8515            return Ok(Some(item));
8516        }
8517        let mut slot = item.slot.clone();
8518        slot.insert(key.as_str().to_owned(), value.clone());
8519        // A key a board text field projects moves that field first, so the body — the value's
8520        // one authoritative home — is written last, as every write of an existing item is.
8521        if self
8522            .metadata_fields
8523            .iter()
8524            .any(|projection| &*projection.key == key.as_str())
8525        {
8526            let board = self.projection_board(&item).await?;
8527            let (writes, projected) = self.projection_writes(
8528                &board.fields,
8529                Some(&item.projected),
8530                &slot,
8531                Some(&[key.as_str()]),
8532            )?;
8533            let (mut values, mut clears) = (Vec::new(), Vec::new());
8534            for write in &writes {
8535                write.join(&mut values, &mut clears);
8536            }
8537            self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
8538                .await?;
8539            item.projected = projected;
8540        }
8541        self.write_slot(&mut item, &slot).await?;
8542        self.remember_written(item.clone(), false)?;
8543        Ok(Some(item))
8544    }
8545
8546    /// What a projection write to `item` needs of the board, read off the item when it says
8547    /// enough, as [`Self::fields_for`] reads it.
8548    async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8549        if let Some(board) = item.carried_board() {
8550            return Ok(board);
8551        }
8552        if let Some(board_id) = item.named_board()
8553            && self
8554                .metadata_fields
8555                .iter()
8556                .all(|projection| item.defines(&projection.field))
8557        {
8558            return Ok(BoardFields {
8559                id: board_id,
8560                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8561            });
8562        }
8563        self.board_fields().await
8564    }
8565
8566    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
8567    /// `item` up to what that write left.
8568    ///
8569    /// The body sent differs from the body GitHub holds only inside the slot — see
8570    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
8571    /// the mutation the item's content takes, so a board draft's body is written with
8572    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
8573    async fn write_slot(
8574        &self,
8575        item: &mut Resolved,
8576        slot: &BTreeMap<String, Value>,
8577    ) -> Result<(), SourceError> {
8578        let held = item.raw_body.clone().unwrap_or_default();
8579        let body = with_slot(&held, slot)?;
8580        if body != held {
8581            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8582                .await?;
8583        }
8584        let (visible, slot) = self.visible(Some(body.clone()))?;
8585        item.body = visible.filter(|value| !value.is_empty());
8586        item.raw_body = Some(body);
8587        item.slot = slot;
8588        Ok(())
8589    }
8590
8591    /// This instance's target for a category written to an item of `kind`, refusing one
8592    /// that kind has no option for — before anything is read or written.
8593    ///
8594    /// Nothing here mutates the board's option set to make room for a status. GitHub
8595    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
8596    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
8597    /// field and every item's status.
8598    fn resolved_target(
8599        &self,
8600        kind: ItemKind,
8601        category: StatusCategory,
8602    ) -> Result<StatusTarget, SourceError> {
8603        let target = self.statuses.target(kind, category).clone();
8604        let StatusTarget::Disabled(why) = target else {
8605            return Ok(target);
8606        };
8607        let refusal = why.refusal(&self.name, category, kind);
8608        // Why there is no shipped default, which is the question a person meeting this
8609        // refusal on a source that never mentioned the category asks.
8610        let shipped_none = match category {
8611            StatusCategory::Draft => Some(
8612                "draft has no shipped default because GitHub draft issues cannot have \
8613                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
8614            ),
8615            StatusCategory::Unknown => Some(
8616                "unknown has no shipped default because this board keeps no open-ended status \
8617                 word: every word classified unknown is written to the one board Status option \
8618                 status_mapping.unknown names",
8619            ),
8620            _ => None,
8621        };
8622        Err(match (refusal, shipped_none, why) {
8623            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
8624                SourceError::Refused {
8625                    message: format!("{message}; {note}"),
8626                }
8627            }
8628            (refusal, _, _) => refusal,
8629        })
8630    }
8631
8632    /// What writing `priority` does to one item's `Priority` field on this board, or the
8633    /// refusal naming what the board lacks.
8634    ///
8635    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
8636    /// none already, or of an item not created yet. Every other priority selects the option
8637    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
8638    /// without that option, is refused rather than given one: reads and writes never create
8639    /// a field or an option.
8640    fn priority_write(
8641        &self,
8642        fields: &Value,
8643        existing: Option<&Resolved>,
8644        priority: Priority,
8645    ) -> Result<Option<PriorityWrite>, SourceError> {
8646        let Some(mapping) = &self.priorities else {
8647            return Err(self.holds_no_priority());
8648        };
8649        let Some(wanted) = mapping.option(priority) else {
8650            if !existing.is_some_and(Resolved::holds_priority) {
8651                return Ok(None);
8652            }
8653            let field =
8654                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
8655                    message: format!(
8656                        "an item holding a {PRIORITY_FIELD} value was read without that field"
8657                    ),
8658                })?;
8659            return Ok(Some(PriorityWrite::Clear {
8660                field: required_str(field, "id")?.to_owned(),
8661            }));
8662        };
8663        let missing = |detail: &str| SourceError::Refused {
8664            message: format!(
8665                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
8666                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
8667                 it, or point priority_mapping.{priority} of this source at an option the board \
8668                 has",
8669                self.name, self.name
8670            ),
8671        };
8672        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
8673            return Err(missing(&format!(
8674                "this board has no {PRIORITY_FIELD} field"
8675            )));
8676        };
8677        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8678            return Err(missing(&format!(
8679                "this board's {PRIORITY_FIELD} field is not a single-select field"
8680            )));
8681        }
8682        // An options list that is absent or not a list is an answer this source cannot read,
8683        // not a board lacking the option: `sources fields --apply` is no remedy for it.
8684        let option = field
8685            .get("options")
8686            .and_then(Value::as_array)
8687            .ok_or_else(|| SourceError::Malformed {
8688                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
8689            })?
8690            .iter()
8691            .find(|option| {
8692                option
8693                    .get("name")
8694                    .and_then(Value::as_str)
8695                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8696            })
8697            .ok_or_else(|| missing("this board does not have it"))?;
8698        Ok(Some(PriorityWrite::Select {
8699            field: required_str(field, "id")?.to_owned(),
8700            option: required_str(option, "id")?.to_owned(),
8701        }))
8702    }
8703
8704    /// What writing `metadata` does to each projected text field of one item, and what those
8705    /// fields hold after it — or the refusal, before anything is sent.
8706    ///
8707    /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
8708    /// narrows the work to the entries projecting one of those metadata keys, for a write that
8709    /// touches only them; `None` is every entry. A string the field already holds, and nothing
8710    /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
8711    /// and so is a board without the field, or with a field of that name that is not a text
8712    /// field: reads and writes never create a field.
8713    fn projection_writes(
8714        &self,
8715        fields: &Value,
8716        held: Option<&BTreeMap<String, String>>,
8717        metadata: &BTreeMap<String, Value>,
8718        keys: Option<&[&str]>,
8719    ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
8720        let mut projected = held.cloned().unwrap_or_default();
8721        let mut writes = Vec::new();
8722        for projection in self
8723            .metadata_fields
8724            .iter()
8725            .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
8726        {
8727            let wanted = projection.projected(metadata, &self.name)?;
8728            let field = self.projection_field(fields, projection)?;
8729            let holding = projected.get(&*projection.field);
8730            match wanted {
8731                Projected::Text(text) if holding != Some(&text) => {
8732                    writes.push(ProjectionWrite::Set {
8733                        field,
8734                        text: text.clone(),
8735                    });
8736                    projected.insert(projection.field.to_string(), text);
8737                }
8738                Projected::Nothing if holding.is_some() => {
8739                    writes.push(ProjectionWrite::Clear { field });
8740                    projected.remove(&*projection.field);
8741                }
8742                Projected::Text(_) | Projected::Nothing => {}
8743            }
8744        }
8745        Ok((writes, projected))
8746    }
8747
8748    /// The id of the board text field one projection writes, or the refusal naming what the
8749    /// board has instead.
8750    fn projection_field(
8751        &self,
8752        fields: &Value,
8753        projection: &MetadataField,
8754    ) -> Result<String, SourceError> {
8755        let refuse = |detail: String| SourceError::Refused {
8756            message: format!(
8757                "source {} projects metadata key {:?} onto the board text field {:?}, and \
8758                 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
8759                 text field, or name another field in metadata_fields",
8760                self.name, &*projection.key, &*projection.field, self.name
8761            ),
8762        };
8763        let Some(field) = Board::field(fields, &projection.field)? else {
8764            return Err(refuse("this board has no field of that name".to_owned()));
8765        };
8766        let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
8767        let data_type = optional_str(field, "dataType")?;
8768        if typename != "ProjectV2Field" || data_type != Some("TEXT") {
8769            return Err(refuse(format!(
8770                "this board's field of that name is not a text field (it is a {}), so it is \
8771                 left as it is; rename or remove that field first",
8772                data_type.unwrap_or(typename)
8773            )));
8774        }
8775        Ok(required_str(field, "id")?.to_owned())
8776    }
8777
8778    /// Apply one priority write to one board item.
8779    async fn write_priority(
8780        &self,
8781        board_id: &str,
8782        item_id: &str,
8783        write: &PriorityWrite,
8784    ) -> Result<(), SourceError> {
8785        match write {
8786            PriorityWrite::Select { field, option } => {
8787                self.set_item_field(
8788                    board_id,
8789                    item_id,
8790                    field,
8791                    json!({"singleSelectOptionId": option}),
8792                )
8793                .await
8794            }
8795            PriorityWrite::Clear { field } => {
8796                let data = self
8797                    .graphql(
8798                        graphql::CLEAR_FIELD,
8799                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
8800                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
8801                    )
8802                    .await?;
8803                let returned = data
8804                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
8805                    .ok_or_else(|| SourceError::Malformed {
8806                        message: "GitHub field clear returned no project item".into(),
8807                    })?;
8808                if required_str(returned, "id")? != item_id {
8809                    return Err(SourceError::Malformed {
8810                        message: "GitHub field clear returned the wrong project item".into(),
8811                    });
8812                }
8813                Ok(())
8814            }
8815        }
8816    }
8817
8818    /// The refusal a priority is answered with by an instance configured with no
8819    /// `priority_mapping`, which holds none.
8820    fn holds_no_priority(&self) -> SourceError {
8821        SourceError::Refused {
8822            message: format!(
8823                "source {} holds no task priority: its configuration sets no priority_mapping; \
8824                 next: set priority_mapping on this source, then run `onetaskgraph sources \
8825                 fields {} --apply` to set its board up",
8826                self.name, self.name
8827            ),
8828        }
8829    }
8830
8831    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
8832    ///
8833    /// One field write — a select, or a clear for `none` — and no title, body, label, state
8834    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
8835    async fn set_priority(
8836        &self,
8837        id: &NativeId,
8838        priority: Priority,
8839    ) -> Result<Option<Priority>, SourceError> {
8840        if self.priorities.is_none() {
8841            return Err(self.holds_no_priority());
8842        }
8843        let Some(mut item) = self
8844            .bound_item(id)
8845            .await?
8846            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8847        else {
8848            return Ok(None);
8849        };
8850        if priority == Priority::None && !item.holds_priority() {
8851            return Ok(Some(priority));
8852        }
8853        // The item's own read carries the field's definition whenever it holds a value of
8854        // it, which a clear always does; a select onto an item holding none reads the board.
8855        let board = match (item.carried_board(), item.named_board()) {
8856            (Some(board), _) => board,
8857            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
8858                id,
8859                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
8860            },
8861            _ => self.board_fields().await?,
8862        };
8863        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
8864            return Ok(Some(priority));
8865        };
8866        let (document, root, input) = match write {
8867            PriorityWrite::Select { field, option } => (
8868                graphql::UPDATE_FIELD,
8869                "updateProjectV2ItemFieldValue",
8870                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
8871            ),
8872            PriorityWrite::Clear { field } => (
8873                graphql::CLEAR_FIELD,
8874                "clearProjectV2ItemFieldValue",
8875                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
8876            ),
8877        };
8878        let data = self
8879            .graphql(
8880                document,
8881                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
8882            )
8883            .await?;
8884        let returned = data
8885            .get(root)
8886            .and_then(|value| value.get("projectV2Item"))
8887            .ok_or_else(|| SourceError::Malformed {
8888                message: "GitHub priority write returned no project item".into(),
8889            })?;
8890        if required_str(returned, "id")? != item.item_id {
8891            return Err(SourceError::Malformed {
8892                message: "GitHub priority write returned the wrong project item".into(),
8893            });
8894        }
8895        let value = returned
8896            .get("fieldValueByName")
8897            .ok_or_else(|| SourceError::Malformed {
8898                message: "GitHub priority write returned no priority read-back".into(),
8899            })?;
8900        if !value.is_null()
8901            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
8902        {
8903            return Err(SourceError::Malformed {
8904                message: "GitHub priority read-back is not a Priority field value".into(),
8905            });
8906        }
8907        let values = if value.is_null() {
8908            Vec::new()
8909        } else {
8910            vec![value.clone()]
8911        };
8912        item.priority = self.held_priority(&values)?;
8913        let answer = item.task()?.priority;
8914        self.remember_written(item, false)?;
8915        Ok(Some(answer))
8916    }
8917
8918    /// Replace one task's visible body and nothing else; see
8919    /// [`TaskSource::set_task_content`].
8920    ///
8921    /// One update of the body, which differs from the body GitHub holds only outside the
8922    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
8923    /// this source keeps there reads back as it was. A body that would not change is not
8924    /// sent at all.
8925    async fn replace_content(
8926        &self,
8927        id: &NativeId,
8928        content: &str,
8929    ) -> Result<Option<()>, SourceError> {
8930        let Some(mut item) = self
8931            .bound_item(id)
8932            .await?
8933            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8934        else {
8935            return Ok(None);
8936        };
8937        let held = item.raw_body.clone().unwrap_or_default();
8938        let body = with_content(&held, &self.marked(content))?;
8939        // Checked before anything is sent: content ending in what this source reads as its own
8940        // metadata slot would read back as metadata rather than as the content it was.
8941        let (visible, slot) = self.visible(Some(body.clone()))?;
8942        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
8943            return Err(SourceError::Refused {
8944                message: format!(
8945                    "this content ends in what source {} reads as its own metadata slot \
8946                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
8947                     as content; next: remove that trailing block from the content",
8948                    self.name
8949                ),
8950            });
8951        }
8952        if body != held {
8953            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8954                .await?;
8955        }
8956        item.body = visible.filter(|value| !value.is_empty());
8957        item.raw_body = Some(body);
8958        item.slot = slot;
8959        self.remember_written(item, false)?;
8960        Ok(Some(()))
8961    }
8962
8963    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
8964    ///
8965    /// One read of the item — which carries the board's field definitions and the issue's
8966    /// `blockedBy`, so neither is read again — and then only what differs from it: the
8967    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
8968    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
8969    /// the title, the body — visible content and metadata slot together — and a state change.
8970    /// So an update naming any of title, body, metadata, status and priority is one read and
8971    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
8972    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
8973    /// as a whole write does; an open one selects its option and then reopens. The origin
8974    /// field is never written: an update is of an item that already exists, whose origin is
8975    /// what it is.
8976    ///
8977    /// The task answered is the item as those writes left it, built from the read and what was
8978    /// sent rather than read again — the same record a later read in this run answers from.
8979    ///
8980    /// Its `version` is the one the task holds after the writes: the change token the last
8981    /// mutation's own response spells — each sent as the twin selecting the whole issue, at
8982    /// the price of the plain document — or, where that mutation shows no whole issue (several
8983    /// board fields written together, or `blockedBy` alone), the token one read right after
8984    /// the writes spells. Never the token the item was read at before them. Against GitHub
8985    /// itself that token has not settled yet (`change_token::SETTLED`), so a read gives none
8986    /// until it has, and a write held to it before then is refused rather than let through;
8987    /// once settled, a read gives exactly this token unless the task changed again.
8988    async fn targeted_update(
8989        &self,
8990        id: &NativeId,
8991        update: &TaskUpdate,
8992    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8993        Box::pin(SPELLING.scope(
8994            std::ptr::from_ref(self) as usize,
8995            self.composed_update(id, update),
8996        ))
8997        .await
8998    }
8999
9000    /// [`Self::targeted_update`], inside the scope that has its mutations answer whole.
9001    async fn composed_update(
9002        &self,
9003        id: &NativeId,
9004        update: &TaskUpdate,
9005    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9006        // Everything this source can refuse without reading the item is refused first, in the
9007        // words a whole write of the same fields is refused with.
9008        update.consistent()?;
9009        if update
9010            .title
9011            .as_deref()
9012            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
9013        {
9014            return Err(SourceError::Refused {
9015                message: format!(
9016                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
9017                     spells a document, so it would read back as one rather than as a task; \
9018                     retitle it",
9019                    self.name
9020                ),
9021            });
9022        }
9023        if let Some(delivers) = &update.delivers {
9024            TaskRef::listed(
9025                TaskRef::DELIVERS_KEY,
9026                id,
9027                Some(&self.name),
9028                delivers.clone(),
9029            )
9030            .map_err(|message| SourceError::Refused { message })?;
9031        }
9032        if self.priorities.is_none()
9033            && update
9034                .priority
9035                .is_some_and(|priority| priority != Priority::None)
9036        {
9037            return Err(self.holds_no_priority());
9038        }
9039        let target = update
9040            .status
9041            .as_ref()
9042            .map(|status| self.resolved_target(ItemKind::Task, status.category))
9043            .transpose()?;
9044        let Some(mut item) = self
9045            .bound_item(id)
9046            .await?
9047            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9048        else {
9049            return Ok(None);
9050        };
9051        let before = item.task()?;
9052
9053        let mut status_move = None;
9054        if let (Some(status), Some(target)) = (&update.status, target) {
9055            let board = self.status_board(&item).await?;
9056            let (field, option, name) = self
9057                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
9058                .ok_or_else(|| SourceError::Malformed {
9059                    message: format!(
9060                        "status {} of source {} names no board Status option",
9061                        category_name(status.category),
9062                        self.name
9063                    ),
9064                })?;
9065            let terminal = matches!(target, StatusTarget::Terminal(_, _));
9066            if terminal && item.content_kind == ContentKind::DraftIssue {
9067                return Err(self.closes_a_draft(status.category));
9068            }
9069            let landed = match &target {
9070                StatusTarget::Terminal(_, reason) => {
9071                    self.statuses
9072                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
9073                }
9074                _ => self
9075                    .statuses
9076                    .status(ItemKind::Task, Some(&name), false, None),
9077            };
9078            let option_moves = item
9079                .option
9080                .as_deref()
9081                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
9082            let state_moves = item.content_kind == ContentKind::Issue
9083                && (item.closed != terminal || (terminal && item.status != landed));
9084            if let Some(moves) = Moves::of(option_moves, state_moves) {
9085                status_move = Some(StatusMove {
9086                    board: board.id,
9087                    field,
9088                    option,
9089                    name,
9090                    target,
9091                    landed,
9092                    moves,
9093                });
9094            }
9095        }
9096
9097        let mut priority_move = None;
9098        if let Some(priority) = update.priority
9099            && self.priorities.is_some()
9100            && item.priority != HeldPriority::Read(priority)
9101        {
9102            let board = match (item.carried_board(), item.named_board()) {
9103                (Some(board), _) => board,
9104                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
9105                    id: board,
9106                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
9107                },
9108                _ => self.board_fields().await?,
9109            };
9110            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
9111                priority_move = Some((board.id, write, priority));
9112            }
9113        }
9114
9115        // The metadata keys this update names that a board text field projects, and what
9116        // that does to those fields — refused, as a whole write refuses it, before anything
9117        // is sent.
9118        let touched: Vec<&str> = update
9119            .metadata_set
9120            .keys()
9121            .chain(update.metadata_remove.iter())
9122            .map(MetadataKey::as_str)
9123            .collect();
9124        let mut projection_move = None;
9125        if self
9126            .metadata_fields
9127            .iter()
9128            .any(|projection| touched.contains(&&*projection.key))
9129        {
9130            let mut metadata = item.slot.clone();
9131            for (key, value) in &update.metadata_set {
9132                metadata.insert(key.as_str().to_owned(), value.clone());
9133            }
9134            for key in &update.metadata_remove {
9135                metadata.remove(key.as_str());
9136            }
9137            let board = self.projection_board(&item).await?;
9138            let (writes, projected) = self.projection_writes(
9139                &board.fields,
9140                Some(&item.projected),
9141                &metadata,
9142                Some(&touched),
9143            )?;
9144            projection_move = Some((board.id, writes, projected));
9145        }
9146
9147        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
9148        // recorded in the slot, and the slot travels in the one body update below.
9149        let edges = match &update.depends_on {
9150            Some(edges) => Some(
9151                self.partition_edges(
9152                    BoardKind::Work(ItemKind::Task),
9153                    item.content_kind,
9154                    item.blocked_by.as_deref(),
9155                    edges,
9156                )
9157                .await?,
9158            ),
9159            None => None,
9160        };
9161
9162        // A record primed from what a producer wrote holds no body anybody read, so an update
9163        // changing the body reads it first: by the `Status` write's own answer when the update
9164        // moves the option — the mutation answering with the issue whole — and otherwise by
9165        // reading the issue. The body is then composed against what GitHub holds now.
9166        let mut option_selected = false;
9167        // The mark stays until a read lands, so an update that writes no body, or one whose
9168        // read fails, leaves the next update of it to read the body too.
9169        if (update.content.is_some()
9170            || !update.metadata_set.is_empty()
9171            || !update.metadata_remove.is_empty()
9172            || update.delivers.is_some()
9173            || update.repositories.is_some()
9174            || edges.is_some())
9175            && self.is_unread(id)
9176        {
9177            let live = match status_move.as_ref().filter(|moving| moving.moves.option()) {
9178                Some(moving) => {
9179                    option_selected = true;
9180                    Some(
9181                        self.select_reading_body(
9182                            moving.board.as_str(),
9183                            &item.item_id,
9184                            &moving.field,
9185                            &moving.option,
9186                        )
9187                        .await?,
9188                    )
9189                }
9190                None => {
9191                    self.forget(id)?;
9192                    let Some(read) = self.item_by_id(id).await? else {
9193                        return Ok(None);
9194                    };
9195                    read.raw_body
9196                }
9197            };
9198            let (visible, read_slot) = self.visible(live.clone())?;
9199            self.mark_read(id);
9200            item.raw_body = live;
9201            item.body = visible;
9202            item.slot = read_slot;
9203        }
9204
9205        let mut slot = item.slot.clone();
9206        for (key, value) in &update.metadata_set {
9207            slot.insert(key.as_str().to_owned(), value.clone());
9208        }
9209        for key in &update.metadata_remove {
9210            slot.remove(key.as_str());
9211        }
9212        if let Some(delivers) = &update.delivers {
9213            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
9214        }
9215        // The list a whole write records, by the same rule: none when it is exactly the issue's
9216        // own repository, or empty with no issue repository to derive, and otherwise the list.
9217        if let Some(repositories) = &update.repositories {
9218            let derivable = item
9219                .own_repository
9220                .as_ref()
9221                .map_or(repositories.is_empty(), |own| {
9222                    repositories.as_slice() == std::slice::from_ref(own)
9223                });
9224            if derivable {
9225                slot.remove(Repository::METADATA_KEY);
9226            } else {
9227                slot.insert(
9228                    Repository::METADATA_KEY.to_owned(),
9229                    Value::Array(
9230                        repositories
9231                            .iter()
9232                            .map(|repository| Value::String(repository.as_str().to_owned()))
9233                            .collect(),
9234                    ),
9235                );
9236            }
9237        }
9238        if let Some((_, recorded)) = &edges {
9239            record_edges(&mut slot, recorded);
9240        }
9241        let held = item.raw_body.clone().unwrap_or_default();
9242        let content = match &update.content {
9243            Some(content) => with_content(&held, &self.marked(content))?,
9244            None => held.clone(),
9245        };
9246        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
9247        // the body's bytes, as a metadata write compares it: a slot a person spelled with
9248        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
9249        let body = if slot == item.slot {
9250            content
9251        } else {
9252            with_slot(&content, &slot)?
9253        };
9254        // Checked before anything is sent, as a content write checks it: content ending in
9255        // what this source reads as its own slot would read back as metadata.
9256        let (visible, read) = self.visible(Some(body.clone()))?;
9257        let wanted = update.content.as_deref().or(item.body.as_deref());
9258        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
9259            return Err(SourceError::Refused {
9260                message: format!(
9261                    "this content ends in what source {} reads as its own metadata slot \
9262                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9263                     as content; next: remove that trailing block from the content",
9264                    self.name
9265                ),
9266            });
9267        }
9268        let recorded_moves =
9269            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
9270
9271        // One `updateIssue` carries all three, because every mutation spends the secondary
9272        // limiter and the title, body and state are one mutation's inputs.
9273        let mut fields = serde_json::Map::new();
9274        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
9275            fields.insert("title".to_owned(), json!(title));
9276        }
9277        if body != held {
9278            fields.insert("body".to_owned(), json!(body));
9279        }
9280        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
9281            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
9282        }
9283        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
9284        // GitHub runs no two requests as one, and runs one document's mutation fields in order
9285        // without undoing an earlier field when a later one fails — so a body written before a
9286        // board field the board then refused would be left changed. Written after every other
9287        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
9288        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
9289        // first, together in one request — a terminal option selected before the issue
9290        // closes, as a whole write does — then the `blockedBy` difference, then the body.
9291        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
9292        let mut clears: Vec<(&BoardId, &str)> = Vec::new();
9293        if let Some(moving) = status_move
9294            .as_ref()
9295            .filter(|moving| moving.moves.option() && !option_selected)
9296        {
9297            board_writes.push((
9298                &moving.board,
9299                (
9300                    moving.field.clone(),
9301                    json!({"singleSelectOptionId": moving.option}),
9302                ),
9303            ));
9304        }
9305        match &priority_move {
9306            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
9307                board,
9308                (field.clone(), json!({"singleSelectOptionId": option})),
9309            )),
9310            Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
9311            None => {}
9312        }
9313        if let Some((board, writes, _)) = &projection_move {
9314            for write in writes {
9315                let (mut values, mut cleared) = (Vec::new(), Vec::new());
9316                write.join(&mut values, &mut cleared);
9317                board_writes.extend(values.into_iter().map(|value| (board, value)));
9318                clears.extend(cleared.into_iter().map(|field| (board, field)));
9319            }
9320        }
9321        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
9322        boards.extend(clears.iter().map(|(board, _)| *board));
9323        boards.dedup_by(|one, other| one.as_str() == other.as_str());
9324        for board in boards {
9325            let writes = board_writes
9326                .iter()
9327                .filter(|(on, _)| on.as_str() == board.as_str())
9328                .map(|(_, write)| write.clone())
9329                .collect::<Vec<_>>();
9330            let cleared = clears
9331                .iter()
9332                .filter(|(on, _)| on.as_str() == board.as_str())
9333                .map(|(_, field)| *field)
9334                .collect::<Vec<_>>();
9335            self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
9336                .await?;
9337        }
9338        // Whether anything is sent: what the version answered has to come after.
9339        let sends =
9340            option_selected || !board_writes.is_empty() || !clears.is_empty() || !fields.is_empty();
9341        let mut blocked_by_moved = false;
9342        if let Some((native, _)) = &edges
9343            && item.content_kind == ContentKind::Issue
9344        {
9345            blocked_by_moved = self
9346                .reconcile_blocked_by(
9347                    &item.id,
9348                    native,
9349                    Issue::Existing(item.blocked_by.as_deref()),
9350                )
9351                .await?;
9352        }
9353        if !fields.is_empty() {
9354            self.update_content(item.content_kind, &item.id, Value::Object(fields))
9355                .await?;
9356        }
9357
9358        // The `blockedBy` this item was read with is what the write just changed, so it is no
9359        // answer any more: a later write in this command would reconcile against it — re-adding
9360        // what it kept and never removing what it dropped — and a forward-edge read would
9361        // answer with it. Left unknown, each asks GitHub instead.
9362        if blocked_by_moved {
9363            item.blocked_by = None;
9364        }
9365        if let Some(title) = &update.title {
9366            item.title.clone_from(title);
9367        }
9368        item.body = visible.filter(|value| !value.is_empty());
9369        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
9370        item.slot = slot;
9371        if let Some(delivers) = &update.delivers {
9372            item.delivers.clone_from(delivers);
9373        }
9374        if let Some(repositories) = &update.repositories {
9375            item.repositories.clone_from(repositories);
9376        }
9377        if let Some(moving) = status_move {
9378            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
9379                && item.content_kind == ContentKind::Issue;
9380            item.status = moving.landed;
9381            item.option = Some(moving.name);
9382        }
9383        if let Some((_, _, priority)) = priority_move {
9384            item.priority = HeldPriority::Read(priority);
9385        }
9386        if let Some((_, _, projected)) = projection_move {
9387            item.projected = projected;
9388        }
9389        let mut task = item.task()?;
9390        let mut written = update.changed(&before, &task);
9391        if blocked_by_moved || recorded_moves {
9392            written.insert(UpdatedField::DependsOn);
9393        }
9394        if sends || blocked_by_moved {
9395            // The token the item was read at is not the one it holds now. A batch of updates
9396            // holding this one's writes back reads every task it wrote once they are sent.
9397            task.version = if self.holding_writes_of(id) {
9398                None
9399            } else {
9400                self.version_after_write(id).await
9401            };
9402        }
9403        self.remember_written(item, false)?;
9404        Ok(Some(TaskUpdateOutcome {
9405            task,
9406            written,
9407            delivers_before: before.delivers,
9408        }))
9409    }
9410
9411    /// The change token the task `id` holds after the writes a targeted update just sent: the
9412    /// one the last mutation's own response showed, or, where none showed the whole issue, the
9413    /// one a read made now spells. `None` when that read cannot say — never the token the
9414    /// task was read at before the writes.
9415    async fn version_after_write(&self, id: &NativeId) -> Option<String> {
9416        // A response that showed the issue whole answers, a token or none: a read made now
9417        // would read the same parts.
9418        if let Some(answered) = self.written_answer(id) {
9419            return answered;
9420        }
9421        // An item this command created is answered from its own record, not read.
9422        if self.created().ok()?.iter().any(|own| own.id == *id) {
9423            return None;
9424        }
9425        let read = self.item_by_id(id).await.ok()??;
9426        let token = read.written_version()?;
9427        self.remember_written_token(&read, token.clone());
9428        Some(token)
9429    }
9430
9431    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
9432    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
9433    ///
9434    /// One update of the body: the content outside the slot, and inside it that one entry,
9435    /// every other entry kept as it was. This source keeps no template answers — an issue has
9436    /// no room beside itself that is not its body, and answers written there would duplicate
9437    /// what the content already says and count against GitHub's body limit — so `answers`
9438    /// reaches nothing here. A body that would not change is not sent at all.
9439    async fn replace_rendering(
9440        &self,
9441        id: &NativeId,
9442        kind: BoardKind,
9443        content: &str,
9444        provenance: &Value,
9445        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
9446    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9447        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
9448            return Ok(None);
9449        };
9450        let held = item.raw_body.clone().unwrap_or_default();
9451        let mut slot = item.slot.clone();
9452        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
9453        let rewritten;
9454        let content = if let Some(assets) = assets {
9455            let uploads = self
9456                .upload_assets(item.own_repository.as_ref(), assets)
9457                .await?;
9458            rewritten =
9459                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
9460            rewritten.as_str()
9461        } else {
9462            content
9463        };
9464        let body = with_slot(&with_content(&held, &self.marked(content))?, &slot)?;
9465        // Checked before anything is sent, as a content write checks it.
9466        let (visible, read) = self.visible(Some(body.clone()))?;
9467        if visible.as_deref().unwrap_or_default() != content || read != slot {
9468            return Err(SourceError::Refused {
9469                message: format!(
9470                    "this content ends in what source {} reads as its own metadata slot \
9471                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9472                     as content; next: remove that trailing block from the template",
9473                    self.name
9474                ),
9475            });
9476        }
9477        if body != held {
9478            self.update_content(item.content_kind, &item.id, json!({"body": body}))
9479                .await?;
9480        }
9481        item.body = visible.filter(|value| !value.is_empty());
9482        item.raw_body = Some(body);
9483        item.slot = read;
9484        self.remember_written(item, false)?;
9485        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
9486            id: id.clone(),
9487            content: Some(content.to_owned()),
9488        }))
9489    }
9490
9491    async fn set_item_field(
9492        &self,
9493        board_id: &str,
9494        item_id: &str,
9495        field_id: &str,
9496        value: Value,
9497    ) -> Result<(), SourceError> {
9498        let data = self
9499            .graphql(
9500                graphql::UPDATE_FIELD,
9501                json!({"input":{
9502                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
9503                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
9504            )
9505            .await?;
9506        field_update_item(&data, item_id).map(|_| ())
9507    }
9508
9509    /// GitHub accepts one value per field mutation; aliases combine those mutations in
9510    /// one request — writes in the order given, then clears. A lone write is
9511    /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
9512    /// request's slots hold go in further requests of the same document, in order. Every
9513    /// returned item id is checked, including optional aliases.
9514    async fn set_item_fields(
9515        &self,
9516        board: &str,
9517        item: &str,
9518        fields: &[(String, Value)],
9519        clears: &[&str],
9520    ) -> Result<(), SourceError> {
9521        if !(fields.is_empty() && clears.is_empty())
9522            && self.hold_fields(board, item, fields, clears)?
9523        {
9524            return Ok(());
9525        }
9526        match (fields, clears) {
9527            ([], []) => return Ok(()),
9528            ([(field, value)], []) => {
9529                return self.set_item_field(board, item, field, value.clone()).await;
9530            }
9531            ([], [field]) => {
9532                return self
9533                    .write_priority(
9534                        board,
9535                        item,
9536                        &PriorityWrite::Clear {
9537                            field: (*field).to_owned(),
9538                        },
9539                    )
9540                    .await;
9541            }
9542            _ => {}
9543        }
9544        // Any field of the request stands in for an input a slot leaves out, which GitHub
9545        // still requires to be well formed although it never runs it.
9546        let filler = fields
9547            .first()
9548            .map_or_else(|| clears[0], |(field, _)| field.as_str());
9549        let mut writes = fields.iter();
9550        let mut cleared = clears.iter();
9551        loop {
9552            let batch: Vec<&(String, Value)> =
9553                writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
9554            let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
9555            if batch.is_empty() && batch_clears.is_empty() {
9556                return Ok(());
9557            }
9558            let mut variables = serde_json::Map::new();
9559            for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
9560                let input = match batch.get(index) {
9561                    Some((field, value)) => {
9562                        json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
9563                    }
9564                    None => json!({"projectId":board,"itemId":item,"fieldId":filler,
9565                                   "value":{"text":""}}),
9566                };
9567                variables.insert(slot.variable.to_owned(), input);
9568                variables.insert(slot.include.to_owned(), json!(index < batch.len()));
9569            }
9570            for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
9571                let field = batch_clears.get(index).map_or(filler, |field| **field);
9572                variables.insert(
9573                    slot.variable.to_owned(),
9574                    json!({"projectId":board,"itemId":item,"fieldId":field}),
9575                );
9576                variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
9577            }
9578            let data = self
9579                .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
9580                .await?;
9581            let sent = FIELD_WRITE_SLOTS[..batch.len()]
9582                .iter()
9583                .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
9584            for slot in sent {
9585                let alias = slot.alias;
9586                let returned = data
9587                    .get(alias)
9588                    .and_then(|value| value.get("projectV2Item"))
9589                    .ok_or_else(|| SourceError::Malformed {
9590                        message: format!("GitHub field update {alias} returned no project item"),
9591                    })?;
9592                if required_str(returned, "id")? != item {
9593                    return Err(SourceError::Malformed {
9594                        message: format!(
9595                            "GitHub field update {alias} returned the wrong project item"
9596                        ),
9597                    });
9598                }
9599            }
9600        }
9601    }
9602
9603    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
9604        let mut after: Option<String> = None;
9605        let mut ids = Vec::new();
9606        loop {
9607            let data = self
9608                .graphql(
9609                    graphql::ISSUE_DEPENDENCIES,
9610                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
9611                )
9612                .await?;
9613            let connection =
9614                data.pointer("/node/blockedBy")
9615                    .ok_or_else(|| SourceError::Malformed {
9616                        message: "GitHub dependency response has no blockedBy connection".into(),
9617                    })?;
9618            ids.extend(
9619                connection
9620                    .get("nodes")
9621                    .and_then(Value::as_array)
9622                    .ok_or_else(|| SourceError::Malformed {
9623                        message: "GitHub dependency response nodes is not an array".into(),
9624                    })?
9625                    .iter()
9626                    .map(|value| required_str(value, "id").map(str::to_owned))
9627                    .collect::<Result<Vec<_>, _>>()?,
9628            );
9629            let next = next_cursor(connection)?;
9630            if let Some(next) = &next {
9631                validate_cursor_progress(after.as_deref(), &next.0)?;
9632            }
9633            after = next.map(|cursor| cursor.0);
9634            if after.is_none() {
9635                return Ok(ids);
9636            }
9637        }
9638    }
9639
9640    async fn dependencies(
9641        &self,
9642        id: &NativeId,
9643        near_kind: ItemKind,
9644        direction: Direction,
9645        page: &PageRequest,
9646    ) -> Result<Page<DependencyEdge>, SourceError> {
9647        validate_page(page)?;
9648        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
9649        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
9650        let recorded = recorded_offset(cursor, direction)?;
9651        // What this issue is blocked by, when a read of it by its own id in this command
9652        // already carried the whole connection — a copy reads the item it writes before it
9653        // reads its edges — and the page asked for is the whole of it, or the recorded tail
9654        // after it. Answered from that read, in the shape the dependency read answers in;
9655        // anything else is asked of GitHub.
9656        let carried = match direction {
9657            Direction::DependsOn => self
9658                .resolved_cache()?
9659                .get(id)
9660                .filter(|item| item.content_kind == ContentKind::Issue)
9661                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
9662            Direction::DependedOnBy => None,
9663        }
9664        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
9665        // Asked for even in the recorded phase, whose page reads nothing from the
9666        // connection: `__typename` is what says whether this item has a native
9667        // relationship at all, and that is what decides which far ends the reserved key is
9668        // allowed to hold.
9669        let data = match carried {
9670            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
9671                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
9672            None => {
9673                let after = if recorded.is_some() { None } else { cursor };
9674                let first = page.limit.min(MAX_PAGE_SIZE);
9675                match direction {
9676                    Direction::DependsOn => {
9677                        self.graphql(
9678                            graphql::ISSUE_DEPENDS_ON,
9679                            json!({"id":id.0,"first":first,"after":after,"owner":self.owner,
9680                                   "number":self.project_number,
9681                                   "boardItems":BOARD_ITEMS_PAGE_SIZE}),
9682                        )
9683                        .await?
9684                    }
9685                    Direction::DependedOnBy => {
9686                        self.graphql(
9687                            graphql::ISSUE_DEPENDED_ON_BY,
9688                            json!({"id":id.0,"first":first,"after":after}),
9689                        )
9690                        .await?
9691                    }
9692                }
9693            }
9694        };
9695        let draft_slot = draft_slot(&data, id, self.project_number)?;
9696        let node =
9697            data.get("node")
9698                .filter(|v| !v.is_null())
9699                .ok_or_else(|| SourceError::Refused {
9700                    message: format!(
9701                        "GitHub item {} was not found or does not support dependencies",
9702                        id.0
9703                    ),
9704                })?;
9705        let connection_name = match direction {
9706            Direction::DependsOn => "blockedBy",
9707            Direction::DependedOnBy => "blocking",
9708        };
9709        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
9710        // named natively and the reserved key may hold any far end. An issue's connections
9711        // hold issues, and this source reads them at the near item's own level.
9712        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
9713        if let Some(offset) = recorded {
9714            return Ok(recorded_page(
9715                self.recorded_edges(
9716                    id,
9717                    near_kind,
9718                    direction,
9719                    natively_names,
9720                    node,
9721                    draft_slot.as_ref(),
9722                )
9723                .await?,
9724                offset,
9725                limit,
9726            ));
9727        }
9728        if natively_names.is_none() {
9729            return Ok(recorded_page(
9730                self.recorded_edges(
9731                    id,
9732                    near_kind,
9733                    direction,
9734                    natively_names,
9735                    node,
9736                    draft_slot.as_ref(),
9737                )
9738                .await?,
9739                0,
9740                limit,
9741            ));
9742        }
9743        let connection = node
9744            .get(connection_name)
9745            .ok_or_else(|| SourceError::Malformed {
9746                message: "GitHub dependency response is missing its connection".into(),
9747            })?;
9748        let nodes = connection
9749            .get("nodes")
9750            .and_then(Value::as_array)
9751            .ok_or_else(|| SourceError::Malformed {
9752                message: "GitHub dependency response nodes is not an array".into(),
9753            })?;
9754        // `from` depends on `to`, always. GitHub spells the same relationship from either
9755        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
9756        // it — so the near item is `from` in one direction and `to` in the other.
9757        let items = nodes
9758            .iter()
9759            .map(|value| {
9760                let related = NativeId(required_str(value, "id")?.into());
9761                let related_kind = related_kind(value)?;
9762                let (from, to) = match direction {
9763                    Direction::DependsOn => (
9764                        DependencyEndpoint::from_native(id.clone(), near_kind),
9765                        DependencyEndpoint::from_native(related, related_kind),
9766                    ),
9767                    Direction::DependedOnBy => (
9768                        DependencyEndpoint::from_native(related, related_kind),
9769                        DependencyEndpoint::from_native(id.clone(), near_kind),
9770                    ),
9771                };
9772                Ok(DependencyEdge {
9773                    from,
9774                    to,
9775                    kind: DependencyKind::Blocks,
9776                })
9777            })
9778            .collect::<Result<Vec<_>, SourceError>>()?;
9779        let mut next = next_cursor(connection)?;
9780        if let Some(next) = &next {
9781            validate_cursor_progress(cursor, &next.0)?;
9782        }
9783        // The recorded far ends ride on the last page of `blockedBy`, whose answer carried the
9784        // body they are recorded in, as far as the page has room — so they cost no request of
9785        // their own — and only what does not fit is resumed by a cursor.
9786        let mut items = items;
9787        if next.is_none() {
9788            let recorded = self
9789                .recorded_edges(
9790                    id,
9791                    near_kind,
9792                    direction,
9793                    natively_names,
9794                    node,
9795                    draft_slot.as_ref(),
9796                )
9797                .await?;
9798            let room = limit.saturating_sub(items.len());
9799            let total = recorded.len();
9800            items.extend(recorded.into_iter().take(room));
9801            if total > room {
9802                next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
9803            }
9804        }
9805        Ok(Page { items, next })
9806    }
9807
9808    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
9809    /// a far end in another source has to live: no GitHub issue relationship can name one.
9810    ///
9811    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
9812    /// source never writes one down.
9813    ///
9814    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
9815    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
9816    /// request beyond the read already made, and reading the board for them would be a
9817    /// walk of every item for one field of one. A draft has no body in that answer, because
9818    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
9819    /// listing of the board, which can be behind on the very item asked about.
9820    async fn recorded_edges(
9821        &self,
9822        id: &NativeId,
9823        near_kind: ItemKind,
9824        direction: Direction,
9825        natively_names: Option<ItemKind>,
9826        node: &Value,
9827        draft_slot: Option<&Option<BTreeMap<String, Value>>>,
9828    ) -> Result<Vec<DependencyEdge>, SourceError> {
9829        if direction != Direction::DependsOn {
9830            return Ok(Vec::new());
9831        }
9832        let slot = match (node.get("body"), draft_slot) {
9833            (Some(body), _) if natively_names.is_some() => {
9834                metadata_body(body.as_str().map(str::to_owned))?.1
9835            }
9836            // A draft the dependency read itself answered: this board's, or none of its.
9837            (_, Some(Some(slot))) => slot.clone(),
9838            (_, Some(None)) => return Ok(Vec::new()),
9839            _ => {
9840                let Some(item) = self.bound_item(id).await? else {
9841                    return Ok(Vec::new());
9842                };
9843                item.slot
9844            }
9845        };
9846        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
9847            .map_err(|message| SourceError::Malformed { message })
9848    }
9849
9850    /// Why `creation_owners` refuses a create filed in `target`, or `None` when it allows it —
9851    /// every create when the list is empty.
9852    fn owner_refusal(&self, target: &RepositoryTarget) -> Option<String> {
9853        if self.creation_owners.is_empty()
9854            || self
9855                .creation_owners
9856                .iter()
9857                .any(|owner| owner.eq_ignore_ascii_case(&target.owner))
9858        {
9859            return None;
9860        }
9861        Some(format!(
9862            "source {} would create the issue in repository {}, owned by {}, and its \
9863             creation_owners allows only repositories of {}",
9864            self.name,
9865            target.slug(),
9866            target.owner,
9867            self.creation_owners.join(", ")
9868        ))
9869    }
9870
9871    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
9872        self.repository
9873            .as_ref()
9874            .ok_or_else(|| SourceError::Refused {
9875                message: format!(
9876                    "source {} has no repository configured, and a GitHub Projects board has no \
9877                 repository of its own to create an issue in; set repository: owner/name on \
9878                 this source",
9879                    self.name
9880                ),
9881            })
9882    }
9883
9884    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
9885    /// states.
9886    ///
9887    /// The fallback is demanded first, whichever arm answers: a write without a configured
9888    /// repository is refused naming the field exactly as it was before the rule existed,
9889    /// so a source that could not write before cannot write now, rather than writing for
9890    /// the one item whose own field happens to decide it.
9891    ///
9892    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
9893    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
9894    /// entry owned by someone other than the owner of the parent issue's repository —
9895    /// GitHub accepts a sub-issue from another repository of the same owner and from no
9896    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
9897    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
9898    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
9899    /// and is visible to the token is checked where its node id is resolved, still before
9900    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
9901    /// looked up in a listing of the board, which can be minutes behind an issue its own
9902    /// `projectItems` already places on it — and that read answers first from this process's
9903    /// own record, so a project created moments ago in this command answers though GitHub
9904    /// has not caught up.
9905    async fn creation_target(
9906        &self,
9907        incoming: &Incoming<'_>,
9908    ) -> Result<RepositoryTarget, SourceError> {
9909        let what = format!(
9910            "{} {:?}",
9911            incoming.written.kind().describes(),
9912            incoming.title
9913        );
9914        let target = self
9915            .creation_rule(incoming.repositories, incoming.parent, &what)
9916            .await?;
9917        // The engine asks `creation_refusal` before anything of a copy is written; this holds
9918        // every other create to the same restriction, before `createIssue`.
9919        match self.owner_refusal(&target) {
9920            Some(message) => Err(SourceError::Refused { message }),
9921            None => Ok(target),
9922        }
9923    }
9924
9925    /// [`Self::creation_target`]'s rule over what it reads of the item — its `repositories`
9926    /// and the project issue it is filed under — with `what` naming the item in a refusal: the
9927    /// one place the rule is, so the create, [`TaskSource::creation_repository`] and the
9928    /// write-time read of who can read the issue ([`TaskSource::visibility`]) cannot answer
9929    /// differently.
9930    ///
9931    /// On a source declared private, an item's one named repository is read for its
9932    /// visibility first — once per engine hold, see the `visibility` module — and one that
9933    /// does not read private decides nothing: the issue is created in the parent's
9934    /// repository, else the configured one. A read that cannot be answered refuses.
9935    async fn creation_rule(
9936        &self,
9937        repositories: &[Repository],
9938        parent: Option<&NativeId>,
9939        what: &str,
9940    ) -> Result<RepositoryTarget, SourceError> {
9941        let parent = match parent {
9942            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
9943                SourceError::Refused {
9944                    message: format!(
9945                        "GitHub project issue {} was not found on the board of source {}, so {} \
9946                         cannot be filed under it",
9947                        parent.0, self.name, what
9948                    ),
9949                }
9950            })?),
9951            None => None,
9952        };
9953        let parents_repository = parent
9954            .as_ref()
9955            .map(|parent| {
9956                // A draft is on the board and so is found, but it has no repository to
9957                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
9958                // would refuse the task only once `createIssue` had made it.
9959                if parent.content_kind == ContentKind::DraftIssue {
9960                    return Err(SourceError::Refused {
9961                        message: format!(
9962                            "GitHub project item {} on the board of source {} is a draft, \
9963                             which cannot have sub-issues, so {} cannot be filed under it",
9964                            parent.id.0, self.name, what
9965                        ),
9966                    });
9967                }
9968                // An issue's repository is where a sub-issue is placed and whose owner it
9969                // is compared against, so a parent whose repository this source cannot
9970                // spell as `owner/name` — GitHub's login grammar is wider than this
9971                // source's floor — is one nothing can be filed under.
9972                parent
9973                    .own_repository
9974                    .as_ref()
9975                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
9976                    .ok_or_else(|| SourceError::Malformed {
9977                        message: format!(
9978                            "GitHub project issue {} on the board of source {} is in {}, which \
9979                             is not a {}/owner/name repository this source can place {} in",
9980                            parent.id.0,
9981                            self.name,
9982                            parent
9983                                .own_repository
9984                                .as_ref()
9985                                .map_or("no repository", Repository::as_str),
9986                            RepositoryTarget::HOST,
9987                            what
9988                        ),
9989                    })
9990            })
9991            .transpose()?;
9992        self.creation_rule_in_repository(repositories, parents_repository, what)
9993            .await
9994    }
9995
9996    async fn creation_rule_in_repository(
9997        &self,
9998        repositories: &[Repository],
9999        parents_repository: Option<RepositoryTarget>,
10000        what: &str,
10001    ) -> Result<RepositoryTarget, SourceError> {
10002        let fallback = self.configured_repository()?;
10003        match repositories {
10004            [named] => {
10005                let target =
10006                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
10007                        message: format!(
10008                            "{} names repository {}, which is not a {}/owner/name repository \
10009                             source {} can create an issue in; name one that is, or name none",
10010                            what,
10011                            named.as_str(),
10012                            RepositoryTarget::HOST,
10013                            self.name
10014                        ),
10015                    })?;
10016                // On a source declared private, an issue whose one named repository does not
10017                // read private is created where an item naming none is: the item still names
10018                // that repository in its metadata slot, and its issue stays private.
10019                if self.is_declared_private()
10020                    && self.repository_visibility(&target).await?
10021                        != onetaskgraph_plugin_api::Visibility::Private
10022                {
10023                    return Ok(parents_repository.unwrap_or_else(|| fallback.clone()));
10024                }
10025                if let Some(parents) = &parents_repository
10026                    && parents.owner != target.owner
10027                {
10028                    return Err(SourceError::Refused {
10029                        message: format!(
10030                            "{} names repository {}, owned by {}, but its project's issue is in \
10031                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
10032                             of the same owner as its parent issue; name a repository of {}, or \
10033                             name none",
10034                            what,
10035                            target.slug(),
10036                            target.owner,
10037                            parents.slug(),
10038                            parents.owner,
10039                            parents.owner
10040                        ),
10041                    });
10042                }
10043                Ok(target)
10044            }
10045            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
10046        }
10047    }
10048
10049    /// The node id of the repository `incoming` is being created in, or the refusal naming
10050    /// the item and the repository the token cannot see.
10051    ///
10052    /// Resolved once per command per repository; see [`Self::repository_cache`].
10053    async fn repository_id(
10054        &self,
10055        repository: &RepositoryTarget,
10056        incoming: &Incoming<'_>,
10057    ) -> Result<String, SourceError> {
10058        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
10059            return Ok(id);
10060        }
10061        let data = self
10062            .graphql(
10063                graphql::REPOSITORY,
10064                json!({"owner":repository.owner,"name":repository.name}),
10065            )
10066            .await?;
10067        self.repository_read(&data, repository, incoming)
10068    }
10069
10070    /// The repository's node id out of an answer carrying the `repository` root, held for
10071    /// the rest of this command, or the refusal naming the item that cannot be created in it.
10072    fn repository_read(
10073        &self,
10074        data: &Value,
10075        repository: &RepositoryTarget,
10076        incoming: &Incoming<'_>,
10077    ) -> Result<String, SourceError> {
10078        let node = data
10079            .get("repository")
10080            .filter(|value| !value.is_null())
10081            .ok_or_else(|| SourceError::Refused {
10082                message: format!(
10083                    "GitHub repository {} was not found or is not visible to the token, so {} \
10084                     {:?} cannot be created in it",
10085                    repository.slug(),
10086                    incoming.written.kind().describes(),
10087                    incoming.title
10088                ),
10089            })?;
10090        let id = required_str(node, "id")?.to_owned();
10091        self.repository_cache()?
10092            .insert(repository.clone(), id.clone());
10093        Ok(id)
10094    }
10095
10096    fn repository_cache(
10097        &self,
10098    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
10099        self.repository_cache
10100            .lock()
10101            .map_err(|_| SourceError::Unavailable {
10102                message: "this source's record of the destination repository was left \
10103                          inconsistent by an earlier failure; next: run the command again"
10104                    .into(),
10105            })
10106    }
10107
10108    /// Create or update one board item, whichever kind it is.
10109    async fn write_item(
10110        &self,
10111        incoming: &Incoming<'_>,
10112        target: Option<&NativeId>,
10113        depends_on: &[DependencyEdge],
10114    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10115        let prepared = self.prepare_write(incoming, target, depends_on).await?;
10116        let landed = match &prepared.destination {
10117            // Its content is written last, by `complete_write`, once everything else has landed.
10118            WriteDestination::Existing(item) => Landed {
10119                content_id: item.id.clone(),
10120                item_id: Some(item.item_id.clone()),
10121                url: item.url.clone(),
10122                number: item.number,
10123            },
10124            WriteDestination::New(target) => {
10125                if self.deferring()? {
10126                    self.create_filed_issue(
10127                        prepared.board.id.as_str(),
10128                        target,
10129                        incoming,
10130                        &prepared.body,
10131                    )
10132                    .await?
10133                } else {
10134                    self.create_and_file_issue(
10135                        prepared.board.id.as_str(),
10136                        target,
10137                        incoming,
10138                        &prepared.body,
10139                    )
10140                    .await?
10141                }
10142            }
10143        };
10144        self.complete_write(incoming, prepared, landed).await
10145    }
10146
10147    /// Everything one write of `incoming` reads and decides before it sends anything: what is
10148    /// there now, where a new item is created, the board's fields, and every value refused while
10149    /// nothing has been written — the item's body included.
10150    async fn prepare_write(
10151        &self,
10152        incoming: &Incoming<'_>,
10153        target: Option<&NativeId>,
10154        depends_on: &[DependencyEdge],
10155    ) -> Result<Prepared, SourceError> {
10156        // Refused before anything is read or written: a task or a project titled the way
10157        // this board spells a document would land as an issue this same source reads back
10158        // as a document, so the field this destination cannot carry is named rather than
10159        // written and silently reclassified.
10160        if let Written::Work(kind, _) = incoming.written
10161            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
10162        {
10163            return Err(SourceError::Refused {
10164                message: format!(
10165                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
10166                     spells a document, so it would read back as one rather than as a {}; \
10167                     retitle it, or copy it as a document",
10168                    kind.marker(),
10169                    self.name,
10170                    kind.marker()
10171                ),
10172            });
10173        }
10174        // The destination is read by its own id, and whether this board holds it is decided
10175        // by that read — its own `projectItems` — rather than by whether a listing of the
10176        // board happens to include it yet. See the module documentation.
10177        // An existing issue is never moved; a new one is created where the rule says — and
10178        // knowing where is what lets the board's fields and that repository's id be read
10179        // together, before anything below needs either.
10180        let destination = match target {
10181            Some(target) => {
10182                WriteDestination::Existing(Box::new(self.bound_item(target).await?.ok_or_else(
10183                    || SourceError::Refused {
10184                        message: format!("GitHub destination item {} was not found", target.0),
10185                    },
10186                )?))
10187            }
10188            None => {
10189                let target = self.creation_target(incoming).await?;
10190                self.creation_context(&target, incoming).await?;
10191                WriteDestination::New(target)
10192            }
10193        };
10194        let existing = destination.existing();
10195        let board = self
10196            .fields_for(
10197                existing,
10198                incoming.metadata.contains_key(ORIGIN_KEY),
10199                incoming.written.status().is_some(),
10200                incoming
10201                    .priority
10202                    .is_some_and(|priority| priority != Priority::None),
10203            )
10204            .await?;
10205        let status_target = incoming
10206            .written
10207            .work_status()
10208            .map(|(kind, status)| self.resolved_target(kind, status.category))
10209            .transpose()?;
10210        let column = match (incoming.written.work_status(), status_target.as_ref()) {
10211            (Some((kind, status)), Some(target)) => {
10212                self.column_for(&board.fields, kind, status.category, target)?
10213            }
10214            _ => None,
10215        };
10216        // Resolved before anything is created, for the reason the column above is: a
10217        // priority this board has no option for is refused while nothing has been written.
10218        let priority_write = match incoming.priority {
10219            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
10220            None => None,
10221        };
10222        // And for the reason the priority is: a value no projected field can hold, or a board
10223        // that cannot hold the field, is refused while nothing has been written.
10224        let (projections, projected) = self.projection_writes(
10225            &board.fields,
10226            existing.map(|item| &item.projected),
10227            incoming.metadata,
10228            None,
10229        )?;
10230        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
10231        if content_kind == ContentKind::DraftIssue {
10232            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
10233                (status_target.as_ref(), incoming.written.status())
10234            {
10235                return Err(self.closes_a_draft(status.category));
10236            }
10237            if incoming.parent.is_some() {
10238                return Err(SourceError::Refused {
10239                    message: "GitHub draft items cannot be a project's sub-issue".into(),
10240                });
10241            }
10242        }
10243        match existing {
10244            Some(item) if content_kind == ContentKind::Issue => {
10245                if item.labels != incoming.labels {
10246                    return Err(SourceError::Refused {
10247                        message: "GitHub issue labels differ from the labels being written".into(),
10248                    });
10249                }
10250            }
10251            _ => {
10252                if !incoming.labels.is_empty() {
10253                    return Err(SourceError::Refused {
10254                        message: "GitHub items created by this destination carry no labels".into(),
10255                    });
10256                }
10257            }
10258        }
10259
10260        // The repository the issue really lives in is what the slot below is written against,
10261        // so a single entry that is where the issue is created travels as no key at all, and
10262        // the read side derives it back from the issue.
10263        let own_repository = match &destination {
10264            WriteDestination::Existing(item) => item.own_repository.clone(),
10265            WriteDestination::New(target) => Some(
10266                Repository::try_from(target.origin())
10267                    .map_err(|message| SourceError::Config { message })?,
10268            ),
10269        };
10270        let (native, fallback) = self
10271            .partition_edges(
10272                incoming.written.kind(),
10273                content_kind,
10274                existing.and_then(|item| item.blocked_by.as_deref()),
10275                depends_on,
10276            )
10277            .await?;
10278        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
10279        let content = match incoming.assets {
10280            Some(assets) => {
10281                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
10282                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
10283                    incoming.content.unwrap_or_default(),
10284                    &mut slot,
10285                    &uploads,
10286                );
10287                incoming.content.map(|_| rewritten)
10288            }
10289            None => incoming.content.map(str::to_owned),
10290        };
10291        let marked = content.as_deref().map(|content| self.marked(content));
10292        let body = compose_body(marked.as_deref(), &slot)?;
10293        // Read before anything is created, for the reason the field below is: a value
10294        // this destination cannot store has to refuse, and refusing after `createIssue`
10295        // would leave an issue behind that nothing asked for. The engine writes a
10296        // qualified id here; a caller handing this key anything else is told so rather
10297        // than having it silently stored as no origin at all.
10298        // llmlint: ignore[boundary_inputs_validated, changed_behavior_has_e2e] The qualified id's syntax is the engine's and not this plugin's to police: `GlobalId` is deliberately absent from the contract crate because a plugin never sees a qualified id (AGENTS.md), no plugin crate may depend on the engine to parse one, and `docs/metadata.md` says the contents of this key are what no plugin constructs or interprets. What this boundary owns is whether the value is a string its text field can hold, and that is what it checks.
10299        let origin = match incoming.metadata.get(ORIGIN_KEY) {
10300            None => "",
10301            Some(Value::String(origin)) => origin.as_str(),
10302            Some(other) => {
10303                return Err(SourceError::Refused {
10304                    message: format!(
10305                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
10306                         is {other}"
10307                    ),
10308                });
10309            }
10310        };
10311        // Resolved before anything is created: a board that cannot carry the copy origin
10312        // has to refuse the write, and refusing it after `createIssue` would leave an
10313        // issue behind that nothing asked for.
10314        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
10315            Some(field) => {
10316                if required_str(field, "__typename")? != "ProjectV2Field" {
10317                    return Err(SourceError::Refused {
10318                        message: format!(
10319                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
10320                        ),
10321                    });
10322                }
10323                Some(required_str(field, "id")?.to_owned())
10324            }
10325            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
10326                return Err(SourceError::Refused {
10327                    message: format!(
10328                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
10329                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
10330                         the board"
10331                    ),
10332                });
10333            }
10334            None => None,
10335        };
10336
10337        Ok(Prepared {
10338            origin: origin.to_owned(),
10339            destination,
10340            board,
10341            status_target,
10342            column,
10343            priority_write,
10344            projections,
10345            projected,
10346            content_kind,
10347            own_repository,
10348            native,
10349            slot,
10350            content,
10351            body,
10352            origin_field,
10353        })
10354    }
10355
10356    /// Everything one write does once the item it writes exists: its board fields, its
10357    /// relationships and — for an item that was already there — its content last, then what this
10358    /// process remembers of it.
10359    ///
10360    /// While this source is holding writes back — see [`TaskSource::begin_deferred_writes`] — an
10361    /// item this command created has its fields, its closing and its edges held for
10362    /// [`TaskSource::finish_writes`] rather than sent here.
10363    async fn complete_write(
10364        &self,
10365        incoming: &Incoming<'_>,
10366        prepared: Prepared,
10367        landed: Landed,
10368    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10369        let Prepared {
10370            destination,
10371            board,
10372            status_target,
10373            column,
10374            priority_write,
10375            projections,
10376            projected,
10377            content_kind,
10378            own_repository,
10379            native,
10380            slot,
10381            content,
10382            body,
10383            origin,
10384            origin_field,
10385        } = prepared;
10386        let existing = destination.existing();
10387        let origin = origin.as_str();
10388        let Landed {
10389            content_id,
10390            item_id,
10391            url,
10392            number,
10393        } = landed;
10394
10395        // Only what differs from the item as this write read it is sent: a priority the item
10396        // already holds is not, so an unchanged re-copy writes no field and a person's edit of
10397        // it since that read is left alone.
10398        let priority_write = priority_write.filter(|_| {
10399            !existing.is_some_and(|item| {
10400                incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
10401            })
10402        });
10403        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
10404        let column = column
10405            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
10406            .map(|(field, option, _)| (field, option));
10407        // Creating an item here is several calls — `createIssue`, which files it on the
10408        // board, then its board fields, the parent and the dependencies — and GitHub can fail
10409        // at any of them. Everything this source can refuse *before* the first of those is
10410        // already checked above, so what is left is GitHub itself failing part way. When it
10411        // does over an item this call created, the issue is taken back: a write that
10412        // refused must not leave an item behind that nobody asked for, and one that does
10413        // makes the retry create a second.
10414        // Whether the board-field write carrying a moved origin was answered as landing whole.
10415        // When it was refused, GitHub does not say which of its fields ran before the one that
10416        // failed, so the origin may or may not have moved.
10417        let mut origin_landed = false;
10418        // An item this command created while writes are held back has every part that can land
10419        // later held for `finish_writes` — its own create filed it on the board and under its
10420        // project already — and so does a later write of it in the same command.
10421        let holding = (existing.is_none() && self.deferring()?) || self.holds_back(&content_id)?;
10422        let landed = if holding {
10423            self.hold_back(
10424                &board.id,
10425                &content_id,
10426                existing,
10427                origin_field.as_deref(),
10428                origin,
10429                column,
10430                status_target.as_ref(),
10431                priority_write.as_ref(),
10432                &projections,
10433                &native,
10434            )
10435        } else if let Some(item_id) = item_id.as_deref() {
10436            self.finish_write(
10437                board.id.as_str(),
10438                incoming,
10439                &content_id,
10440                item_id,
10441                content_kind,
10442                existing,
10443                origin_field.as_deref(),
10444                origin,
10445                column,
10446                status_target.as_ref(),
10447                priority_write.as_ref(),
10448                &projections,
10449                &native,
10450                &mut origin_landed,
10451            )
10452            .await
10453        } else {
10454            // An issue a create filed on the board itself has no board item this process
10455            // knows until `finish_writes` reads it, so nothing but a held-back write may land
10456            // on it before then.
10457            Err(SourceError::Malformed {
10458                message: format!(
10459                    "issue {} was written before the board item GitHub filed it as was read",
10460                    content_id.0
10461                ),
10462            })
10463        };
10464        // An existing item's title, body and state go last, in one `updateIssue`, once its board
10465        // fields and its relationships have landed: a refusal of any of those then leaves its
10466        // body — and the metadata slot inside it — exactly as it stood.
10467        let landed = match (landed, existing) {
10468            (Ok(()), Some(item)) => {
10469                self.update_existing(item, incoming, &body, status_target.as_ref())
10470                    .await
10471            }
10472            (landed, _) => landed,
10473        };
10474        if let Err(error) = landed {
10475            match existing {
10476                // Best effort, and the write's own failure is what the caller is told: a
10477                // refusal naming the tidy-up would hide why the write failed at all.
10478                None => {
10479                    let _ = self.delete_issue(&content_id).await;
10480                }
10481                // The origin field is the one piece of an existing item's metadata written
10482                // before its body, so a write refused after it puts it back as it was. When
10483                // that is refused too, the write's own failure is still what the caller is
10484                // told — with what it left behind added, because the item's metadata is then
10485                // not as it stood and a caller retrying has to know which key moved.
10486                Some(item) => {
10487                    let before = item.origin.as_deref().unwrap_or("");
10488                    if let Some(field) = origin_field.as_deref()
10489                        && before != origin
10490                        && let Err(restore) = self
10491                            .set_item_field(
10492                                board.id.as_str(),
10493                                &item.item_id,
10494                                field,
10495                                json!({"text": before}),
10496                            )
10497                            .await
10498                    {
10499                        let left = if origin_landed {
10500                            format!(
10501                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
10502                                 not be put back to {before:?} ({restore}), so item {} still \
10503                                 holds {origin:?} there",
10504                                item.id.0
10505                            )
10506                        } else {
10507                            format!(
10508                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
10509                                 {origin:?}, GitHub does not say whether that part of it ran, \
10510                                 and putting it back to {before:?} was refused ({restore}), so \
10511                                 item {} holds {origin:?} or {before:?} there",
10512                                item.id.0
10513                            )
10514                        };
10515                        return Err(noting(
10516                            error,
10517                            &format!(
10518                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
10519                                 run the write again"
10520                            ),
10521                        ));
10522                    }
10523                }
10524            }
10525            return Err(error);
10526        }
10527
10528        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
10529            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
10530                self.statuses
10531                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
10532            }
10533            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
10534                self.statuses
10535                    .status(kind, written_option.as_deref(), false, None)
10536            }
10537            (Some((_, status)), _) => status.clone(),
10538            (None, _) => Status {
10539                category: StatusCategory::Unknown,
10540                name: "Open".to_owned(),
10541            },
10542        };
10543
10544        // So the rest of this command reads what it just did rather than what the board
10545        // said before it. See `remember_written` for which half takes it.
10546        let remembered = Resolved {
10547            // Empty for an issue whose board item is not read yet: `finish_writes` reads it
10548            // and `learn_item` puts it here.
10549            // llmlint: ignore[invalid_states_unrepresentable] `Resolved` is serialised into the cache's stored write context, so its field type is a stored shape this change may not move; the empty id lives only until `finish_writes` reads the board item in this same command, and nothing addresses it before then — `complete_write` refuses any write naming no board item.
10550            item_id: item_id.unwrap_or_default(),
10551            id: content_id.clone(),
10552            content_kind,
10553            kind: incoming.written.kind(),
10554            title: incoming.title.to_owned(),
10555            // The visible half of the body this write composed, split back off it the
10556            // way a read splits it — so what this record reports is what a read of the
10557            // same issue reports, rather than the person's text with the metadata slot
10558            // still on the end of it.
10559            body: self.visible(body.clone())?.0,
10560            raw_body: body.clone(),
10561            // A document has no status of its own; what it reads back as is whatever
10562            // the issue's own state says, which is what a re-read reports.
10563            status: written_status,
10564            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
10565            priority: match incoming.priority {
10566                Some(priority) => HeldPriority::Read(priority),
10567                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
10568                    item.priority.clone()
10569                }),
10570            },
10571            // What `state_input` asked for: closed for a terminal target, open for any other
10572            // status, and the issue's own state left as it was by a document write.
10573            closed: content_kind == ContentKind::Issue
10574                && match status_target.as_ref() {
10575                    Some(StatusTarget::Terminal(_, _)) => true,
10576                    Some(_) => false,
10577                    None => existing.is_some_and(|item| item.closed),
10578                },
10579            reason: match status_target.as_ref() {
10580                Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
10581                    Some(reason.reason().to_owned())
10582                }
10583                Some(_) => None,
10584                None => existing.and_then(|item| item.reason.clone()),
10585            },
10586            delivers: incoming.delivers.to_vec(),
10587            delivered_by: incoming.delivered_by.to_vec(),
10588            labels: incoming.labels.to_vec(),
10589            parent: incoming.parent.cloned(),
10590            origin: (!origin.is_empty()).then(|| origin.to_owned()),
10591            projected,
10592            number,
10593            // In the update path this is the item's own url, read off `existing` where the
10594            // record above was bound, so one expression serves both halves.
10595            url,
10596            created_at: existing.and_then(|item| item.created_at),
10597            updated_at: existing.and_then(|item| item.updated_at),
10598            // What this write moved is GitHub's to say, so a record of it gives no token until
10599            // it is read again.
10600            item_updated_at: None,
10601            comment_count: None,
10602            relationship_events: None,
10603            served_at: None,
10604            facets: None,
10605            own_repository,
10606            repositories: incoming.repositories.to_vec(),
10607            classification: incoming.classification,
10608            slot,
10609            board_id: Some(board.id.as_str().to_owned()),
10610            fields: board
10611                .fields
10612                .get("nodes")
10613                .and_then(Value::as_array)
10614                .cloned()
10615                .unwrap_or_default(),
10616            board_fields: Some(board.fields.clone()),
10617            // What this write left the relationship holding is known by id alone, and a
10618            // later read of its edges needs each far end's kind, so it reads them again.
10619            blocked_by: None,
10620        };
10621        self.remember_written(remembered, existing.is_none())?;
10622        Ok(onetaskgraph_plugin_api::AssetsWritten {
10623            id: content_id,
10624            content,
10625        })
10626    }
10627
10628    /// Everything a write does after the item exists: its board fields, its parent, and
10629    /// its dependencies.
10630    ///
10631    /// Split out of `write_item` so there is one place a failure past the point of no
10632    /// return is caught, rather than a tidy-up repeated at each `?` above.
10633    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
10634    // so there is one place a failure past the point of no return is caught, and its
10635    // arguments are exactly the values that tail already had in scope. Bundling them into a
10636    // struct would describe no concept — it would be "the arguments of this function" — and
10637    // would put the whole of `write_item`'s locals behind one more indirection.
10638    #[allow(clippy::too_many_arguments)]
10639    async fn finish_write(
10640        &self,
10641        board_id: &str,
10642        incoming: &Incoming<'_>,
10643        content_id: &NativeId,
10644        item_id: &str,
10645        content_kind: ContentKind,
10646        existing: Option<&Resolved>,
10647        origin_field: Option<&str>,
10648        origin: &str,
10649        column: Option<(String, String)>,
10650        status_target: Option<&StatusTarget>,
10651        priority: Option<&PriorityWrite>,
10652        projections: &[ProjectionWrite],
10653        native: &[String],
10654        origin_landed: &mut bool,
10655    ) -> Result<(), SourceError> {
10656        let (fields, clears) = field_writes(
10657            existing,
10658            origin_field,
10659            origin,
10660            column,
10661            priority,
10662            projections,
10663        );
10664        self.set_item_fields(board_id, item_id, &fields, &clears)
10665            .await?;
10666        *origin_landed = true;
10667
10668        // An existing issue closes in the `updateIssue` its write ends with; one created just
10669        // now closes here, once its option is selected.
10670        if existing.is_none()
10671            && content_kind == ContentKind::Issue
10672            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
10673        {
10674            self.update_content(
10675                ContentKind::Issue,
10676                content_id,
10677                json!({"stateInput":state_input(status_target)}),
10678            )
10679            .await?;
10680        }
10681
10682        if content_kind == ContentKind::Issue {
10683            self.reparent(
10684                existing.and_then(|item| item.parent.clone()),
10685                content_id,
10686                incoming.parent,
10687            )
10688            .await?;
10689            // A document takes part in no dependency graph, so writing one neither reads
10690            // nor changes the issue's own `blockedBy` relationships. Reconciling them
10691            // against the empty list a document write carries would *delete* whatever
10692            // relationships a person had made on that issue, which is a write nobody
10693            // asked for.
10694            if incoming.written.kind() != BoardKind::Document {
10695                let issue = match existing {
10696                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
10697                    None => Issue::Created,
10698                };
10699                self.reconcile_blocked_by(content_id, native, issue).await?;
10700            }
10701        }
10702        Ok(())
10703    }
10704
10705    /// Whether this source is holding writes back — see [`TaskSource::begin_deferred_writes`].
10706    fn deferring(&self) -> Result<bool, SourceError> {
10707        Ok(self.deferred()?.is_some())
10708    }
10709
10710    /// Whether `content` is an item this command created whose parts are held back.
10711    fn holds_back(&self, content: &NativeId) -> Result<bool, SourceError> {
10712        Ok(self
10713            .deferred()?
10714            .as_ref()
10715            .is_some_and(|deferred| deferred.holds(content)))
10716    }
10717
10718    fn deferred(&self) -> Result<std::sync::MutexGuard<'_, Option<Deferred>>, SourceError> {
10719        self.deferred.lock().map_err(|_| SourceError::Unavailable {
10720            message: "this source's record of the writes it is holding back was left \
10721                      inconsistent by an earlier failure; next: run the command again"
10722                .into(),
10723        })
10724    }
10725
10726    /// Creates one issue filed on the board and under its project by the `createIssue` itself —
10727    /// `projectV2Ids` naming the board and `parentIssueId` the project — which is what a copy
10728    /// creates with: one request where adding it to the board and filing it under its project
10729    /// were two more. GitHub adds the board item after answering, so its id is not known here;
10730    /// [`TaskSource::finish_writes`] reads it.
10731    async fn create_filed_issue(
10732        &self,
10733        board_id: &str,
10734        repository: &RepositoryTarget,
10735        incoming: &Incoming<'_>,
10736        body: &Option<String>,
10737    ) -> Result<Landed, SourceError> {
10738        let repository_id = self.repository_id(repository, incoming).await?;
10739        let data = self
10740            .graphql(
10741                graphql::CREATE_ISSUE,
10742                json!({"input": filed_input(&repository_id, board_id, incoming, body)}),
10743            )
10744            .await?;
10745        let created = data
10746            .pointer("/createIssue/issue")
10747            .filter(|value| !value.is_null())
10748            .ok_or_else(|| SourceError::Malformed {
10749                message: "GitHub issue creation returned no issue".into(),
10750            })?;
10751        self.created_landed(created).await
10752    }
10753
10754    /// Keep the address `createIssue` answered for the issue `content`, for
10755    /// [`TaskSource::written_location`].
10756    fn note_written(&self, content: &NativeId, url: Option<&str>) {
10757        if let Some(url) = url {
10758            self.written
10759                .lock()
10760                .unwrap_or_else(std::sync::PoisonError::into_inner)
10761                .insert(content.clone(), url.to_owned());
10762        }
10763    }
10764
10765    /// What one `createIssue` answered, as the item it created — or, when its number cannot be
10766    /// read, the refusal, after taking the issue back.
10767    async fn created_landed(&self, created: &Value) -> Result<Landed, SourceError> {
10768        let content_id = NativeId(required_nonblank_str(created, "id")?.to_owned());
10769        let url = optional_str(created, "url")?.map(str::to_owned);
10770        let number = match created_issue_number(created) {
10771            Ok(number) => number,
10772            Err(error) => {
10773                let _ = self.delete_issue(&content_id).await;
10774                return Err(error);
10775            }
10776        };
10777        self.note_written(&content_id, url.as_deref());
10778        Ok(Landed {
10779            content_id,
10780            item_id: None,
10781            url,
10782            number,
10783        })
10784    }
10785
10786    /// Hold back everything a write of the created item `content` would send after creating it,
10787    /// for [`TaskSource::finish_writes`]: the same field values, closing and edges
10788    /// `finish_write` sends, the parent excepted, which its create already named. A later write
10789    /// of the same item replaces what it changes.
10790    // llmlint: ignore[suppressions_justified] The arguments are `finish_write`'s, which this holds back instead of sending, and they are exactly the values `complete_write` has in scope; bundling them would describe no concept.
10791    #[allow(clippy::too_many_arguments)]
10792    fn hold_back(
10793        &self,
10794        board_id: &BoardId,
10795        content: &NativeId,
10796        existing: Option<&Resolved>,
10797        origin_field: Option<&str>,
10798        origin: &str,
10799        column: Option<(String, String)>,
10800        status_target: Option<&StatusTarget>,
10801        priority: Option<&PriorityWrite>,
10802        projections: &[ProjectionWrite],
10803        native: &[String],
10804    ) -> Result<(), SourceError> {
10805        let status = column.map(|(field, option)| {
10806            let name = self
10807                .fields_cache()
10808                .ok()
10809                .and_then(|fields| {
10810                    fields.as_ref().and_then(|board| {
10811                        option_name(&board.fields, &field, &option).map(str::to_owned)
10812                    })
10813                })
10814                .unwrap_or_default();
10815            (field, option, name)
10816        });
10817        let (fields, clears) =
10818            field_writes(existing, origin_field, origin, None, priority, projections);
10819        let close = match status_target {
10820            Some(StatusTarget::Terminal(_, reason)) => Some(*reason),
10821            _ => None,
10822        };
10823        let mut deferred = self.deferred()?;
10824        let deferred = deferred.get_or_insert_with(Deferred::default);
10825        if let Deferred::Nothing = deferred {
10826            *deferred = Deferred::Held {
10827                board: board_id.clone(),
10828                created: Vec::new(),
10829            };
10830        }
10831        let Deferred::Held { created, .. } = deferred else {
10832            unreachable!("held back on the line above");
10833        };
10834        let held = match created.iter().position(|held| held.content == *content) {
10835            Some(at) => &mut created[at],
10836            None => {
10837                created.push(HeldBack {
10838                    content: content.clone(),
10839                    fields: Vec::new(),
10840                    status: None,
10841                    clears: Vec::new(),
10842                    close: None,
10843                    edges: Vec::new(),
10844                });
10845                created.last_mut().expect("just held")
10846            }
10847        };
10848        for (field, value) in fields {
10849            held.fields.retain(|(held, _)| *held != field);
10850            held.clears.retain(|held| *held != field);
10851            held.fields.push((field, value));
10852        }
10853        for field in clears {
10854            held.fields.retain(|(held, _)| held != field);
10855            if !held.clears.iter().any(|held| held == field) {
10856                held.clears.push(field.to_owned());
10857            }
10858        }
10859        if status.is_some() {
10860            held.status = status;
10861        }
10862        if close.is_some() {
10863            held.close = close;
10864        }
10865        // Nothing of an item created in this command has been sent but its create, so the edges
10866        // it is to have are exactly the ones its latest write names.
10867        held.edges = native.iter().map(|far| NativeId(far.clone())).collect();
10868        Ok(())
10869    }
10870
10871    /// Create every task of `writes`, together, filed on the board and under each one's
10872    /// project: [`graphql::CREATE_ISSUES`], [`MUTATION_BATCH`] to a request.
10873    ///
10874    /// Each is prepared as [`Self::write_item`] prepares one — every refusal a write owes made
10875    /// before the first request — then created, then completed with its parts held back. A
10876    /// request refused part way leaves the issues it did create, which are taken back, with the
10877    /// rest of this call's, before the refusal is answered.
10878    async fn create_together(
10879        &self,
10880        writes: &[ItemWrite<Task>],
10881    ) -> Result<Vec<NativeId>, SourceError> {
10882        let incomings: Vec<Incoming<'_>> = writes
10883            .iter()
10884            .map(|write| self.task_incoming(write, None))
10885            .collect::<Result<_, _>>()?;
10886        let mut prepared = Vec::with_capacity(writes.len());
10887        for (incoming, write) in incomings.iter().zip(writes) {
10888            if write.target.is_some() {
10889                return Err(SourceError::Malformed {
10890                    message: "a write naming its target was handed to a batch of creates".into(),
10891                });
10892            }
10893            prepared.push(
10894                self.prepare_write(incoming, None, &write.depends_on)
10895                    .await?,
10896            );
10897        }
10898        let mut inputs = Vec::with_capacity(prepared.len());
10899        for (incoming, prepared) in incomings.iter().zip(&prepared) {
10900            let WriteDestination::New(target) = &prepared.destination else {
10901                return Err(SourceError::Malformed {
10902                    message: "an update was decided for a write handed to a batch of creates"
10903                        .into(),
10904                });
10905            };
10906            let repository_id = self.repository_id(target, incoming).await?;
10907            inputs.push(filed_input(
10908                &repository_id,
10909                prepared.board.id.as_str(),
10910                incoming,
10911                &prepared.body,
10912            ));
10913        }
10914        let mut landed: Vec<Landed> = Vec::with_capacity(inputs.len());
10915        let refused = 'create: {
10916            for chunk in inputs.chunks(MUTATION_BATCH) {
10917                let mut variables = serde_json::Map::new();
10918                for slot in 0..MUTATION_BATCH {
10919                    variables.insert(
10920                        format!("create{slot}"),
10921                        chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
10922                    );
10923                    variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
10924                }
10925                let data = match self
10926                    .graphql(graphql::CREATE_ISSUES, Value::Object(variables))
10927                    .await
10928                {
10929                    Ok(data) => data,
10930                    Err(error) => break 'create Some(error),
10931                };
10932                let mut refusal = data.get(ANSWER_ERRORS).map(|errors| {
10933                    let said: Vec<&str> = errors
10934                        .as_array()
10935                        .into_iter()
10936                        .flatten()
10937                        .filter_map(|error| error.get("message").and_then(Value::as_str))
10938                        .collect();
10939                    SourceError::Refused {
10940                        message: if said.is_empty() {
10941                            "GitHub returned GraphQL errors".to_owned()
10942                        } else {
10943                            said.join("; ")
10944                        },
10945                    }
10946                });
10947                for slot in 0..chunk.len() {
10948                    match data
10949                        .pointer(&format!("/create{slot}/issue"))
10950                        .filter(|value| !value.is_null())
10951                    {
10952                        Some(created) => match self.created_landed(created).await {
10953                            Ok(created) => landed.push(created),
10954                            Err(error) => {
10955                                refusal.get_or_insert(error);
10956                            }
10957                        },
10958                        // An alias GitHub refused answers nothing, and the refusal says why.
10959                        None => {
10960                            refusal.get_or_insert(SourceError::Malformed {
10961                                message: "GitHub issue creation returned no issue".into(),
10962                            });
10963                        }
10964                    }
10965                }
10966                if let Some(error) = refusal {
10967                    break 'create Some(error);
10968                }
10969            }
10970            None
10971        };
10972        if let Some(error) = refused {
10973            // None of these ids reaches the caller, so an issue GitHub will not take back is
10974            // named here or by nobody.
10975            let ids: Vec<&NativeId> = landed.iter().map(|created| &created.content_id).collect();
10976            let left = self.delete_issues(&ids).await;
10977            let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
10978            return Err(left_behind(error, &left));
10979        }
10980        let created: Vec<NativeId> = landed
10981            .iter()
10982            .map(|landed| landed.content_id.clone())
10983            .collect();
10984        let mut ids = Vec::with_capacity(landed.len());
10985        for ((incoming, prepared), landed) in incomings.iter().zip(prepared).zip(landed) {
10986            match self.complete_write(incoming, prepared, landed).await {
10987                Ok(written) => ids.push(written.id),
10988                // llmlint: ignore[changed_behavior_has_e2e] While writes are held back, which is the only time a batch is created, completing a created item only holds its parts back, and that fails on nothing but a poisoned lock, which no journey can cause; the issues are taken back regardless, because the caller is never handed their ids.
10989                Err(error) => {
10990                    let ids: Vec<&NativeId> = created.iter().collect();
10991                    let left = self.delete_issues(&ids).await;
10992                    let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
10993                    return Err(left_behind(error, &left));
10994                }
10995            }
10996        }
10997        Ok(ids)
10998    }
10999
11000    /// Hold back the update of the existing issue `issue` — `members`, what its own
11001    /// [`graphql::UPDATE_ISSUE`] would send beside its id — while writes are being held back — so the updates a copy makes one after
11002    /// another go to GitHub as one request. Whatever this source held of the issue is dropped
11003    /// as the mutation itself would drop it. Answers whether it was held: never while a write
11004    /// the store's cache expects runs, which records each mutation's own answer, and never for
11005    /// an issue an update is already held for, which that one is sent ahead of.
11006    fn hold_update(&self, issue: &NativeId, members: &Value) -> Result<bool, SourceError> {
11007        if self.expected.journaling() || self.deferred()?.is_none() {
11008            return Ok(false);
11009        }
11010        let mut held = self
11011            .held_updates
11012            .lock()
11013            .map_err(|_| SourceError::Unavailable {
11014                message: "issue updates held back were left inconsistent; run the command again"
11015                    .into(),
11016            })?;
11017        if held.iter().any(|earlier| earlier.issue == *issue) {
11018            return Ok(false);
11019        }
11020        let named = |value: &str| issue.0 == value;
11021        self.resolved_cache()?
11022            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11023        self.read_ahead()?.retain(|id, item| {
11024            !named(&id.0) && !item.as_ref().is_some_and(|item| named(&item.item_id))
11025        });
11026        self.carried()?
11027            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11028        held.push(HeldUpdate::new(issue, members));
11029        Ok(true)
11030    }
11031
11032    /// Send every update [`Self::hold_update`] held back: one alone as [`graphql::UPDATE_ISSUE`]
11033    /// sends it, several as aliased `updateIssue`s of [`graphql::UPDATE_ISSUES`],
11034    /// [`MUTATION_BATCH`] to a request. Each counts one write, as it would alone.
11035    async fn send_held_updates(&self) -> Result<(), SourceError> {
11036        let held = std::mem::take(&mut *self.held_updates.lock().map_err(|_| {
11037            SourceError::Unavailable {
11038                message: "issue updates held back were left inconsistent; run the command again"
11039                    .into(),
11040            }
11041        })?);
11042        if let [one] = held.as_slice() {
11043            return self
11044                .send_update(
11045                    graphql::UPDATE_ISSUE,
11046                    "/updateIssue/issue",
11047                    &one.issue,
11048                    one.input.clone(),
11049                )
11050                .await;
11051        }
11052        for chunk in held.chunks(MUTATION_BATCH) {
11053            let mut variables = serde_json::Map::new();
11054            for slot in 0..MUTATION_BATCH {
11055                variables.insert(
11056                    format!("update{slot}"),
11057                    chunk.get(slot).unwrap_or(&chunk[0]).input.clone(),
11058                );
11059                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11060            }
11061            let data = self
11062                .graphql(graphql::UPDATE_ISSUES, Value::Object(variables))
11063                .await?;
11064            let inputs: Vec<&Value> = chunk.iter().map(|held| &held.input).collect();
11065            check_aliased_answers(
11066                graphql::UPDATE_ISSUES,
11067                &data,
11068                "update",
11069                &inputs,
11070                &[("issue", "id")],
11071            )?;
11072        }
11073        Ok(())
11074    }
11075
11076    /// Send everything held back since [`TaskSource::begin_deferred_writes`]: one read of the
11077    /// created items' board items, then their field values and edges together in requests of
11078    /// [`WRITE_BATCH`], then their closes in requests of [`MUTATION_BATCH`]. Anything refused
11079    /// takes back every issue held, [`DELETE_BATCH`] to a request, and answers the refusal.
11080    async fn send_held_back(&self) -> Result<(), SourceError> {
11081        let Some(Deferred::Held { board, created }) = self.deferred()?.take() else {
11082            return Ok(());
11083        };
11084        if created.is_empty() {
11085            return Ok(());
11086        }
11087        let sent = self.send_deferred(board.as_str(), &created).await;
11088        if let Err(error) = sent {
11089            // Best effort, and the failure is what the caller is told, as a write of one item
11090            // that fails part way takes back what it created: the copy that held these back
11091            // journalled every one, so one this cannot take back its undo tries again, and says
11092            // so when it cannot either.
11093            let ids: Vec<&NativeId> = created.iter().map(|held| &held.content).collect();
11094            let _ = self.delete_issues(&ids).await;
11095            return Err(error);
11096        }
11097        Ok(())
11098    }
11099
11100    /// The requests [`Self::send_held_back`] makes.
11101    async fn send_deferred(&self, board: &str, created: &[HeldBack]) -> Result<(), SourceError> {
11102        let items = self.created_board_items(board, created).await?;
11103        let mut fields: Vec<Value> = Vec::new();
11104        let mut closes: Vec<Value> = Vec::new();
11105        let mut edges: Vec<Value> = Vec::new();
11106        for held in created {
11107            let (item, status) = &items[&held.content];
11108            let mut values = held.fields.clone();
11109            // A status the board already shows — set by a workflow a person turned on to file
11110            // new items at it — is not written again.
11111            if let Some((field, option, name)) = &held.status
11112                && status.as_deref() != Some(name.as_str())
11113            {
11114                values.insert(0, (field.clone(), json!({"singleSelectOptionId": option})));
11115            }
11116            for (field, value) in values {
11117                fields.push(json!({"projectId":board,"itemId":item,"fieldId":field,"value":value}));
11118            }
11119            if let Some(reason) = held.close {
11120                closes.push(json!({"id": held.content.0, "stateInput": closed_input(reason)}));
11121            }
11122            for far in &held.edges {
11123                edges.push(json!({"issueId": held.content.0, "blockingIssueId": far.0}));
11124            }
11125            if !held.clears.is_empty() {
11126                let clears: Vec<&str> = held.clears.iter().map(String::as_str).collect();
11127                self.set_item_fields(board, item, &[], &clears).await?;
11128            }
11129        }
11130        // Each alias answers with what it wrote, which is what its input named: the board item a
11131        // field value is set on, the issue closed, the issue blocked and the one blocking it.
11132        let field_answer: &[(&str, &str)] = &[("projectV2Item", "itemId")];
11133        let close_answer: &[(&str, &str)] = &[("issue", "id")];
11134        let block_answer: &[(&str, &str)] =
11135            &[("issue", "issueId"), ("blockingIssue", "blockingIssueId")];
11136        // Every field value, then every edge, as one ordered list sent `WRITE_BATCH` aliases to a
11137        // request: a request holding both kinds is the one document carrying both, so a plan's
11138        // values and edges cost one request where they fit in one.
11139        let writes: Vec<HeldWrite<'_>> = fields
11140            .iter()
11141            .map(HeldWrite::Field)
11142            .chain(edges.iter().map(HeldWrite::Edge))
11143            .collect();
11144        for chunk in writes.chunks(WRITE_BATCH) {
11145            let values: Vec<&Value> = chunk
11146                .iter()
11147                .filter_map(|write| match write {
11148                    HeldWrite::Field(input) => Some(*input),
11149                    HeldWrite::Edge(_) => None,
11150                })
11151                .collect();
11152            let blocks: Vec<&Value> = chunk
11153                .iter()
11154                .filter_map(|write| match write {
11155                    HeldWrite::Edge(input) => Some(*input),
11156                    HeldWrite::Field(_) => None,
11157                })
11158                .collect();
11159            let (document, sends) = match (values.is_empty(), blocks.is_empty()) {
11160                (false, false) => (graphql::WRITE_FIELDS_AND_EDGES, ["sendField", "sendBlock"]),
11161                (false, true) => (graphql::WRITE_FIELD_VALUES, ["send", "send"]),
11162                _ => (graphql::ADD_BLOCKED_BYS, ["send", "send"]),
11163            };
11164            let mut variables = serde_json::Map::new();
11165            let kinds = [
11166                ("field", &values, sends[0], field_answer),
11167                ("block", &blocks, sends[1], block_answer),
11168            ];
11169            for (prefix, inputs, send, _) in &kinds {
11170                if inputs.is_empty() {
11171                    continue;
11172                }
11173                for slot in 0..WRITE_BATCH {
11174                    variables.insert(
11175                        format!("{prefix}{slot}"),
11176                        (*inputs.get(slot).unwrap_or(&inputs[0])).clone(),
11177                    );
11178                    variables.insert(format!("{send}{slot}"), json!(slot < inputs.len()));
11179                }
11180            }
11181            let data = self.graphql(document, Value::Object(variables)).await?;
11182            for (prefix, inputs, _, answers) in kinds {
11183                check_aliased_answers(document, &data, prefix, inputs, answers)?;
11184            }
11185        }
11186        for chunk in closes.chunks(MUTATION_BATCH) {
11187            let mut variables = serde_json::Map::new();
11188            for slot in 0..MUTATION_BATCH {
11189                variables.insert(
11190                    format!("close{slot}"),
11191                    chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11192                );
11193                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11194            }
11195            let data = self
11196                .graphql(graphql::CLOSE_ISSUES, Value::Object(variables))
11197                .await?;
11198            let inputs: Vec<&Value> = chunk.iter().collect();
11199            check_aliased_answers(graphql::CLOSE_ISSUES, &data, "close", &inputs, close_answer)?;
11200        }
11201        for held in created {
11202            self.learn_item(&held.content, &items[&held.content].0)?;
11203        }
11204        Ok(())
11205    }
11206
11207    /// The board item each of `created` is on this board, with its `Status` as the board set it:
11208    /// [`graphql::CREATED_BOARD_ITEMS`], read again — after a wait of a second, then two, four,
11209    /// eight and sixteen — for only those GitHub has not filed yet.
11210    ///
11211    /// **Membership is never assumed.** `createIssue`'s `projectV2Ids` files an issue some time
11212    /// after GitHub answered the create — the feasibility spike saw 0.45 to 1.84 seconds, and no
11213    /// create answer carrying the item — so only a read says it is there. One still absent once
11214    /// those reads are spent is filed with [`graphql::ADD_TO_BOARD`], one write each; GitHub
11215    /// filing it meanwhile refuses that "Content already exists in this project", which says it
11216    /// is placed, and one more read finds the item rather than a second one being made.
11217    async fn created_board_items(
11218        &self,
11219        board: &str,
11220        created: &[HeldBack],
11221    ) -> Result<BTreeMap<NativeId, (String, Option<String>)>, SourceError> {
11222        let mut found: BTreeMap<NativeId, (String, Option<String>)> = BTreeMap::new();
11223        let unfiled = |found: &BTreeMap<NativeId, (String, Option<String>)>| -> Vec<NativeId> {
11224            created
11225                .iter()
11226                .map(|held| held.content.clone())
11227                .filter(|id| !found.contains_key(id))
11228                .collect()
11229        };
11230        let mut wait = Duration::from_secs(1);
11231        for attempt in 0..6 {
11232            let absent = unfiled(&found);
11233            if absent.is_empty() {
11234                return Ok(found);
11235            }
11236            if attempt > 0 {
11237                self.clock
11238                    .sleep_because(wait, pacing::cause::BOARD_FILING)
11239                    .await;
11240                wait = wait.saturating_mul(2);
11241            }
11242            self.read_created_items(board, &absent, &mut found).await?;
11243        }
11244        let mut placed_meanwhile = Vec::new();
11245        for id in unfiled(&found) {
11246            match self
11247                .graphql(
11248                    graphql::ADD_TO_BOARD,
11249                    json!({"input":{"projectId":board,"contentId":id.0}}),
11250                )
11251                .await
11252            {
11253                Ok(added) => {
11254                    let item = added
11255                        .pointer("/addProjectV2ItemById/item")
11256                        .filter(|value| !value.is_null())
11257                        .ok_or_else(|| SourceError::Malformed {
11258                            message: "GitHub board addition returned no project item".into(),
11259                        })?;
11260                    found.insert(id, (required_nonblank_str(item, "id")?.to_owned(), None));
11261                }
11262                Err(error) if already_on_the_board(&error) => placed_meanwhile.push(id),
11263                Err(error) => return Err(error),
11264            }
11265        }
11266        if !placed_meanwhile.is_empty() {
11267            self.read_created_items(board, &placed_meanwhile, &mut found)
11268                .await?;
11269        }
11270        let absent = unfiled(&found);
11271        if absent.is_empty() {
11272            return Ok(found);
11273        }
11274        let absent: Vec<&str> = absent.iter().map(|id| id.0.as_str()).collect();
11275        Err(SourceError::Unavailable {
11276            message: format!(
11277                "GitHub created {} filed on board {board} and, after 31 seconds and adding {} \
11278                 to it, still answers {} on no item of it: {}; next: run the copy again once \
11279                 GitHub has caught up",
11280                if absent.len() == 1 {
11281                    "an issue"
11282                } else {
11283                    "issues"
11284                },
11285                if absent.len() == 1 { "it" } else { "them" },
11286                if absent.len() == 1 { "it" } else { "them" },
11287                absent.join(", ")
11288            ),
11289        })
11290    }
11291
11292    /// One [`graphql::CREATED_BOARD_ITEMS`] read of each of `ids`, [`COPY_DETAIL_BATCH`] to a
11293    /// request, adding to `found` the board item of every one GitHub has filed on `board`.
11294    async fn read_created_items(
11295        &self,
11296        board: &str,
11297        ids: &[NativeId],
11298        found: &mut BTreeMap<NativeId, (String, Option<String>)>,
11299    ) -> Result<(), SourceError> {
11300        for chunk in ids.chunks(COPY_DETAIL_BATCH) {
11301            let mut variables = serde_json::Map::new();
11302            for slot in 0..COPY_DETAIL_BATCH {
11303                variables.insert(
11304                    format!("id{slot}"),
11305                    json!(chunk.get(slot).unwrap_or(&chunk[0]).0),
11306                );
11307                variables.insert(format!("read{slot}"), json!(slot < chunk.len()));
11308            }
11309            variables.insert("boardItems".into(), json!(BOARD_ITEMS_PAGE_SIZE));
11310            variables.insert("statusName".into(), json!(STATUS_FIELD));
11311            let data = self
11312                .graphql(graphql::CREATED_BOARD_ITEMS, Value::Object(variables))
11313                .await?;
11314            for (slot, id) in chunk.iter().enumerate() {
11315                // An answer for another issue than the one asked about says nothing of
11316                // where that one is filed, and its board item is not this one's.
11317                let node = data.get(format!("i{slot}")).filter(|node| !node.is_null());
11318                let named = node.map(|node| node.get("id").and_then(Value::as_str));
11319                if let Some(named) = named
11320                    && named != Some(id.0.as_str())
11321                {
11322                    return Err(SourceError::Malformed {
11323                        message: format!(
11324                            "GitHub answered the board items of {} when asked for those of {}",
11325                            named.unwrap_or("an issue it did not name"),
11326                            id.0
11327                        ),
11328                    });
11329                }
11330                // An issue GitHub does not answer yet is not filed yet; one it answers is
11331                // read whole, its connection, each board item's board and its status, and
11332                // any of them unreadable is refused rather than read as not filed.
11333                let Some(node) = node else { continue };
11334                let malformed = |what: &str| SourceError::Malformed {
11335                    message: format!("GitHub answered the board items of {} with {what}", id.0),
11336                };
11337                let nodes = node
11338                    .pointer("/projectItems/nodes")
11339                    .and_then(Value::as_array)
11340                    .ok_or_else(|| malformed("no list of board items"))?;
11341                let mut item = None;
11342                for node in nodes {
11343                    let on = node
11344                        .pointer("/project/id")
11345                        .and_then(Value::as_str)
11346                        .ok_or_else(|| malformed("a board item naming no board"))?;
11347                    if on == board {
11348                        item = Some(node);
11349                    }
11350                }
11351                if let Some(item) = item {
11352                    let status = match item.get("status") {
11353                        None | Some(Value::Null) => None,
11354                        Some(status) => Some(
11355                            status
11356                                .get("name")
11357                                .and_then(Value::as_str)
11358                                .ok_or_else(|| malformed("a status that names no option"))?
11359                                .to_owned(),
11360                        ),
11361                    };
11362                    found.insert(
11363                        id.clone(),
11364                        (required_nonblank_str(item, "id")?.to_owned(), status),
11365                    );
11366                }
11367            }
11368        }
11369        Ok(())
11370    }
11371
11372    /// The board item id of `content`, which this process created and remembered without one,
11373    /// in every half of its own view.
11374    fn learn_item(&self, content: &NativeId, item: &str) -> Result<(), SourceError> {
11375        if let Some(held) = self.resolved_cache()?.get_mut(content) {
11376            held.item_id = item.to_owned();
11377        }
11378        if let Some(held) = self.created()?.iter_mut().find(|held| held.id == *content) {
11379            held.item_id = item.to_owned();
11380        }
11381        Ok(())
11382    }
11383
11384    /// Ask together which items each of `origins` names, and what creating an item reads; see
11385    /// [`TaskSource::read_ahead_origins_for_copy`]. An origin whose answer runs past its first
11386    /// page is left to its own lookup.
11387    async fn origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
11388        if self.repository.is_none() {
11389            return Ok(());
11390        }
11391        let unasked: Vec<&String> = {
11392            let held = self.narrowed_cache()?;
11393            let mut unasked: Vec<&String> = Vec::new();
11394            for origin in origins {
11395                if !held.contains_key(&Narrowing::Origin(origin.clone()).key())
11396                    && !unasked.contains(&origin)
11397                {
11398                    unasked.push(origin);
11399                }
11400            }
11401            unasked
11402        };
11403        if unasked.is_empty() {
11404            return Ok(());
11405        }
11406        // One batch is held back for the read ahead that follows, which may carry a project's
11407        // members beside it in the same request.
11408        if unasked.len() <= ORIGIN_BATCH {
11409            let mut pending =
11410                self.pending_origins
11411                    .lock()
11412                    .map_err(|_| SourceError::Unavailable {
11413                        message:
11414                            "origins held for a read ahead were left inconsistent; run the command \
11415                          again"
11416                                .into(),
11417                    })?;
11418            for origin in unasked {
11419                if !pending.contains(origin) {
11420                    pending.push(origin.clone());
11421                }
11422            }
11423            return Ok(());
11424        }
11425        let unasked: Vec<String> = unasked.into_iter().cloned().collect();
11426        self.send_origin_lookups(&unasked, None).await
11427    }
11428
11429    /// Hold, for [`TaskSource::member_classifications`] of `project`, each member's
11430    /// classification and repositories as the members page `data` carries them — when that page
11431    /// is every member, each one names `project` as its parent, and every record it holds is
11432    /// readable. Any other page holds nothing, so the members are read as they always were.
11433    fn hold_member_classes(&self, project: &NativeId, data: &Value) -> Result<(), SourceError> {
11434        let Some(page) = data
11435            .pointer("/members/subIssues")
11436            .filter(|page| !page.is_null())
11437        else {
11438            return Ok(());
11439        };
11440        if page
11441            .pointer("/pageInfo/hasNextPage")
11442            .and_then(Value::as_bool)
11443            != Some(false)
11444        {
11445            return Ok(());
11446        }
11447        let Some(nodes) = page.get("nodes").and_then(Value::as_array) else {
11448            return Ok(());
11449        };
11450        let mut members = Vec::with_capacity(nodes.len());
11451        for node in nodes {
11452            if node.pointer("/parent/id").and_then(Value::as_str) != Some(project.0.as_str()) {
11453                return Ok(());
11454            }
11455            let Some(body) = node.get("body").and_then(Value::as_str) else {
11456                return Ok(());
11457            };
11458            // Every node here is filed under `project`, which `BoardKind::of` reads as one of its
11459            // tasks whatever its marker says, so each is a member the full read would classify.
11460            let Ok((_, slot)) = metadata_body(Some(body.to_owned())) else {
11461                return Ok(());
11462            };
11463            let Ok(classification) = Classification::from_metadata(&slot) else {
11464                return Ok(());
11465            };
11466            let repositories = if slot.contains_key(Repository::METADATA_KEY) {
11467                let Ok(repositories) = Repository::from_metadata(&slot) else {
11468                    return Ok(());
11469                };
11470                repositories
11471            } else {
11472                let Some(origin) = node
11473                    .pointer("/repository/nameWithOwner")
11474                    .and_then(Value::as_str)
11475                else {
11476                    return Ok(());
11477                };
11478                let Ok(own) = Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST))
11479                else {
11480                    return Ok(());
11481                };
11482                vec![own]
11483            };
11484            members.push((classification, repositories));
11485        }
11486        self.member_classes
11487            .lock()
11488            .map_err(|_| SourceError::Unavailable {
11489                message: "a project's members held for its classification were left \
11490                          inconsistent; run the command again"
11491                    .into(),
11492            })?
11493            .insert(project.clone(), members);
11494        Ok(())
11495    }
11496
11497    /// Send every origin held back for the read ahead, beside the members of `members` when it
11498    /// names a project — or nothing, when none is held.
11499    async fn send_pending_origins(&self, members: Option<&NativeId>) -> Result<(), SourceError> {
11500        let pending = std::mem::take(&mut *self.pending_origins.lock().map_err(|_| {
11501            SourceError::Unavailable {
11502                message: "origins held for a read ahead were left inconsistent; run the command \
11503                          again"
11504                    .into(),
11505            }
11506        })?);
11507        if pending.is_empty() {
11508            return Ok(());
11509        }
11510        self.send_origin_lookups(&pending, members).await
11511    }
11512
11513    /// [`graphql::ORIGIN_LOOKUPS`] of `unasked`, [`ORIGIN_BATCH`] to a request — the first one
11514    /// [`graphql::ORIGIN_LOOKUPS_WITH_MEMBERS`], carrying the members of `members`, when it names
11515    /// a project — holding each origin's answer and, from a members page that is the whole of
11516    /// them, each member's classification.
11517    async fn send_origin_lookups(
11518        &self,
11519        unasked: &[String],
11520        members: Option<&NativeId>,
11521    ) -> Result<(), SourceError> {
11522        let Some(repository) = self.repository.clone() else {
11523            return Ok(());
11524        };
11525        let mut members = members;
11526        for chunk in unasked.chunks(ORIGIN_BATCH) {
11527            let mut variables = json!({"owner":self.owner,"number":self.project_number,
11528                "type":"ISSUE","lookupFirst":ORIGIN_LOOKUPS_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
11529                "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true,
11530                "repositoryOwner":repository.owner,"repositoryName":repository.name});
11531            for slot in 0..ORIGIN_BATCH {
11532                let origin = chunk.get(slot).unwrap_or(&chunk[0]);
11533                variables[format!("filter{slot}")] =
11534                    json!(format!("{ORIGIN_FIELD}:{}", quoted(origin)));
11535                variables[format!("search{slot}")] = json!(
11536                    self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))))
11537                );
11538                variables[format!("ask{slot}")] = json!(slot < chunk.len());
11539            }
11540            let project = members.take();
11541            let document = match project {
11542                Some(project) => {
11543                    variables["project"] = json!(project.0);
11544                    variables["memberFirst"] = json!(MEMBER_PAGE_SIZE);
11545                    graphql::ORIGIN_LOOKUPS_WITH_MEMBERS
11546                }
11547                None => graphql::ORIGIN_LOOKUPS,
11548            };
11549            let data = self.graphql(document, variables).await?;
11550            self.fields_read(&data)?;
11551            if let Some(project) = project {
11552                self.hold_member_classes(project, &data)?;
11553            }
11554            if let Some(node) = data.get("repository").filter(|value| !value.is_null()) {
11555                let id = required_nonblank_str(node, "id")?.to_owned();
11556                self.repository_cache()?.insert(repository.clone(), id);
11557                if let Some(numeric) = node
11558                    .get("databaseId")
11559                    .and_then(Value::as_u64)
11560                    .and_then(std::num::NonZeroU64::new)
11561                {
11562                    self.numeric_repositories
11563                        .lock()
11564                        .await
11565                        .insert(repository.clone(), numeric);
11566                }
11567            }
11568            for (slot, origin) in chunk.iter().enumerate() {
11569                let items = data
11570                    .pointer(&format!("/o{slot}/projectV2/items"))
11571                    .filter(|value| !value.is_null())
11572                    .ok_or_else(|| SourceError::Refused {
11573                        message: format!(
11574                            "GitHub project {}/{} was not found or is not visible to the token",
11575                            self.owner, self.project_number
11576                        ),
11577                    })?;
11578                let searched =
11579                    data.get(format!("s{slot}"))
11580                        .ok_or_else(|| SourceError::Malformed {
11581                            message: "GitHub search response has no search connection".into(),
11582                        })?;
11583                if resumed(items, None)?.has_more() || resumed(searched, None)?.has_more() {
11584                    continue;
11585                }
11586                let mut found: Vec<Resolved> = Vec::new();
11587                for item in optional_nodes(Some(items), "project items")?
11588                    .into_iter()
11589                    .flatten()
11590                {
11591                    if let Some(resolved) = self.resolve(item)?
11592                        && resolved.content_kind == ContentKind::Issue
11593                        && !found.iter().any(|held| held.id == resolved.id)
11594                    {
11595                        found.push(resolved);
11596                    }
11597                }
11598                for node in optional_nodes(Some(searched), "search")?
11599                    .into_iter()
11600                    .flatten()
11601                {
11602                    if let Some(resolved) = self.resolve_issue(node).await?
11603                        && !found.iter().any(|held| held.id == resolved.id)
11604                    {
11605                        found.push(resolved);
11606                    }
11607                }
11608                self.narrowed_cache()?
11609                    .insert(Narrowing::Origin((*origin).clone()).key(), found);
11610            }
11611        }
11612        Ok(())
11613    }
11614
11615    /// Delete every issue of `ids`, [`DELETE_BATCH`] to a request with
11616    /// [`graphql::DELETE_ISSUES`], and answer those still there: each a request's answer does not
11617    /// confirm, every one of a request refused whole included.
11618    async fn delete_issues(&self, ids: &[&NativeId]) -> Vec<NativeId> {
11619        let mut left = Vec::new();
11620        for chunk in ids.chunks(DELETE_BATCH) {
11621            let mut variables = serde_json::Map::new();
11622            for slot in 0..DELETE_BATCH {
11623                let id = chunk.get(slot).unwrap_or(&chunk[0]);
11624                variables.insert(format!("delete{slot}"), json!({"issueId": id.0}));
11625                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11626            }
11627            let data = self
11628                .graphql(graphql::DELETE_ISSUES, Value::Object(variables))
11629                .await
11630                .unwrap_or(Value::Null);
11631            for (slot, id) in chunk.iter().enumerate() {
11632                // Confirmed only by the repository the deleted issue was in, named by a
11633                // non-blank id: anything else says nothing of whether the issue is gone.
11634                let confirmed = data
11635                    .pointer(&format!("/delete{slot}/repository/id"))
11636                    .and_then(Value::as_str)
11637                    .is_some_and(|id| !id.trim().is_empty());
11638                if !confirmed {
11639                    left.push((*id).clone());
11640                }
11641            }
11642        }
11643        left
11644    }
11645
11646    /// Delete one issue, which takes its board item with it.
11647    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
11648        let data = self
11649            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11650            .await?;
11651        data.pointer("/deleteIssue/repository")
11652            .filter(|value| !value.is_null())
11653            .ok_or_else(|| SourceError::Malformed {
11654                message: "GitHub issue deletion returned no repository".into(),
11655            })?;
11656        self.forget(id)?;
11657        Ok(())
11658    }
11659
11660    /// Remove one item this copy created, so a copy that could not finish leaves the board
11661    /// as it found it.
11662    ///
11663    /// Deleting the issue takes its board item with it, so there is no second mutation to
11664    /// keep in step. An id the board does not hold is not an error: the item is already
11665    /// gone, which is the state this asks for. Which that is, is decided by reading the item
11666    /// by its own id — a listing of the board can still be missing an item it holds, and
11667    /// reading that as *already gone* would leave behind the very item this was asked to
11668    /// take back.
11669    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
11670        let Some(item) = self.bound_item(id).await? else {
11671            return Ok(());
11672        };
11673        if item.content_kind == ContentKind::DraftIssue {
11674            return Err(SourceError::Refused {
11675                message: format!(
11676                    "GitHub item {} is a draft, and this source removes an item by deleting \
11677                     its issue; next: remove it from the board by hand",
11678                    id.0
11679                ),
11680            });
11681        }
11682        let data = self
11683            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11684            .await?;
11685        data.pointer("/deleteIssue/repository")
11686            .filter(|value| !value.is_null())
11687            .ok_or_else(|| SourceError::Malformed {
11688                message: "GitHub issue deletion returned no repository".into(),
11689            })?;
11690        self.forget(id)?;
11691        Ok(())
11692    }
11693
11694    /// The issue a comment call on `task` is about, or `None` when this board holds no such
11695    /// task.
11696    ///
11697    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
11698    /// read of the task cannot disagree about which ids name one: a project or a document of
11699    /// this board is not a task here either.
11700    ///
11701    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
11702    /// issues and a draft is not one. It is refused rather than answered with an empty page,
11703    /// which would read as a task nobody has commented on yet.
11704    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
11705        let cached = self.resolved_cache()?.get(task).cloned();
11706        let Some(item) = (match cached {
11707            Some(item) => Some(item),
11708            None => self.item_by_id(task).await?,
11709        })
11710        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11711            return Ok(None);
11712        };
11713        if item.content_kind == ContentKind::DraftIssue {
11714            return Err(self.draft_has_no_comments(task));
11715        }
11716        Ok(Some(item.id))
11717    }
11718
11719    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
11720    /// issues, and a draft is not one.
11721    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
11722        SourceError::Refused {
11723            message: format!(
11724                "task {} of source {} is a draft item on the board, and GitHub keeps \
11725                 comments on issues alone, so a draft has none to read or write; next: \
11726                 convert the draft to an issue on the board, then comment on the issue it \
11727                 becomes",
11728                task.0, self.name
11729            ),
11730        }
11731    }
11732
11733    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
11734    /// request — or `None` when this board holds no task by that id.
11735    ///
11736    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
11737    /// is answered with the draft and the refusal, at the price of the draft's own read.
11738    async fn issue_detail(
11739        &self,
11740        id: &NativeId,
11741        page: &PageRequest,
11742    ) -> Result<Option<TaskDetailRead>, SourceError> {
11743        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
11744        let asked = self
11745            .graphql(
11746                graphql::ISSUE_DETAIL,
11747                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
11748                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
11749                       "duplicates":true}),
11750            )
11751            .await;
11752        let data = match asked {
11753            Ok(data) => data,
11754            Err(error) if unresolvable_node(&error) => return Ok(None),
11755            Err(error) => return Err(error),
11756        };
11757        // `node` is null for an id that names nothing, and absent only from an answer this
11758        // source cannot read — never the same thing.
11759        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
11760            message: format!("GitHub answered the read of {} with no node", id.0),
11761        })?;
11762        self.detail_of(id, node, true, after).await
11763    }
11764
11765    /// Several tasks, each with the first page of its comments when `comments` is set, read
11766    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
11767    /// order.
11768    ///
11769    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
11770    /// one item at a time, so that id is answered as missing and the others as themselves; any
11771    /// other refusal is every id of that batch's answer.
11772    async fn issue_details(
11773        &self,
11774        ids: &[NativeId],
11775        comments: Option<&PageRequest>,
11776    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11777        let mut read = Vec::with_capacity(ids.len());
11778        for batch in ids.chunks(DETAIL_BATCH) {
11779            match self
11780                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
11781                .await
11782            {
11783                Ok(data) => {
11784                    for (slot, id) in batch.iter().enumerate() {
11785                        // Every alias asked for is answered, null for an id naming nothing;
11786                        // one missing is an answer this source cannot read.
11787                        let read_one = match data.get(format!("i{slot}")) {
11788                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
11789                            None => Err(SourceError::Malformed {
11790                                message: format!(
11791                                    "GitHub answered a batch read with no item for {}",
11792                                    id.0
11793                                ),
11794                            }),
11795                        };
11796                        read.push(read_one);
11797                    }
11798                }
11799                Err(error) if unresolvable_node(&error) => {
11800                    for id in batch {
11801                        read.push(match comments {
11802                            Some(page) => self.issue_detail(id, page).await,
11803                            None => self.task_read(id).await,
11804                        });
11805                    }
11806                }
11807                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
11808            }
11809        }
11810        read
11811    }
11812
11813    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
11814    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
11815        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
11816            task,
11817            comments: None,
11818        }))
11819    }
11820
11821    /// What one node a detail read reached says: the task this board holds by `id`, with the
11822    /// page of comments the node carries when `commented` — or `None` for a node that is no
11823    /// task of this board.
11824    ///
11825    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
11826    /// and an item this process created answers from this process's own record, which a node
11827    /// read taken moments after the write can still be behind.
11828    async fn detail_of(
11829        &self,
11830        id: &NativeId,
11831        node: &Value,
11832        commented: bool,
11833        after: Option<&str>,
11834    ) -> Result<Option<TaskDetailRead>, SourceError> {
11835        if node.is_null() {
11836            return Ok(None);
11837        }
11838        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
11839        // An issue answered under one id is that id's, or the answer is not one this source
11840        // can report: reporting another issue's task and comments under the qualified id asked
11841        // for would be the one wrong answer here. A draft's own read checks the same.
11842        if !draft
11843            && optional_str(node, "__typename")? == Some("Issue")
11844            && required_str(node, "id")? != id.0
11845        {
11846            return Err(SourceError::Malformed {
11847                message: format!(
11848                    "GitHub answered the read of {} with issue {}",
11849                    id.0,
11850                    required_str(node, "id")?
11851                ),
11852            });
11853        }
11854        let item = if draft {
11855            self.draft_by_id(id).await?
11856        } else {
11857            self.resolve_issue(node).await?
11858        };
11859        // A read that carried the whole of what the issue is blocked by — a copy's — is kept
11860        // for this command, so the dependency read the copy makes next is answered from it.
11861        if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
11862            self.resolved_cache()?.insert(read.id.clone(), read.clone());
11863        }
11864        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11865            return Ok(None);
11866        };
11867        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
11868        let task = own.unwrap_or(item).task()?;
11869        let comments = match (commented, draft) {
11870            (false, _) => None,
11871            (true, true) => Some(Err(self.draft_has_no_comments(id))),
11872            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
11873        };
11874        if commented && !draft && after.is_none() {
11875            self.expected.learn_comments(id, node);
11876        }
11877        Ok(Some(TaskDetailRead { task, comments }))
11878    }
11879
11880    /// Whether the comment `comment` is one of `issue`'s own.
11881    ///
11882    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
11883    /// comment's id and nothing else: a comment id given against the wrong task would
11884    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
11885    /// names something that is not an issue comment, is a comment this task does not have —
11886    /// which is what GitHub refusing to resolve it means too.
11887    async fn comment_is_on(
11888        &self,
11889        issue: &NativeId,
11890        comment: &NativeId,
11891    ) -> Result<bool, SourceError> {
11892        Ok(self.comment_on(issue, comment).await?.is_some())
11893    }
11894
11895    /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
11896    /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
11897    async fn comment_on(
11898        &self,
11899        issue: &NativeId,
11900        comment: &NativeId,
11901    ) -> Result<Option<String>, SourceError> {
11902        // A write the store's cache expects knows which comments its issue held, and their
11903        // bodies, at the version it expects: what this read would answer.
11904        if let Some(primed) = self.expected.primed_comment(issue, comment) {
11905            return Ok(primed);
11906        }
11907        let asked = self
11908            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
11909            .await;
11910        let data = match asked {
11911            Ok(data) => data,
11912            Err(error) if unresolvable_node(&error) => return Ok(None),
11913            Err(error) => return Err(error),
11914        };
11915        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
11916            return Ok(None);
11917        };
11918        if optional_str(node, "__typename")? != Some("IssueComment") {
11919            return Ok(None);
11920        }
11921        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
11922            message: format!("GitHub issue comment {} names no issue", comment.0),
11923        })?;
11924        if required_str(on, "id")? != issue.0 {
11925            return Ok(None);
11926        }
11927        Ok(Some(required_str(node, "body")?.to_owned()))
11928    }
11929
11930    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
11931    async fn partition_edges(
11932        &self,
11933        near_kind: BoardKind,
11934        near_content: ContentKind,
11935        carried: Option<&[Value]>,
11936        depends_on: &[DependencyEdge],
11937    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
11938        let mut native = Vec::new();
11939        let mut fallback = Vec::new();
11940        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
11941            .iter()
11942            .map(|edge| {
11943                // An opaque far end names another tracker, so it is never this board's own.
11944                let same_source = edge.to.designator().is_none()
11945                    && edge
11946                        .to
11947                        .source()
11948                        .is_none_or(|source| source == self.name.as_str());
11949                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
11950                // `DependencyEndpoint::source` both read it that way — and a native id may hold
11951                // colons of its own, so the far end is everything after that one separator.
11952                // Splitting at the last would truncate `work:urn:task:7` to `7`.
11953                let far_id = if edge.to.is_qualified() {
11954                    edge.to
11955                        .id()
11956                        .split_once(':')
11957                        .map_or(edge.to.id(), |(_, native)| native)
11958                } else {
11959                    edge.to.id()
11960                };
11961                // One that already blocks the near issue was answered by that issue's own
11962                // read, which carried each of its blockers' kinds — an issue every one — so it
11963                // is not read again.
11964                let blocking = carried.and_then(|nodes| {
11965                    nodes
11966                        .iter()
11967                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
11968                });
11969                (edge, far_id, same_source, blocking)
11970            })
11971            .collect();
11972        // Every other same-source far end is read by its own id, exactly as the item it is a
11973        // far end of is: whether this board holds it is that read's answer, never a listing's.
11974        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
11975        let mut unread: Vec<NativeId> = Vec::new();
11976        for (_, far_id, same_source, blocking) in &far_ends {
11977            let id = NativeId((*far_id).to_owned());
11978            if *same_source && blocking.is_none() && !unread.contains(&id) {
11979                unread.push(id);
11980            }
11981        }
11982        let read: BTreeMap<NativeId, Option<Resolved>> = unread
11983            .iter()
11984            .cloned()
11985            .zip(self.items_by_ids(&unread).await?)
11986            .collect();
11987        for (edge, far_id, same_source, blocking) in far_ends {
11988            let far = match (same_source, blocking) {
11989                (false, _) => None,
11990                (true, Some(node)) => Some(FarEnd {
11991                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
11992                        BoardKind::Document
11993                    } else {
11994                        BoardKind::Work(related_kind(node)?)
11995                    },
11996                    content_kind: ContentKind::Issue,
11997                }),
11998                (true, None) => {
11999                    let read = read
12000                        .get(&NativeId(far_id.to_owned()))
12001                        .cloned()
12002                        .flatten()
12003                        .ok_or_else(|| SourceError::Refused {
12004                            message: format!("GitHub dependency item {far_id} was not found"),
12005                        })?;
12006                    Some(FarEnd {
12007                        kind: read.kind,
12008                        content_kind: read.content_kind,
12009                    })
12010                }
12011            };
12012            let far = far.as_ref();
12013            // The caller says which kind the far end is, and this board holds the far end
12014            // itself, so a disagreement is settled here rather than stored: recorded, the
12015            // wrong kind would read back as a cross-level edge that never existed; written
12016            // natively, it would name a relationship of a different level than the caller
12017            // asked for.
12018            //
12019            // A far end this board holds as a *document* fails the same comparison and is
12020            // refused by the same sentence: `ItemKind` has no document variant because
12021            // nothing may point at one, so no caller can name it correctly and the refusal
12022            // is the only honest answer.
12023            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
12024                return Err(SourceError::Refused {
12025                    message: format!(
12026                        "GitHub dependency item {far_id} is a {} of this board, and this item \
12027                         names it as a {}; record the kind it is",
12028                        disagreeing.kind.describes(),
12029                        edge.to.kind.marker()
12030                    ),
12031                });
12032            }
12033            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
12034            // however the far end is spelled — and one classified native here would be
12035            // written nowhere at all, because a draft's native reconciliation never runs.
12036            let native_here = near_content == ContentKind::Issue
12037                && far.is_some_and(|far| {
12038                    far.content_kind == ContentKind::Issue
12039                        && BoardKind::Work(edge.to.kind) == near_kind
12040                });
12041            if native_here {
12042                native.push(far_id.to_owned());
12043            } else {
12044                fallback.push(edge.clone());
12045            }
12046        }
12047        Ok((native, fallback))
12048    }
12049
12050    async fn update_existing(
12051        &self,
12052        item: &Resolved,
12053        incoming: &Incoming<'_>,
12054        body: &Option<String>,
12055        status_target: Option<&StatusTarget>,
12056    ) -> Result<(), SourceError> {
12057        let title = incoming.written_title();
12058        // A terminal status closes the issue here, in the same mutation as its body: its board
12059        // option was selected before this, so a close never lands on an item whose board cannot
12060        // show it.
12061        let mut fields = match item.content_kind {
12062            ContentKind::DraftIssue => json!({"title":title,"body":body}),
12063            ContentKind::Issue => json!({"title":title,"body":body,
12064                                         "stateInput":state_input(status_target)}),
12065        };
12066        // Only the members whose value differs from the item as this write read it are sent,
12067        // and nothing at all when none does — whether or not the store's cache expects a
12068        // version of it. A member left out is one GitHub leaves as it is, so a person's edit
12069        // of it since that read is kept rather than overwritten with what it already held,
12070        // and a re-copy of an unchanged item costs no mutation.
12071        if let Some(members) = fields.as_object_mut() {
12072            if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
12073                members.remove("title");
12074            }
12075            let body_unchanged = match (members.get("body"), item.raw_body.as_deref()) {
12076                (Some(Value::String(sent)), Some(held)) => sent == held,
12077                (Some(Value::Null), held) => held.is_none_or(str::is_empty),
12078                _ => false,
12079            };
12080            if body_unchanged {
12081                members.remove("body");
12082            }
12083            // An issue already in the state the target asks for keeps it: an open one stays
12084            // open, and a closed one whose closure reads as the target's category keeps its
12085            // reason — a duplicate's says more than this write would.
12086            let unchanged_state = match status_target {
12087                Some(StatusTarget::Terminal(_, reason)) => {
12088                    item.closed
12089                        && closed_category(item.reason.as_deref())
12090                            == closed_category(Some(reason.reason()))
12091                }
12092                Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => !item.closed,
12093                None => true,
12094            };
12095            if unchanged_state {
12096                members.remove("stateInput");
12097            }
12098            if members.is_empty() {
12099                return Ok(());
12100            }
12101        }
12102        self.update_content(item.content_kind, &item.id, fields)
12103            .await
12104    }
12105
12106    /// Update one board item's content with exactly `fields` beside its id, through the
12107    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
12108    /// a draft.
12109    ///
12110    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
12111    /// is what lets a narrow write carry the one thing it changes and nothing else.
12112    async fn update_content(
12113        &self,
12114        kind: ContentKind,
12115        id: &NativeId,
12116        fields: Value,
12117    ) -> Result<(), SourceError> {
12118        let (operation, id_key, pointer) = match kind {
12119            ContentKind::DraftIssue => (
12120                graphql::UPDATE_DRAFT,
12121                "draftIssueId",
12122                "/updateProjectV2DraftIssue/draftIssue",
12123            ),
12124            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
12125        };
12126        if kind == ContentKind::Issue && self.hold_issue(&fields)? {
12127            return Ok(());
12128        }
12129        if kind == ContentKind::Issue && self.hold_update(id, &fields)? {
12130            return Ok(());
12131        }
12132        let mut input = fields;
12133        input[id_key] = json!(id.0);
12134        self.send_update(operation, pointer, id, input).await
12135    }
12136
12137    /// Send one item's update, `input`, as `operation`, and hold GitHub's answer at `pointer`
12138    /// to naming that item, `id`.
12139    async fn send_update(
12140        &self,
12141        operation: &str,
12142        pointer: &str,
12143        id: &NativeId,
12144        input: Value,
12145    ) -> Result<(), SourceError> {
12146        let data = self.graphql(operation, json!({"input":input})).await?;
12147        let returned = data
12148            .pointer(pointer)
12149            .ok_or_else(|| SourceError::Malformed {
12150                message: "GitHub item update returned no item".into(),
12151            })?;
12152        if required_str(returned, "id")? != id.0 {
12153            return Err(SourceError::Malformed {
12154                message: "GitHub item update returned the wrong item".into(),
12155            });
12156        }
12157        Ok(())
12158    }
12159
12160    /// Creates one issue, files it on the board, and reports what a read of it would say:
12161    /// its content id, its board item id, and the web address GitHub gave it.
12162    ///
12163    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
12164    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
12165    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
12166    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
12167    /// "Content already exists in this project". A terminal status is not written here:
12168    /// `finish_write` selects its option first and closes the issue after, so a close never
12169    /// lands on an item whose board cannot show it.
12170    ///
12171    /// The address and the number come back here because this is the only place either is
12172    /// known before GitHub's own board read catches up — an item this run created answers
12173    /// the reads that follow it out of the record below, and one remembered without them
12174    /// would report no location and no key for the rest of the run.
12175    async fn create_and_file_issue(
12176        &self,
12177        board_id: &str,
12178        repository: &RepositoryTarget,
12179        incoming: &Incoming<'_>,
12180        body: &Option<String>,
12181    ) -> Result<Landed, SourceError> {
12182        let repository_id = self.repository_id(repository, incoming).await?;
12183        let data = self
12184            .graphql(
12185                graphql::CREATE_ISSUE,
12186                json!({"input":{
12187                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
12188                }}),
12189            )
12190            .await?;
12191        let created = data
12192            .pointer("/createIssue/issue")
12193            .filter(|value| !value.is_null())
12194            .ok_or_else(|| SourceError::Malformed {
12195                message: "GitHub issue creation returned no issue".into(),
12196            })?;
12197        let content_id = NativeId(required_str(created, "id")?.to_owned());
12198        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
12199        // a response without it is not worth failing a landed write over — the item simply
12200        // reports no location until the board read catches up, which is what it did before.
12201        let url = optional_str(created, "url")?.map(str::to_owned);
12202        // The issue exists from here on, so an unreadable number and a refused board
12203        // filing below each try, best effort, to take it back: an issue in the repository
12204        // that is on no board is an item nobody asked for and nothing here would find again.
12205        //
12206        // Its number is optional on the same terms its address is — a landed write is not
12207        // worth failing over a member that came back missing, and such an item reports no
12208        // handle until a board read catches up. A number that is *present* and is not an
12209        // unsigned integer is still a response this source cannot read.
12210        let number = match created_issue_number(created) {
12211            Ok(number) => number,
12212            Err(error) => {
12213                let _ = self.delete_issue(&content_id).await;
12214                return Err(error);
12215            }
12216        };
12217        let added = match self
12218            .graphql(
12219                graphql::ADD_TO_BOARD,
12220                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
12221            )
12222            .await
12223        {
12224            Ok(added) => added,
12225            Err(error) => {
12226                let _ = self.delete_issue(&content_id).await;
12227                return Err(error);
12228            }
12229        };
12230        let item = added
12231            .pointer("/addProjectV2ItemById/item")
12232            .filter(|value| !value.is_null())
12233            .ok_or_else(|| SourceError::Malformed {
12234                message: "GitHub board addition returned no project item".into(),
12235            })?;
12236        self.note_written(&content_id, url.as_deref());
12237        Ok(Landed {
12238            content_id,
12239            item_id: Some(required_str(item, "id")?.to_owned()),
12240            url,
12241            number,
12242        })
12243    }
12244
12245    /// Move one issue under the project it now belongs to, or out of the one it left.
12246    async fn reparent(
12247        &self,
12248        held: Option<NativeId>,
12249        child: &NativeId,
12250        wanted: Option<&NativeId>,
12251    ) -> Result<(), SourceError> {
12252        if held.as_ref() == wanted {
12253            return Ok(());
12254        }
12255        if let Some(held) = &held {
12256            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
12257                .await?;
12258        }
12259        if let Some(wanted) = wanted {
12260            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
12261                .await?;
12262        }
12263        Ok(())
12264    }
12265
12266    async fn sub_issue(
12267        &self,
12268        operation: &str,
12269        parent: &NativeId,
12270        child: &NativeId,
12271        root: &str,
12272    ) -> Result<(), SourceError> {
12273        let data = self
12274            .graphql(
12275                operation,
12276                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
12277            )
12278            .await?;
12279        let issue =
12280            data.pointer(&format!("/{root}/issue"))
12281                .ok_or_else(|| SourceError::Malformed {
12282                    message: "GitHub sub-issue update returned no issue".into(),
12283                })?;
12284        let sub =
12285            data.pointer(&format!("/{root}/subIssue"))
12286                .ok_or_else(|| SourceError::Malformed {
12287                    message: "GitHub sub-issue update returned no sub-issue".into(),
12288                })?;
12289        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
12290            return Err(SourceError::Malformed {
12291                message: "GitHub sub-issue update returned the wrong issues".into(),
12292            });
12293        }
12294        Ok(())
12295    }
12296
12297    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
12298    /// whether there was one.
12299    ///
12300    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
12301    /// relationships are not read: there is nothing a read of them could find.
12302    async fn reconcile_blocked_by(
12303        &self,
12304        content_id: &NativeId,
12305        native: &[String],
12306        issue: Issue<'_>,
12307    ) -> Result<bool, SourceError> {
12308        let current = match issue {
12309            Issue::Created => Vec::new(),
12310            Issue::Existing(Some(held)) => held
12311                .iter()
12312                .map(|far| required_str(far, "id").map(str::to_owned))
12313                .collect::<Result<Vec<_>, _>>()?,
12314            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
12315        };
12316        let mut changed = false;
12317        for (operation, far_id) in current
12318            .iter()
12319            .filter(|id| !native.contains(id))
12320            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
12321            .chain(
12322                native
12323                    .iter()
12324                    .filter(|id| !current.contains(id))
12325                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
12326            )
12327        {
12328            let data = self
12329                .graphql(
12330                    operation,
12331                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
12332                )
12333                .await?;
12334            let root = if operation == graphql::ADD_BLOCKED_BY {
12335                "addBlockedBy"
12336            } else {
12337                "removeBlockedBy"
12338            };
12339            let issue =
12340                data.pointer(&format!("/{root}/issue"))
12341                    .ok_or_else(|| SourceError::Malformed {
12342                        message: "GitHub dependency update returned no issue".into(),
12343                    })?;
12344            let blocker = data
12345                .pointer(&format!("/{root}/blockingIssue"))
12346                .ok_or_else(|| SourceError::Malformed {
12347                    message: "GitHub dependency update returned no blocking issue".into(),
12348                })?;
12349            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
12350            {
12351                return Err(SourceError::Malformed {
12352                    message: "GitHub dependency update returned the wrong issues".into(),
12353                });
12354            }
12355            changed = true;
12356        }
12357        Ok(changed)
12358    }
12359}
12360
12361/// The board field values one write sends, in the order GitHub runs them, and the fields it
12362/// clears after them: the origin field when it moves, the `Status` option `column` names, the
12363/// priority, then each projected field.
12364fn field_writes<'a>(
12365    existing: Option<&Resolved>,
12366    origin_field: Option<&str>,
12367    origin: &str,
12368    column: Option<(String, String)>,
12369    priority: Option<&'a PriorityWrite>,
12370    projections: &'a [ProjectionWrite],
12371) -> (Vec<(String, Value)>, Vec<&'a str>) {
12372    let mut fields = Vec::new();
12373    if let Some(field_id) = origin_field
12374        && existing.map_or(!origin.is_empty(), |item| {
12375            item.origin.as_deref().unwrap_or("") != origin
12376        })
12377    {
12378        fields.push((field_id.to_owned(), json!({"text":origin})));
12379    }
12380    if let Some((field_id, option_id)) = column {
12381        fields.push((field_id, json!({"singleSelectOptionId":option_id})));
12382    }
12383    let mut clears = Vec::new();
12384    match priority {
12385        Some(PriorityWrite::Select { field, option }) => {
12386            fields.push((field.clone(), json!({"singleSelectOptionId":option})));
12387        }
12388        Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
12389        None => {}
12390    }
12391    for projection in projections {
12392        projection.join(&mut fields, &mut clears);
12393    }
12394    (fields, clears)
12395}
12396
12397/// Everything one write decided before sending anything: see
12398/// [`GitHubProjectsSource::prepare_write`].
12399struct Prepared {
12400    /// What the write lands on.
12401    destination: WriteDestination,
12402    board: BoardFields,
12403    status_target: Option<StatusTarget>,
12404    /// The `Status` field, the option and the option's name the status lands on.
12405    // llmlint: ignore[invalid_states_unrepresentable] Exactly what `column_for` returns, which predates this change and four callers share; this record only carries it from `prepare_write` to `complete_write`, which a retyping of that return would reach and this change does not need.
12406    column: Option<(String, String, String)>,
12407    priority_write: Option<PriorityWrite>,
12408    projections: Vec<ProjectionWrite>,
12409    projected: BTreeMap<String, String>,
12410    content_kind: ContentKind,
12411    own_repository: Option<Repository>,
12412    /// The far ends of the item's native blocked-by relationships.
12413    // llmlint: ignore[invalid_states_unrepresentable] What `partition_edges` answers and `finish_write` takes as `&[String]`, both of which predate this change; this record only carries the value between them.
12414    native: Vec<String>,
12415    slot: BTreeMap<String, Value>,
12416    content: Option<String>,
12417    body: Option<String>,
12418    /// The copy origin the item records, empty for none.
12419    // llmlint: ignore[invalid_states_unrepresentable] `finish_write` and `field_writes` take the origin as `&str`, empty for none, as they did before this change; this record only carries it to them.
12420    origin: String,
12421    origin_field: Option<String>,
12422}
12423
12424/// One write a copy held back of the kinds sent together: a board field value, or a blocked-by
12425/// edge, each its input.
12426enum HeldWrite<'a> {
12427    Field(&'a Value),
12428    Edge(&'a Value),
12429}
12430
12431/// Whether `error` is GitHub refusing to file an issue on a board because it is filed there
12432/// already — what `addProjectV2ItemById` answers once `createIssue`'s `projectV2Ids` has placed
12433/// it, rather than answering with the item it holds.
12434fn already_on_the_board(error: &SourceError) -> bool {
12435    error
12436        .to_string()
12437        .contains("Content already exists in this project")
12438}
12439
12440/// Hold every alias `inputs` sent under `prefix` in `data` to answering with what its input
12441/// named — each `(root, named)` of `answers` — and refuse one that answered nothing or another
12442/// item.
12443fn check_aliased_answers(
12444    document: &str,
12445    data: &Value,
12446    prefix: &str,
12447    inputs: &[&Value],
12448    answers: &[(&str, &str)],
12449) -> Result<(), SourceError> {
12450    for (slot, input) in inputs.iter().enumerate() {
12451        for (root, named) in answers {
12452            let answered = data
12453                .pointer(&format!("/{prefix}{slot}/{root}/id"))
12454                .and_then(Value::as_str)
12455                .ok_or_else(|| SourceError::Malformed {
12456                    message: format!(
12457                        "GitHub answered {} with nothing at {prefix}{slot}",
12458                        operation_description(document)
12459                    ),
12460                })?;
12461            let asked = input.get(named).and_then(Value::as_str).unwrap_or_default();
12462            if answered != asked {
12463                return Err(SourceError::Malformed {
12464                    message: format!(
12465                        "GitHub answered {} at {prefix}{slot} with {answered}, which is not \
12466                         {asked}, the item it was asked to write",
12467                        operation_description(document)
12468                    ),
12469                });
12470            }
12471        }
12472    }
12473    Ok(())
12474}
12475
12476/// `error`, which stopped a batch of creates, and — when GitHub refused to take any of them
12477/// back — each issue of `left` it created that is still in the repository, which its caller,
12478/// never handed their ids, could not name.
12479fn left_behind(error: SourceError, left: &[&str]) -> SourceError {
12480    if left.is_empty() {
12481        return error;
12482    }
12483    SourceError::Refused {
12484        message: format!(
12485            "{error}; GitHub then refused to take back {}, which this write created and which \
12486             are still in the repository; next: delete {} before writing again",
12487            left.join(", "),
12488            if left.len() == 1 { "it" } else { "them" }
12489        ),
12490    }
12491}
12492
12493/// What one write lands on: the item it updates, as it stood, or where it creates a new one.
12494enum WriteDestination {
12495    /// The item as it stood, for an update.
12496    Existing(Box<Resolved>),
12497    /// Where a new item is created.
12498    New(RepositoryTarget),
12499}
12500
12501impl WriteDestination {
12502    /// The item an update writes over, as it stood; `None` for a create.
12503    fn existing(&self) -> Option<&Resolved> {
12504        match self {
12505            Self::Existing(item) => Some(item),
12506            Self::New(_) => None,
12507        }
12508    }
12509}
12510
12511/// One existing issue's update held back by [`GitHubProjectsSource::hold_update`]: the issue,
12512/// and the `UpdateIssueInput` its own [`graphql::UPDATE_ISSUE`] would have sent — the members
12513/// it writes beside that issue's id, which only [`Self::new`] sets.
12514struct HeldUpdate {
12515    issue: NativeId,
12516    // llmlint: ignore[invalid_states_unrepresentable] The members are exactly what `update_content` would have sent at once, which every one of its callers builds as GraphQL variables; holding them back changes when they are sent, not what they may be, and only `new` builds one, fixing the issue's id. Typing `UpdateIssueInput` would retype every caller of `update_content`, a change of its own.
12517    input: Value,
12518}
12519
12520impl HeldUpdate {
12521    /// The update writing `members` — an `UpdateIssueInput` without its id — to `issue`.
12522    fn new(issue: &NativeId, members: &Value) -> Self {
12523        let mut input = members.clone();
12524        input["id"] = json!(issue.0);
12525        Self {
12526            issue: issue.clone(),
12527            input,
12528        }
12529    }
12530}
12531
12532/// What a copy has held back of the items it created; see
12533/// [`GitHubProjectsSource::hold_back`].
12534#[derive(Debug, Default)]
12535enum Deferred {
12536    /// Writes are being held back, and nothing has been held yet.
12537    #[default]
12538    Nothing,
12539    /// The items held back, every one of them on `board`.
12540    Held {
12541        /// The board every one of them is on.
12542        board: BoardId,
12543        /// Each created item, in the order it was created.
12544        created: Vec<HeldBack>,
12545    },
12546}
12547
12548impl Deferred {
12549    /// Whether `content` is one of the items held back.
12550    fn holds(&self, content: &NativeId) -> bool {
12551        match self {
12552            Self::Nothing => false,
12553            Self::Held { created, .. } => created.iter().any(|held| held.content == *content),
12554        }
12555    }
12556}
12557
12558/// One created item's parts that land once [`TaskSource::finish_writes`] is called.
12559#[derive(Debug)]
12560struct HeldBack {
12561    /// The issue.
12562    content: NativeId,
12563    /// Its board field values other than its `Status`, in the order they are written.
12564    fields: Vec<(String, Value)>,
12565    /// The `Status` field, and the option and its name that the item's status lands on.
12566    // llmlint: ignore[invalid_states_unrepresentable] The same triple `column_for` returns and `Prepared::column` carries, held back unchanged until it is sent.
12567    status: Option<(String, String, String)>,
12568    /// The fields it clears after those are written.
12569    clears: Vec<String>,
12570    /// The reason it is closed for, once its option is selected; `None` leaves it open.
12571    close: Option<ClosedState>,
12572    /// The issues it is blocked by.
12573    edges: Vec<NativeId>,
12574}
12575
12576/// The member [`answer_with_errors`] carries a response's GraphQL errors under, beside its data:
12577/// no GitHub type has a field of this name.
12578const ANSWER_ERRORS: &str = "onetaskgraphErrors";
12579
12580/// A response's `data` with its GraphQL errors beside it under [`ANSWER_ERRORS`], when it has
12581/// both — or `None`, for an answer [`GitHubProjectsSource::answer`] reads as it always has.
12582///
12583/// What [`graphql::CREATE_ISSUES`] is answered with: GitHub runs a document's mutation fields in
12584/// order and answers each one that ran, so a refusal of one alias leaves the issues the others
12585/// created named in the data beside it — the ids a copy needs to take them back.
12586fn answer_with_errors(body: &str) -> Option<Value> {
12587    let parsed: Value = serde_json::from_str(body).ok()?;
12588    let errors = parsed
12589        .get("errors")?
12590        .as_array()
12591        .filter(|errors| !errors.is_empty())?;
12592    let mut data = parsed.get("data").filter(|data| data.is_object())?.clone();
12593    data[ANSWER_ERRORS] = Value::Array(errors.clone());
12594    Some(data)
12595}
12596
12597/// The `createIssue` input that files the new issue on `board` and, when `incoming` names one,
12598/// under its project — see [`GitHubProjectsSource::create_filed_issue`].
12599fn filed_input(
12600    repository: &str,
12601    board: &str,
12602    incoming: &Incoming<'_>,
12603    body: &Option<String>,
12604) -> Value {
12605    let mut input = json!({"repositoryId":repository,"title":incoming.written_title(),
12606                           "body":body,"projectV2Ids":[board]});
12607    if let Some(parent) = incoming.parent {
12608        input["parentIssueId"] = json!(parent.0);
12609    }
12610    input
12611}
12612
12613/// The name of option `option` of field `field` among a board's `fields`.
12614fn option_name<'a>(fields: &'a Value, field: &str, option: &str) -> Option<&'a str> {
12615    fields
12616        .get("nodes")?
12617        .as_array()?
12618        .iter()
12619        .find(|node| node.get("id").and_then(Value::as_str) == Some(field))?
12620        .get("options")?
12621        .as_array()?
12622        .iter()
12623        .find(|candidate| candidate.get("id").and_then(Value::as_str) == Some(option))?
12624        .get("name")?
12625        .as_str()
12626}
12627
12628/// What a write needs to know of one far end it names: which kind of item it is, and whether
12629/// it is an issue a native relationship can name.
12630struct FarEnd {
12631    kind: BoardKind,
12632    content_kind: ContentKind,
12633}
12634
12635/// Whether the issue one write reconciles was created by that write or was already there.
12636#[derive(Clone, Copy, PartialEq, Eq)]
12637enum Issue<'a> {
12638    /// Created by this write, so it holds no relationships yet.
12639    Created,
12640    /// On the board before this write, holding whatever relationships it holds — the far
12641    /// ends of its whole `blockedBy`, when the read that reached it carried them.
12642    Existing(Option<&'a [Value]>),
12643}
12644
12645/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
12646enum Reached {
12647    /// An issue this board holds, resolved into everything this source reports about it.
12648    Held(Box<Resolved>),
12649    /// Nothing this board holds: no such node, or a node on some other board.
12650    Nothing,
12651    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
12652    /// again by [`GitHubProjectsSource::draft_by_id`].
12653    Draft,
12654}
12655
12656/// What GitHub says when a string is not a node id it can resolve.
12657///
12658/// Matched because it is the ordinary answer to a project selector naming a project by its
12659/// *name*, and reporting that as a failure would make naming one impossible. It is read
12660/// off the refusal GitHub sent, never guessed from the shape of the string: this source
12661/// does not define the syntax of a GitHub node id and would be wrong about it.
12662const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
12663
12664/// `error` with `note` added to the end of what it says, its kind and every other member
12665/// unchanged — so a caller still branches on the failure that happened, and reads beside it
12666/// what that failure left behind.
12667fn noting(error: SourceError, note: &str) -> SourceError {
12668    match error {
12669        SourceError::Config { message } => SourceError::Config {
12670            message: message + note,
12671        },
12672        SourceError::Auth { message } => SourceError::Auth {
12673            message: message + note,
12674        },
12675        SourceError::Refused { message } => SourceError::Refused {
12676            message: message + note,
12677        },
12678        SourceError::RateLimited {
12679            retry_after_seconds,
12680            message,
12681        } => SourceError::RateLimited {
12682            retry_after_seconds,
12683            message: Some(message.unwrap_or_default() + note),
12684        },
12685        SourceError::Unavailable { message } => SourceError::Unavailable {
12686            message: message + note,
12687        },
12688        SourceError::Malformed { message } => SourceError::Malformed {
12689            message: message + note,
12690        },
12691    }
12692}
12693
12694/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
12695/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
12696fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest, children: bool) -> Value {
12697    let mut variables = serde_json::Map::new();
12698    for slot in 0..COPY_DETAIL_BATCH {
12699        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12700        variables.insert(format!("id{slot}"), json!(id));
12701    }
12702    variables.insert("project".to_owned(), json!(batch.first().map(|id| &id.0)));
12703    variables.insert("children".to_owned(), json!(children));
12704    variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
12705    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12706    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12707    variables.insert("duplicates".to_owned(), json!(true));
12708    Value::Object(variables)
12709}
12710
12711/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
12712/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
12713/// for them.
12714///
12715/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
12716/// is read again at no added price.
12717fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
12718    let mut variables = serde_json::Map::new();
12719    for slot in 0..DETAIL_BATCH {
12720        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12721        variables.insert(format!("id{slot}"), json!(id));
12722    }
12723    variables.insert(
12724        "first".to_owned(),
12725        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
12726    );
12727    variables.insert("comments".to_owned(), json!(comments.is_some()));
12728    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12729    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12730    variables.insert("duplicates".to_owned(), json!(true));
12731    Value::Object(variables)
12732}
12733
12734/// Whether this refusal is GitHub saying the id names no node at all.
12735fn unresolvable_node(error: &SourceError) -> bool {
12736    matches!(error, SourceError::Refused { message }
12737        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
12738}
12739
12740/// One project name, as a search qualifier which filters on it at the server.
12741///
12742/// Quoted so the whole title is one phrase rather than a bag of words, with the two
12743/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
12744/// the way it documents. A title matched here is still compared for equality afterwards:
12745/// the qualifier narrows what the server sends, and this source decides what it names.
12746fn title_qualifier(name: &str) -> String {
12747    format!("in:title {}", quoted(name))
12748}
12749
12750/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
12751/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
12752/// it documents — so a value holding a qualifier's spelling is searched for rather than
12753/// obeyed.
12754fn quoted(value: &str) -> String {
12755    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
12756    format!("\"{escaped}\"")
12757}
12758
12759/// The search qualifier for the issues updated at or after `since`.
12760///
12761/// Written to the second, rounded down, which can only widen what the search returns.
12762fn updated_qualifier(since: DateTime<Utc>) -> String {
12763    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
12764}
12765
12766/// The search terms that narrow a board-scoped issue search to a task query's text and
12767/// metadata predicates, or `None` when it carries neither.
12768///
12769/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
12770/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
12771/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
12772/// matches each in any field the `in:` qualifier names, so a query naming a title search and
12773/// a metadata value searches both fields for both — wider than asked, never narrower, and
12774/// every candidate is confirmed in process afterwards.
12775///
12776/// **This narrows a text search, and that is this source's declared semantics.** GitHub
12777/// matches whole tokens where a substring rule would match inside a word, so an item holding
12778/// the text only inside a longer word is not returned. A text of nothing but whitespace
12779/// matches every item, so it narrows nothing and is not sent.
12780fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
12781    let text = query
12782        .text
12783        .as_ref()
12784        .filter(|text| !text.terms.trim().is_empty());
12785    if text.is_none() && query.metadata.is_empty() {
12786        return None;
12787    }
12788    let (title, body) = match text.map(|text| text.fields) {
12789        None => (false, true),
12790        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
12791        Some(TextFields::Content) => (false, true),
12792        Some(TextFields::TitleOrContent) => (true, true),
12793    };
12794    let fields = match (title, body) {
12795        (true, true) => "in:title,body",
12796        (true, false) => "in:title",
12797        _ => "in:body",
12798    };
12799    let phrases = text
12800        .map(|text| text.terms.clone())
12801        .into_iter()
12802        .chain(
12803            query
12804                .metadata
12805                .iter()
12806                .map(|wanted| as_stored(wanted.value())),
12807        )
12808        .map(|phrase| quoted(&phrase))
12809        .collect::<Vec<_>>();
12810    Some(format!("{fields} {}", phrases.join(" ")))
12811}
12812
12813/// Whether every status category `query` keeps reads only off an open issue or a draft: a
12814/// closed issue reads `done`, `cancelled` or — closed for a reason that implies no category —
12815/// `unknown`, so a status filter naming none of those three can ask every issue search it sends
12816/// for open issues alone; each candidate's category is still confirmed from its own item.
12817fn open_only(query: &TaskQuery) -> bool {
12818    !query.statuses.is_empty()
12819        && query.statuses.iter().all(|category| {
12820            !matches!(
12821                category,
12822                StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
12823            )
12824        })
12825}
12826
12827/// What a task query is narrowed by beside a board-scoped issue search's scope — its comment
12828/// activity and its text and metadata, with `is:open` when [`open_only`] — or `None` for a query
12829/// carrying none of those, which no narrowed search answers.
12830///
12831/// A narrowed search never answers a draft, which is no issue, so narrowing it to open issues
12832/// too leaves out nothing such a search could have answered.
12833fn search_qualifiers(query: &TaskQuery) -> Option<String> {
12834    let mut qualifiers: Vec<String> = [
12835        query.commented_since.map(updated_qualifier),
12836        narrowing_qualifiers(query),
12837    ]
12838    .into_iter()
12839    .flatten()
12840    .collect();
12841    if qualifiers.is_empty() {
12842        return None;
12843    }
12844    if open_only(query) {
12845        qualifiers.push(OPEN_QUALIFIER.to_owned());
12846    }
12847    Some(qualifiers.join(" "))
12848}
12849
12850/// The search terms that narrow a board-scoped issue search to a project or document query's
12851/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
12852/// carrying that text alone is sent as by [`narrowing_qualifiers`].
12853fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
12854    narrowing_qualifiers(&TaskQuery {
12855        text: text.cloned(),
12856        ..TaskQuery::default()
12857    })
12858}
12859
12860/// Refuses a project or document query's text GitHub's issue search cannot find, before
12861/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
12862/// query's.
12863fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
12864    refuse_unsearchable(&TaskQuery {
12865        text: text.cloned(),
12866        ..TaskQuery::default()
12867    })
12868}
12869
12870/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
12871/// before anything is asked of GitHub.
12872///
12873/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
12874/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
12875/// left out, the search is every issue of the board. So this source says it cannot answer
12876/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
12877/// nothing GitHub could search for, and keeps the board read it always had.
12878fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
12879    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
12880                       letter or digit with a bounded query";
12881    if let Some(text) = &query.text
12882        && !text.terms.trim().is_empty()
12883        && !has_words(&text.terms)
12884    {
12885        return Err(SourceError::Refused {
12886            message: format!(
12887                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
12888                text.terms
12889            ),
12890        });
12891    }
12892    if let Some(wanted) = query
12893        .metadata
12894        .iter()
12895        .find(|wanted| !has_words(wanted.value()))
12896    {
12897        return Err(SourceError::Refused {
12898            message: format!(
12899                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
12900                wanted.value(),
12901                std::iter::once(wanted.key())
12902                    .chain(wanted.path().iter().map(String::as_str))
12903                    .collect::<Vec<_>>()
12904                    .join("/"),
12905            ),
12906        });
12907    }
12908    Ok(())
12909}
12910
12911/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
12912fn has_words(phrase: &str) -> bool {
12913    phrase.chars().any(char::is_alphanumeric)
12914}
12915
12916/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
12917///
12918/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
12919/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
12920/// which GitHub's word match would read as different words.
12921fn as_stored(value: &str) -> String {
12922    let encoded = Value::String(value.to_owned()).to_string();
12923    encoded[1..encoded.len() - 1].to_owned()
12924}
12925
12926/// The one narrower question a task query carrying a text, metadata or origin predicate is
12927/// sent as.
12928enum Narrowing {
12929    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
12930    Origin(String),
12931    /// The board-scoped issue search narrowed by these qualifiers.
12932    Search(String),
12933}
12934
12935impl Narrowing {
12936    /// What this question is remembered under for the length of one command.
12937    fn key(&self) -> String {
12938        match self {
12939            Self::Origin(origin) => format!("origin {origin}"),
12940            Self::Search(also) => format!("search {also}"),
12941        }
12942    }
12943}
12944
12945/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
12946enum Resumed {
12947    /// It reported another page, which starts after this cursor.
12948    More(String),
12949    /// It has ended. Sending this cursor again — the page's own end when it had one, and
12950    /// otherwise the cursor it was reached from — answers an empty page, so the one document
12951    /// can go on walking the other connection.
12952    Ended(Option<String>),
12953}
12954
12955impl Resumed {
12956    /// Whether the connection has another page.
12957    const fn has_more(&self) -> bool {
12958        matches!(self, Self::More(_))
12959    }
12960
12961    /// The cursor to send this connection next.
12962    fn cursor(self) -> Option<String> {
12963        match self {
12964            Self::More(next) => Some(next),
12965            Self::Ended(last) => last,
12966        }
12967    }
12968}
12969
12970/// Where `connection`, reached from `after`, resumes — refused when it reports another page
12971/// with no cursor to it, or from a cursor that does not advance.
12972fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
12973    let info = connection
12974        .get("pageInfo")
12975        .ok_or_else(|| SourceError::Malformed {
12976            message: "GitHub connection has no pageInfo".into(),
12977        })?;
12978    let end = optional_str(info, "endCursor")?;
12979    if required_bool(info, "hasNextPage")? {
12980        let next = end.ok_or_else(|| SourceError::Malformed {
12981            message: "GitHub connection reports another page and no endCursor".into(),
12982        })?;
12983        validate_cursor_progress(after, next)?;
12984        return Ok(Resumed::More(next.to_owned()));
12985    }
12986    Ok(Resumed::Ended(
12987        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
12988    ))
12989}
12990
12991/// The board, and every item on it this source reports.
12992#[derive(Clone)]
12993struct Board {
12994    id: String,
12995    fields: Value,
12996    items: Vec<Resolved>,
12997}
12998
12999/// What a write needs of the board and nothing more: its node id and its field
13000/// definitions, in the shape a read of the board's own `fields` gives them.
13001///
13002/// Deliberately no items. A write decides which item it writes, which parent it files
13003/// under and which far ends it names by reading each of them by its own id; this is the
13004/// half of the board those reads cannot carry, and holding no item is what keeps it from
13005/// ever being asked whether an item is there.
13006#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13007struct BoardFields {
13008    id: BoardId,
13009    fields: Value,
13010}
13011
13012/// A board's node id: what a field write and `addProjectV2ItemById` address.
13013///
13014/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
13015/// refused where it is read, and one an item names blank is read as not named at all.
13016#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
13017#[serde(try_from = "String")]
13018struct BoardId(String);
13019
13020impl TryFrom<String> for BoardId {
13021    type Error = String;
13022
13023    /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
13024    fn try_from(id: String) -> Result<Self, Self::Error> {
13025        Self::parse(&id).map_err(|error| error.to_string())
13026    }
13027}
13028
13029/// Where one write left its item, for the record the rest of the command reads it out of.
13030///
13031/// A named record rather than a tuple because the update arm and the create arm each fill
13032/// all four, and two `Option`s of different meaning side by side in a tuple are two
13033/// positions a reader has to count.
13034struct Landed {
13035    /// The issue's own node id, which is the [`NativeId`] this source reports.
13036    content_id: NativeId,
13037    /// The board item's id, which is what a field write addresses — `None` for an issue its
13038    /// create filed on the board itself, whose board item GitHub adds after answering and
13039    /// [`TaskSource::finish_writes`] reads.
13040    // llmlint: ignore[invalid_states_unrepresentable] This field and the one below are `Resolved::item_id` and `Resolved::url` carried out of one call: the update arm assigns them from an existing `Resolved` and the whole record is assigned straight back into one. A newtype introduced here alone would be wrapped at both of those boundaries and unwrapped at every use, and would make this private record disagree with the type the same values have on the struct they come from and return to. Where the board item id gets a newtype is on `Resolved`, which is the contract's own shape and not this change's to move.
13041    item_id: Option<String>,
13042    /// The web address GitHub gave the issue, when it gave one.
13043    // llmlint: ignore[invalid_states_unrepresentable] The answer `Resolved::url` and the contract's `Task::url` already record: a web address this source never parses, resolves or compares — it reads GitHub's string and hands it back, and `Location::Url` is where the contract gives it a shape. Validating it here would have this plugin decide what GitHub may call an address.
13044    url: Option<String>,
13045    /// The issue's number on its repository, when GitHub reported one.
13046    number: Option<u64>,
13047}
13048
13049impl BoardId {
13050    fn parse(id: &str) -> Result<Self, SourceError> {
13051        if id.trim().is_empty() {
13052            return Err(SourceError::Malformed {
13053                message: "GitHub named a board with a blank node id".into(),
13054            });
13055        }
13056        Ok(Self(id.to_owned()))
13057    }
13058
13059    fn as_str(&self) -> &str {
13060        &self.0
13061    }
13062}
13063
13064impl Board {
13065    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
13066        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
13067        let nodes = fields
13068            .get("nodes")
13069            .and_then(Value::as_array)
13070            .ok_or_else(|| SourceError::Malformed {
13071                message: "GitHub project fields.nodes is not an array".into(),
13072            })?;
13073        Ok(nodes
13074            .iter()
13075            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
13076    }
13077}
13078
13079/// One board item, resolved into everything this source reports about it.
13080///
13081/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
13082/// [`TaskSource::prime_write`] takes back — see [`expected`].
13083#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13084struct Resolved {
13085    item_id: String,
13086    id: NativeId,
13087    content_kind: ContentKind,
13088    kind: BoardKind,
13089    title: String,
13090    body: Option<String>,
13091    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
13092    /// that changes the slot alone has to keep byte for byte outside it.
13093    raw_body: Option<String>,
13094    status: Status,
13095    /// The name of the board `Status` option this item sits in, as the board spells it.
13096    option: Option<String>,
13097    /// What its `Priority` field says, read through this instance's mapping.
13098    priority: HeldPriority,
13099    /// Whether this item's issue is closed. A draft has no such state and is never closed.
13100    closed: bool,
13101    /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
13102    /// issue, a draft, and an issue closed before GitHub recorded reasons.
13103    #[serde(default)]
13104    reason: Option<String>,
13105    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
13106    delivers: Vec<TaskRef>,
13107    /// Every task that delivers this one, read out of its slot. Empty for anything not a
13108    /// task.
13109    delivered_by: Vec<TaskRef>,
13110    labels: Vec<Label>,
13111    parent: Option<NativeId>,
13112    // llmlint: ignore[invalid_states_unrepresentable] The write side's reason, read back: this is the engine's qualified id, taken out of a board text field and handed on untouched. A newtype here would have this plugin define the syntax of an id `docs/metadata.md` says no plugin ever constructs or interprets.
13113    origin: Option<String>,
13114    /// What each board text field this instance projects metadata onto holds for this item,
13115    /// by field name — only the fields holding a value, an empty text included. What a write compares the
13116    /// item's metadata against, so a field already holding its value is not written again;
13117    /// never read back as metadata, which the slot alone reports.
13118    projected: BTreeMap<String, String>,
13119    /// The issue's own number on its repository, as GitHub reports it.
13120    ///
13121    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
13122    /// declares none, and a draft is not filed in a repository to be numbered by one — and
13123    /// an issue this run created whose creating mutation answered without one, which is a
13124    /// response GitHub's own schema says cannot happen and which a landed write is not
13125    /// worth failing over. An `Issue` read off the board always has one.
13126    number: Option<u64>,
13127    url: Option<String>,
13128    created_at: Option<DateTime<Utc>>,
13129    updated_at: Option<DateTime<Utc>>,
13130    /// When the board item itself last changed — what a board field write moves — when the
13131    /// read that reached it said.
13132    item_updated_at: Option<DateTime<Utc>>,
13133    /// How many comments the issue holds, when the read that reached it said.
13134    comment_count: Option<u64>,
13135    /// How many relationship events the issue's timeline holds — every dependency, as
13136    /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
13137    /// removed — when the read that reached it said.
13138    relationship_events: Option<u64>,
13139    /// The server's clock on the response that read it — its `Date` header — when the
13140    /// response carried one.
13141    served_at: Option<DateTime<Utc>>,
13142    /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
13143    /// its labels — when the read that reached it carried all three whole.
13144    #[serde(default)]
13145    facets: Option<change_token::FacetsDigest>,
13146    own_repository: Option<Repository>,
13147    repositories: Vec<Repository>,
13148    classification: Classification,
13149    slot: BTreeMap<String, Value>,
13150    /// The node id of the board this item sits on, when the read that reached it said.
13151    board_id: Option<String>,
13152    /// The definition of every board field this item holds a value of, in the shape a read
13153    /// of the board's own `fields` gives one.
13154    ///
13155    /// Only the fields this item has a value in: a field it holds nothing of is not here,
13156    /// which says nothing about whether the board has it.
13157    fields: Vec<Value>,
13158    /// Every field the board this item sits on defines, as its own read of the board's
13159    /// `fields` gives them — when the read that reached the item carried them, which a read
13160    /// of it by its own id does. What a write of it needs of the board, then, needs no read
13161    /// of the board.
13162    board_fields: Option<Value>,
13163    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
13164    /// selects one — when the read that reached it carried the connection to its end, which a
13165    /// read of it by its own id does for any issue blocked by no more than a page. What a
13166    /// write reconciles that relationship against, and what a read of its forward edges in
13167    /// the same command answers with.
13168    blocked_by: Option<Vec<Value>>,
13169}
13170
13171impl Resolved {
13172    /// The board this item's own read names it on, when that read named one this source can
13173    /// address.
13174    fn named_board(&self) -> Option<BoardId> {
13175        self.board_id
13176            .as_deref()
13177            .and_then(|id| BoardId::parse(id).ok())
13178    }
13179
13180    /// The board's id and every field it defines, when the read that reached this item
13181    /// carried both — which a read of it by its own id does.
13182    fn carried_board(&self) -> Option<BoardFields> {
13183        Some(BoardFields {
13184            id: self.named_board()?,
13185            fields: self.board_fields.clone()?,
13186        })
13187    }
13188
13189    /// Whether this item holds a value of the board field called `name`, and so carries
13190    /// that field's definition. `false` says nothing about whether the board has the field.
13191    fn defines(&self, name: &str) -> bool {
13192        self.fields
13193            .iter()
13194            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
13195    }
13196
13197    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
13198    /// in a field of its own, and none of the five keys that are only an encoding.
13199    ///
13200    /// The two delivery keys are left out for every kind, not only for a task: they are
13201    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
13202    /// document carrying one holds nothing a caller's own metadata could mean by it.
13203    fn metadata(&self) -> BTreeMap<String, Value> {
13204        let mut metadata = self.slot.clone();
13205        metadata.remove(Repository::METADATA_KEY);
13206        metadata.remove(DependencyEdge::RECORDED_KEY);
13207        metadata.remove(ItemKind::METADATA_KEY);
13208        metadata.remove(TaskRef::DELIVERS_KEY);
13209        metadata.remove(TaskRef::DELIVERED_BY_KEY);
13210        metadata.remove(Classification::METADATA_KEY);
13211        // The board field is the origin, and the body's copy of it is only a mirror for the
13212        // issue search to find: an item whose field holds none has none, whatever its body
13213        // says, so no reader ever sees two answers.
13214        metadata.remove(ORIGIN_KEY);
13215        if let Some(origin) = &self.origin {
13216            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
13217        }
13218        metadata
13219    }
13220
13221    /// Where this item is, as a link a reader can open.
13222    ///
13223    /// A board is a hosted place and every issue on it has a web address, so that address
13224    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
13225    /// of place it is, so a reader knows to open it rather than to read a file out. It
13226    /// does not replace or derive from `url`: the field goes on reporting exactly what it
13227    /// reported before, and this says what that address *is*.
13228    ///
13229    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
13230    /// rather than a third variant, which is the contract's "the source did not say". An
13231    /// issue this run created is not one of those: its address comes back from the
13232    /// creating mutation, so it is somewhere a reader can open from the moment it exists
13233    /// rather than from whenever the board read catches up.
13234    fn location(&self) -> Option<Location> {
13235        self.url.clone().map(Location::Url)
13236    }
13237
13238    /// The short handle this board's backend shows people for a task: the issue's number
13239    /// alone, as a decimal string.
13240    ///
13241    /// The number alone rather than `owner/repo#1043`, because that is the contract's
13242    /// value for this backend. A draft has no number and so no handle, which is the
13243    /// contract's *absent* rather than a handle of some other shape — and the native
13244    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
13245    /// derives from.
13246    fn key(&self) -> Option<String> {
13247        self.number.map(|number| number.to_string())
13248    }
13249
13250    /// This item's change token — see [`change_token`] — or `None` when the read that
13251    /// reached it did not carry all six of its parts, which a draft never does, did not say
13252    /// when GitHub served it, or found it changed too recently to have settled.
13253    fn version(&self) -> Option<String> {
13254        let parts = self.token_parts()?;
13255        change_token::settled(parts.issue, parts.item, self.served_at?)
13256            .then(|| change_token::spell(&parts))
13257    }
13258
13259    /// The change token this item, read just after a write of it, holds now — whether or not it
13260    /// has settled: what a written task's `version` answers (see
13261    /// [`GitHubProjectsSource::written_tokens`]), spelled from the parts a later read spells its
13262    /// token from.
13263    fn written_version(&self) -> Option<String> {
13264        self.token_parts().map(|parts| change_token::spell(&parts))
13265    }
13266
13267    /// Every part of this item's change token, or `None` when the read that reached it did not
13268    /// carry them all — a draft never does.
13269    fn token_parts(&self) -> Option<change_token::Parts> {
13270        match self.content_kind {
13271            ContentKind::Issue => Some(change_token::Parts {
13272                issue: self.updated_at?,
13273                item: self.item_updated_at?,
13274                comments: self.comment_count?,
13275                relationships: self.relationship_events?,
13276                facets: self.facets.clone()?,
13277                repository: self.own_repository.clone()?,
13278            }),
13279            ContentKind::DraftIssue => None,
13280        }
13281    }
13282
13283    /// Whether its `Priority` field holds a value at all, mapped or not.
13284    fn holds_priority(&self) -> bool {
13285        self.priority != HeldPriority::Read(Priority::None)
13286    }
13287
13288    /// The task this item is.
13289    ///
13290    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
13291    /// reading that as a level would be a guess, and reading it as `none` would let the next
13292    /// copy clear a priority a person set.
13293    fn task(&self) -> Result<Task, SourceError> {
13294        let priority = match &self.priority {
13295            HeldPriority::Read(priority) => *priority,
13296            HeldPriority::Unmapped(option) => {
13297                return Err(SourceError::Malformed {
13298                    message: format!(
13299                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
13300                         this source's priority_mapping does not name, so its priority cannot be \
13301                         read; next: name {option:?} under priority_mapping, or move the item to \
13302                         a mapped option",
13303                        self.id,
13304                        self.number
13305                            .map(|number| format!(" (#{number})"))
13306                            .unwrap_or_default()
13307                    ),
13308                });
13309            }
13310        };
13311        Ok(Task {
13312            version: self.version(),
13313            id: self.id.clone(),
13314            key: self.key(),
13315            title: self.title.clone(),
13316            content: self.body.clone(),
13317            status: self.status.clone(),
13318            priority,
13319            labels: self.labels.clone(),
13320            project: self.parent.clone(),
13321            url: self.url.clone(),
13322            location: self.location(),
13323            created_at: self.created_at,
13324            updated_at: self.updated_at,
13325            metadata: self.metadata(),
13326            repositories: self.repositories.clone(),
13327            delivers: self.delivers.clone(),
13328            delivered_by: self.delivered_by.clone(),
13329            classification: self.classification,
13330        })
13331    }
13332
13333    fn project(&self) -> Project {
13334        Project {
13335            version: self.version(),
13336            id: self.id.clone(),
13337            title: self.title.clone(),
13338            content: self.body.clone(),
13339            status: self.status.clone(),
13340            labels: self.labels.clone(),
13341            url: self.url.clone(),
13342            location: self.location(),
13343            created_at: self.created_at,
13344            updated_at: self.updated_at,
13345            metadata: self.metadata(),
13346            repositories: self.repositories.clone(),
13347            classification: self.classification,
13348        }
13349    }
13350
13351    /// The same issue as a document: the project it is filed under, and no status and no
13352    /// dependencies, because a document is not work.
13353    fn document(&self) -> Document {
13354        Document {
13355            version: self.version(),
13356            id: self.id.clone(),
13357            title: self.title.clone(),
13358            content: self.body.clone(),
13359            project: self.parent.clone(),
13360            labels: self.labels.clone(),
13361            url: self.url.clone(),
13362            location: self.location(),
13363            created_at: self.created_at,
13364            updated_at: self.updated_at,
13365            metadata: self.metadata(),
13366            repositories: self.repositories.clone(),
13367            classification: self.classification,
13368        }
13369    }
13370}
13371
13372/// Where one targeted update moves an item's status, and which of its two halves move.
13373struct StatusMove {
13374    /// The board the item's `Status` field is on.
13375    board: BoardId,
13376    /// The `Status` field's id.
13377    field: String,
13378    /// The option's id.
13379    option: String,
13380    /// The option's name, as the board spells it.
13381    name: String,
13382    /// What the status asks of the issue's state.
13383    target: StatusTarget,
13384    /// The status the item reads as once it is there.
13385    landed: Status,
13386    /// Which of the status's two halves differ from what the item holds.
13387    moves: Moves,
13388}
13389
13390/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
13391/// closed state of its issue, or both. A status neither half of which differs is no move at all,
13392/// and is not a value of this type.
13393#[derive(Clone, Copy, PartialEq, Eq)]
13394enum Moves {
13395    /// The option alone.
13396    Option,
13397    /// The issue's state alone: open, closed, or closed with another reason.
13398    State,
13399    /// Both.
13400    Both,
13401}
13402
13403impl Moves {
13404    /// What differs, or `None` when nothing does.
13405    const fn of(option: bool, state: bool) -> Option<Self> {
13406        match (option, state) {
13407            (true, true) => Some(Self::Both),
13408            (true, false) => Some(Self::Option),
13409            (false, true) => Some(Self::State),
13410            (false, false) => None,
13411        }
13412    }
13413
13414    /// Whether the option moves.
13415    const fn option(self) -> bool {
13416        matches!(self, Self::Option | Self::Both)
13417    }
13418
13419    /// Whether the issue's state moves.
13420    const fn state(self) -> bool {
13421        matches!(self, Self::State | Self::Both)
13422    }
13423}
13424
13425/// What one write is, and the status that comes with being it.
13426///
13427/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
13428/// status and a task or a project always has one, so "a document carrying a status" and
13429/// "a task carrying none" are states a write cannot be in rather than states every use
13430/// site below has to defend against.
13431enum Written<'a> {
13432    /// A document, which is not work and so has no status at all.
13433    Document,
13434    /// A task or a project, and the status it is being written with.
13435    Work(ItemKind, &'a Status),
13436}
13437
13438impl Written<'_> {
13439    /// Which of the board's three kinds this write is.
13440    const fn kind(&self) -> BoardKind {
13441        match self {
13442            Self::Document => BoardKind::Document,
13443            Self::Work(kind, _) => BoardKind::Work(*kind),
13444        }
13445    }
13446
13447    /// The status this write carries. A document carries none, so a write of one says
13448    /// nothing about the issue's open or closed state and selects no board `Status`
13449    /// option.
13450    const fn status(&self) -> Option<&Status> {
13451        match self {
13452            Self::Document => None,
13453            Self::Work(_, status) => Some(status),
13454        }
13455    }
13456
13457    /// The status this write carries with the kind whose half of `status_mapping` it is
13458    /// written through.
13459    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
13460        match self {
13461            Self::Document => None,
13462            Self::Work(kind, status) => Some((*kind, status)),
13463        }
13464    }
13465}
13466
13467/// The item being written, in the one shape all three write methods reach.
13468struct Incoming<'a> {
13469    written: Written<'a>,
13470    /// The title a person wrote. A document's goes onto the issue with
13471    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
13472    title: &'a str,
13473    content: Option<&'a str>,
13474    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
13475    labels: &'a [Label],
13476    metadata: &'a BTreeMap<String, Value>,
13477    repositories: &'a [Repository],
13478    /// Recorded in the slot while private, and nowhere while public.
13479    classification: Classification,
13480    parent: Option<&'a NativeId>,
13481    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
13482    /// what keeps either key out of their slot.
13483    delivers: &'a [TaskRef],
13484    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
13485    delivered_by: &'a [TaskRef],
13486    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
13487    /// project, a document, and every write to an instance with no `priority_mapping` —
13488    /// which is what keeps such a write's requests exactly what they were before.
13489    priority: Option<Priority>,
13490}
13491
13492/// What one write does to one projected metadata text field, by the field's id.
13493#[derive(Debug, Clone)]
13494enum ProjectionWrite {
13495    /// Write this text.
13496    Set {
13497        field: String, // llmlint: ignore[invalid_states_unrepresentable] A private opaque GraphQL field id passed straight back to GitHub, as `PriorityWrite`'s are; GitHub publishes no grammar for it.
13498        text: String,
13499    },
13500    /// Clear the field's value.
13501    Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
13502}
13503
13504impl ProjectionWrite {
13505    /// Add this write to a field write's list of values and list of clears.
13506    fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
13507        match self {
13508            Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
13509            Self::Clear { field } => clears.push(field),
13510        }
13511    }
13512}
13513
13514/// What one write does to an item's `Priority` field.
13515enum PriorityWrite {
13516    /// Select this option of this field.
13517    Select {
13518        /// The `Priority` field's id.
13519        field: String,
13520        /// The mapped option's id.
13521        option: String,
13522    },
13523    /// Clear the field's value, which is what `none` is.
13524    Clear {
13525        /// The `Priority` field's id.
13526        field: String,
13527    },
13528}
13529
13530impl Incoming<'_> {
13531    /// The title this write puts on the issue.
13532    fn written_title(&self) -> String {
13533        match self.written {
13534            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
13535            Written::Work(..) => self.title.to_owned(),
13536        }
13537    }
13538}
13539
13540#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13541enum ContentKind {
13542    DraftIssue,
13543    Issue,
13544}
13545
13546/// What one board issue is: a document, or the work an [`ItemKind`] names.
13547///
13548/// A type of this source's own rather than an `ItemKind` with a third variant, because
13549/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
13550/// document — the contract keeps a document out of that enum deliberately. Holding the
13551/// board's three answers in one value is what makes every place that asks "which is this?"
13552/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
13553/// two thirds of the board.
13554#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13555enum BoardKind {
13556    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
13557    Document,
13558    /// Every other issue, and every draft.
13559    Work(ItemKind),
13560}
13561
13562impl BoardKind {
13563    /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
13564    /// sub-issues and carrying the kind marker `marked` in its slot.
13565    ///
13566    /// The design prefix is read *first*, before either of the two rules that separate a
13567    /// project from a task. A document is not work whatever sub-issues it has and whatever
13568    /// marker it carries, and reading the prefix later would make a design issue with none of
13569    /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
13570    /// overrides it: an issue filed under a project is that project's task even when it has
13571    /// sub-issues of its own.
13572    fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
13573        if title.starts_with(DESIGN_TITLE_PREFIX) {
13574            Self::Document
13575        } else if filed {
13576            Self::Work(ItemKind::Task)
13577        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
13578            Self::Work(ItemKind::Project)
13579        } else {
13580            Self::Work(ItemKind::Task)
13581        }
13582    }
13583
13584    /// What the change token's facets call this kind.
13585    const fn facet(self) -> change_token::FacetKind {
13586        match self {
13587            Self::Document => change_token::FacetKind::Document,
13588            Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
13589            Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
13590        }
13591    }
13592
13593    /// Whose half of `status_mapping` an item of this kind reads its status through. A
13594    /// document has no status of its own, so the task half stands in for whatever the issue
13595    /// holds; nothing reports it.
13596    const fn status_kind(self) -> ItemKind {
13597        match self {
13598            Self::Document => ItemKind::Task,
13599            Self::Work(kind) => kind,
13600        }
13601    }
13602
13603    /// How a refusal names this kind to the person reading it.
13604    const fn describes(self) -> &'static str {
13605        match self {
13606            Self::Document => "document",
13607            Self::Work(kind) => kind.marker(),
13608        }
13609    }
13610}
13611
13612/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
13613///
13614/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
13615/// the shared cross-source journeys assert one answer to one question, so two sources
13616/// that disagree about what "carries the label bug" means fail them.
13617fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
13618    let holds = |name: &String| {
13619        labels
13620            .iter()
13621            .any(|label| label.name.eq_ignore_ascii_case(name))
13622    };
13623    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
13624        && filter.all_of.iter().all(holds)
13625        && !filter.none_of.iter().any(holds)
13626}
13627
13628/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
13629/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
13630fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
13631    statuses.is_empty() || statuses.contains(&category)
13632}
13633
13634/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
13635///
13636/// `content` is the item's own prose — the body with this source's trailing metadata
13637/// comment already taken off — so a search never matches an encoding the author of the
13638/// issue never wrote.
13639fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
13640    let terms = query.terms.to_lowercase();
13641    let in_title = title.to_lowercase().contains(&terms);
13642    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
13643    match query.fields {
13644        TextFields::Title => in_title,
13645        TextFields::Content => in_content,
13646        TextFields::TitleOrContent => in_title || in_content,
13647    }
13648}
13649
13650/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
13651///
13652/// The project predicate is passed separately because a read narrowed to one project has
13653/// already answered it by asking *that project* for its own items — and re-applying it
13654/// there would compare the caller's selector, which may be a project's **name**, against
13655/// the id of the project that name resolved to, and keep nothing. Every other read passes
13656/// `query.project` and applies it here, which is what keeps `projects` a predicate this
13657/// source really does apply.
13658fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
13659    labels_match(&task.labels, &query.labels)
13660        && status_matches(task.status.category, &query.statuses)
13661        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
13662        && match project {
13663            ProjectFilter::Any => true,
13664            ProjectFilter::Orphans => task.project.is_none(),
13665            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
13666        }
13667        && query
13668            .text
13669            .as_ref()
13670            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
13671        // Against the parsed metadata slot, and against the origin field, which is where
13672        // `Resolved::metadata` reads each of them from.
13673        && query.metadata_matches(&task.metadata)
13674        && query.origin_matches(&task.metadata)
13675}
13676
13677fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
13678    labels_match(&project.labels, &query.labels)
13679        && status_matches(project.status.category, &query.statuses)
13680        && query
13681            .text
13682            .as_ref()
13683            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
13684}
13685
13686/// The same three predicates a task query carries, minus the status filter.
13687///
13688/// A document is not work, so it has no status for one to compare against and the query
13689/// type carries none. The project predicate is the same one — a design issue filed under a
13690/// project issue is in that project, and one filed under nothing is in none — so it is
13691/// spelled the same way here rather than answered differently.
13692fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
13693    labels_match(&document.labels, &query.labels)
13694        && match project {
13695            ProjectFilter::Any => true,
13696            ProjectFilter::Orphans => document.project.is_none(),
13697            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
13698        }
13699        && query
13700            .text
13701            .as_ref()
13702            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
13703}
13704
13705/// Why a GitHub item's change token does not vouch for its forward edges.
13706///
13707/// A forward edge names its far end and that far end's kind, and the near item's token moves
13708/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
13709/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
13710/// nothing the near token reads, so an edge kept at the near version could name a far end that
13711/// is no longer what it said.
13712const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
13713     whether it became a document, or its deletion, so its forward edges are always read from \
13714     github";
13715
13716/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
13717/// categories an open issue reads as narrows a board-scoped search with.
13718const OPEN_QUALIFIER: &str = "is:open";
13719
13720/// The board filter `task status reconcile` reads the board through: closed items alone.
13721const CLOSED_FILTER: &str = "is:closed";
13722
13723/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
13724/// one reference, and a slot the batch has none for names its last one again.
13725fn reference_batch(batch: &[IssueRef]) -> Value {
13726    let mut variables = serde_json::Map::new();
13727    for slot in 0..REFERENCE_BATCH {
13728        let issue = batch.get(slot).or(batch.last());
13729        variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
13730        variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
13731        variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
13732    }
13733    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13734    variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
13735    variables.insert("duplicates".to_owned(), json!(true));
13736    Value::Object(variables)
13737}
13738
13739/// One issue an issue read by reference answered, in the shape every other read of an issue
13740/// hands its resolver: each board membership's `Status` value, read by name, as the one field
13741/// value that membership carries.
13742///
13743/// A membership's `status` is `null` when its card sits at no option — or on a board with no
13744/// field of that name — and otherwise an option value naming its option; anything else is not
13745/// an answer to this read, and is refused rather than read as a card at no option.
13746fn held_issue(issue: &Value) -> Result<Value, SourceError> {
13747    let malformed = |what: &str| SourceError::Malformed {
13748        message: format!("GitHub answered an issue read by reference with {what}"),
13749    };
13750    let mut issue = issue.clone();
13751    let nodes = issue
13752        .pointer_mut("/projectItems/nodes")
13753        .and_then(Value::as_array_mut)
13754        .ok_or_else(|| malformed("no list of board memberships"))?;
13755    for node in nodes {
13756        let node = node
13757            .as_object_mut()
13758            .ok_or_else(|| malformed("a board membership that is not an object"))?;
13759        let status = match node.get("status") {
13760            None => return Err(malformed("a board membership with no status member")),
13761            Some(Value::Null) => None,
13762            Some(status) if status.get("name").is_some_and(Value::is_string) => {
13763                Some(status.clone())
13764            }
13765            // A single-select value of another field type answers `{}`; only an option value
13766            // carries a name.
13767            Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
13768            Some(_) => return Err(malformed("a membership status that names no option")),
13769        };
13770        node.insert(
13771            "fieldValues".to_owned(),
13772            json!({"nodes": status.into_iter().collect::<Vec<_>>(),
13773                   "pageInfo": {"hasNextPage": false}}),
13774        );
13775    }
13776    Ok(issue)
13777}
13778
13779/// The issue one slot of a read by reference answered, `None` when GitHub answered that
13780/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
13781/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
13782/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
13783/// than a reference nothing holds.
13784fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
13785    let alias = format!("r{slot}");
13786    let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
13787        message: format!("GitHub answered a read by reference with no {alias}"),
13788    })?;
13789    if repository.is_null() {
13790        return Ok(None);
13791    }
13792    let issue = repository
13793        .get("issue")
13794        .ok_or_else(|| SourceError::Malformed {
13795            message: format!("GitHub answered {alias} of a read by reference with no issue"),
13796        })?;
13797    if issue.is_null() {
13798        return Ok(None);
13799    }
13800    // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
13801    if optional_str(issue, "__typename")? != Some("Issue") {
13802        return Err(SourceError::Malformed {
13803            message: format!(
13804                "GitHub answered {alias} of a read by reference with an issue whose __typename \
13805                 is not Issue"
13806            ),
13807        });
13808    }
13809    Ok(Some(issue))
13810}
13811
13812/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
13813fn spoken_reason(reason: &str) -> String {
13814    reason.to_ascii_lowercase().replace('_', " ")
13815}
13816
13817impl GitHubProjectsSource {
13818    /// Every task and project of this board whose card contradicts its issue's closure, each
13819    /// judged against its own kind's half of `status_mapping`, read through the board's own
13820    /// `is:closed` filter, page by page — never the board's unfiltered `items`.
13821    ///
13822    /// Each item the filter answers is confirmed from its own node: an open one, one whose
13823    /// closure reads `unknown`, and one whose card is already in line are left out whatever
13824    /// the filter let through. The board's fields ride along and are held for the option
13825    /// writes that follow, so those need no read of the board.
13826    async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
13827        let mut after: Option<String> = None;
13828        let mut found = Vec::new();
13829        loop {
13830            let data = self
13831                .graphql(
13832                    graphql::CLOSED_ITEMS,
13833                    json!({"owner":self.owner,"number":self.project_number,
13834                           "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
13835                           "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
13836                )
13837                .await?;
13838            let board = data
13839                .pointer("/closedItems/projectV2")
13840                .filter(|value| !value.is_null())
13841                .ok_or_else(|| SourceError::Refused {
13842                    message: format!(
13843                        "GitHub project {}/{} was not found or is not visible to the token",
13844                        self.owner, self.project_number
13845                    ),
13846                })?;
13847            *self.fields_cache()? = Some(BoardFields {
13848                id: BoardId::parse(required_str(board, "id")?)?,
13849                fields: board.get("fields").cloned().unwrap_or(Value::Null),
13850            });
13851            let items = board.get("items").ok_or_else(|| SourceError::Malformed {
13852                message: "GitHub project closed items are missing".into(),
13853            })?;
13854            for item in items
13855                .get("nodes")
13856                .and_then(Value::as_array)
13857                .ok_or_else(|| SourceError::Malformed {
13858                    message: "GitHub project items.nodes is not an array".into(),
13859                })?
13860            {
13861                let Some(resolved) = self.resolve(item)? else {
13862                    continue;
13863                };
13864                let BoardKind::Work(kind) = resolved.kind else {
13865                    continue;
13866                };
13867                let Some((closed_as, name)) = self.statuses.contradicted(
13868                    kind,
13869                    resolved.option.as_deref(),
13870                    resolved.closed,
13871                    resolved.reason.as_deref(),
13872                ) else {
13873                    continue;
13874                };
13875                found.push(StatusContradiction {
13876                    closed_as,
13877                    to: name.to_owned(),
13878                    option: resolved.option.clone(),
13879                    item: match kind {
13880                        ItemKind::Task => ContradictedItem::Task(resolved.task()?),
13881                        ItemKind::Project => ContradictedItem::Project(resolved.project()),
13882                    },
13883                });
13884            }
13885            let info = items
13886                .get("pageInfo")
13887                .ok_or_else(|| SourceError::Malformed {
13888                    message: "GitHub project items have no pageInfo".into(),
13889                })?;
13890            if !required_bool(info, "hasNextPage")? {
13891                return Ok(found);
13892            }
13893            let next = required_str(info, "endCursor")?;
13894            validate_cursor_progress(after.as_deref(), next)?;
13895            after = Some(next.to_owned());
13896        }
13897    }
13898
13899    /// What this board holds for each issue in `issues`, once each in the order given, read
13900    /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
13901    ///
13902    /// An issue GitHub cannot find for this source's credential, and one with no item on this
13903    /// board, are both absent. A closure that already says the work will not be done here —
13904    /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
13905    /// completed whose card sits at its kind's `done` option is unchanged, and every other item
13906    /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
13907    /// tasks as sub-issues — is held under exactly these rules, judged against the project
13908    /// kind's half of `status_mapping`.
13909    async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
13910        let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
13911        for issue in issues {
13912            if !distinct.contains(issue) {
13913                distinct.push(issue.clone());
13914            }
13915        }
13916        let done = |kind: ItemKind| {
13917            self.statuses
13918                .target(kind, StatusCategory::Done)
13919                .option()
13920                .unwrap_or("Done")
13921                .to_owned()
13922        };
13923        // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
13924        // its range names no issue any repository can hold: it is answered absent and never sent,
13925        // where binding it would have GitHub refuse the whole batch.
13926        let readable: Vec<IssueRef> = distinct
13927            .iter()
13928            .filter(|issue| i32::try_from(issue.number).is_ok())
13929            .cloned()
13930            .collect();
13931        let mut holdings = Vec::with_capacity(readable.len());
13932        for batch in readable.chunks(REFERENCE_BATCH) {
13933            let data = self
13934                .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
13935                .await?;
13936            for (slot, issue) in batch.iter().enumerate() {
13937                let node = referenced_issue(&data, slot)?;
13938                let resolved = match node {
13939                    Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
13940                    None => None,
13941                };
13942                // A task and a project are both held; a document is not work, so nothing is.
13943                let Some((item, kind)) = resolved.and_then(|item| match item.kind {
13944                    BoardKind::Work(kind) => Some((item, kind)),
13945                    BoardKind::Document => None,
13946                }) else {
13947                    holdings.push(IssueHolding {
13948                        issue: issue.clone(),
13949                        completion: Completion::Absent,
13950                    });
13951                    continue;
13952                };
13953                let task = match kind {
13954                    ItemKind::Task => {
13955                        let held = item.task()?;
13956                        HeldTask {
13957                            id: held.id,
13958                            kind,
13959                            option: item.option.clone(),
13960                            classification: held.classification,
13961                            repositories: held.repositories,
13962                            delivers: held.delivers,
13963                        }
13964                    }
13965                    // A project delivers no task, so what its write keeps in step is nothing.
13966                    ItemKind::Project => {
13967                        let held = item.project();
13968                        HeldTask {
13969                            id: held.id,
13970                            kind,
13971                            option: item.option.clone(),
13972                            classification: held.classification,
13973                            repositories: held.repositories,
13974                            delivers: Vec::new(),
13975                        }
13976                    }
13977                };
13978                let done = done(kind);
13979                let completion = match (item.closed, item.reason.as_deref()) {
13980                    (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
13981                        task,
13982                        reason: format!("closed as {}", spoken_reason(reason)),
13983                    },
13984                    (true, None | Some("COMPLETED"))
13985                        if item
13986                            .option
13987                            .as_deref()
13988                            .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
13989                    {
13990                        Completion::Unchanged { task }
13991                    }
13992                    _ => {
13993                        let kept = item.clone();
13994                        self.held_cache
13995                            .lock()
13996                            .map_err(|_| SourceError::Unavailable {
13997                                message: "items read by issue reference were left \
13998                                          inconsistent; run the command again"
13999                                    .into(),
14000                            })?
14001                            .insert(kept.id.clone(), kept);
14002                        Completion::Due {
14003                            task,
14004                            to: done.clone(),
14005                        }
14006                    }
14007                };
14008                holdings.push(IssueHolding {
14009                    issue: issue.clone(),
14010                    completion,
14011                });
14012            }
14013        }
14014        let mut read = holdings.into_iter();
14015        let mut answered = Vec::with_capacity(distinct.len());
14016        for issue in distinct {
14017            if i32::try_from(issue.number).is_ok() {
14018                answered.push(read.next().ok_or_else(|| SourceError::Malformed {
14019                    message: format!("no holding was read for {issue}"),
14020                })?);
14021            } else {
14022                answered.push(IssueHolding {
14023                    issue,
14024                    completion: Completion::Absent,
14025                });
14026            }
14027        }
14028        Ok(answered)
14029    }
14030}
14031
14032#[async_trait::async_trait]
14033impl TaskSource for GitHubProjectsSource {
14034    fn kind(&self) -> &'static str {
14035        KIND
14036    }
14037    /// GitHub's issue search, which narrows a read of the comments made since an instant, is
14038    /// an index behind the writes it indexes — the credentialed lane has watched it miss a newly
14039    /// commented issue for thirty seconds — so a watermark is set back by
14040    /// [`COMMENT_SEARCH_LAG`], four times that.
14041    fn comment_consistency_window(&self) -> std::time::Duration {
14042        COMMENT_SEARCH_LAG
14043    }
14044    /// The `Date` of the last answer GitHub gave this source.
14045    fn served_at(&self) -> Option<DateTime<Utc>> {
14046        *self
14047            .last_served
14048            .lock()
14049            .unwrap_or_else(std::sync::PoisonError::into_inner)
14050    }
14051    fn attach_body_markup(&self, markup: onetaskgraph_plugin_api::SharedBodyMarkup) {
14052        let _ = self.body_markup.set(markup);
14053    }
14054    fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
14055        change_token::order(a, b)
14056    }
14057    /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
14058    /// member, the change each part of the token moves for. Not its forward edges — see
14059    /// [`FAR_ENDS_UNCOVERED`].
14060    fn version_coverage(&self) -> VersionCoverage {
14061        VersionCoverage {
14062            forward_edges: Coverage::Uncovered {
14063                reason: FAR_ENDS_UNCOVERED.to_owned(),
14064            },
14065            ..VersionCoverage::whole()
14066        }
14067    }
14068    /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
14069    /// and for a far end deleted, so the token sees every change to a task's edges but one — a
14070    /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
14071    /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
14072    /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
14073    // llmlint: ignore[names_match_behavior] The manager's ruling (c-d7838f2c) keeps 0.6.0's GitHub skip and has this plugin state that bounded claim through the coverage it reports, with the bound documented here, in §4.13d and by the copy_skip journey; a separate bounded representation would be a contract change beyond a defaulted addition, which the ruling reserves for the contract's owner, and a follow-up is drafted for a far-end-aware skip.
14074    fn copy_version_coverage(&self) -> VersionCoverage {
14075        VersionCoverage::whole()
14076    }
14077    /// A project's fields, and not its forward edges, for the same reason a task's are not.
14078    fn project_version_coverage(&self) -> ProjectVersionCoverage {
14079        ProjectVersionCoverage {
14080            fields: Coverage::Covered,
14081            forward_edges: Coverage::Uncovered {
14082                reason: FAR_ENDS_UNCOVERED.to_owned(),
14083            },
14084        }
14085    }
14086    /// A document's fields and the manifest of its image assets, which its metadata slot
14087    /// records: both are the issue's body, whose every edit moves the token's issue time.
14088    fn document_version_coverage(&self) -> DocumentVersionCoverage {
14089        DocumentVersionCoverage::whole()
14090    }
14091    fn write_context(&self, id: &NativeId) -> Option<Value> {
14092        self.context_of(id)
14093    }
14094    fn board_context(&self) -> Option<Value> {
14095        self.board_context_held()
14096    }
14097    fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
14098        self.prime(id, context, board)
14099    }
14100    fn unprime(&self, id: &NativeId) {
14101        self.unprime_held(id);
14102    }
14103    fn placement_context(&self) -> Option<Value> {
14104        self.placement_held()
14105    }
14106    fn prime_placement(&self, context: &Value) -> bool {
14107        self.prime_placement_held(context)
14108    }
14109    fn written_context(&self, id: &NativeId) -> Option<Value> {
14110        self.written_held(id)
14111    }
14112    fn prime_written(&self, id: &NativeId, context: &Value, task: &Task) -> bool {
14113        self.prime_written_held(id, context, task)
14114    }
14115    fn begin_journal(&self) {
14116        self.expected.begin();
14117    }
14118    fn end_journal(&self) -> Option<WriteJournal> {
14119        Some(self.expected.end())
14120    }
14121    fn capabilities(&self) -> Capabilities {
14122        Capabilities {
14123            projects: Support::Native,
14124            documents: Support::Native,
14125            comments: Support::Native,
14126            assets: Support::Native,
14127            priority: if self.priorities.is_some() {
14128                Support::Native
14129            } else {
14130                Support::Unsupported
14131            },
14132            filter_by_priority: Support::Native,
14133            filter_by_comment_activity: Support::Native,
14134            filter_by_metadata: Support::Native,
14135            filter_by_origin: Support::Native,
14136            reconcile_status: Support::Native,
14137            complete_by_issue: Support::Native,
14138            orphan_tasks: Support::Native,
14139            filter_by_label: Support::Native,
14140            filter_by_status: Support::Native,
14141            search_title: Support::Native,
14142            search_content: Support::Native,
14143            task_dependencies: DependencySupport::BothDirections,
14144            project_dependencies: DependencySupport::BothDirections,
14145            max_page_size: MAX_PAGE_SIZE,
14146        }
14147    }
14148    async fn visibility(
14149        &self,
14150        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14151    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14152        self.write_visibility(target).await
14153    }
14154    async fn visibility_with_project(
14155        &self,
14156        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14157        project: &onetaskgraph_plugin_api::WriteTarget<'_>,
14158    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14159        self.write_visibility_with_project(target, project).await
14160    }
14161    fn declare_visibility(&self, declared: onetaskgraph_plugin_api::Visibility) {
14162        self.declared_private.store(
14163            declared == onetaskgraph_plugin_api::Visibility::Private,
14164            std::sync::atomic::Ordering::SeqCst,
14165        );
14166    }
14167    fn scope_visibility<'a>(
14168        &'a self,
14169        operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
14170    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
14171        self.scope_visibility_reads(operation)
14172    }
14173
14174    /// Where a create of `target` would file its issue, by [`Self::creation_rule`] — the rule
14175    /// the create applies — reading the parent project's issue only when `target` names one.
14176    async fn creation_repository(
14177        &self,
14178        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14179    ) -> Result<Option<Repository>, SourceError> {
14180        let onetaskgraph_plugin_api::WriteTarget::New {
14181            repositories,
14182            project,
14183        } = target
14184        else {
14185            return Ok(None);
14186        };
14187        let target = self
14188            .creation_rule(repositories, *project, "a new item")
14189            .await?;
14190        Repository::try_from(target.origin())
14191            .map(Some)
14192            .map_err(|message| SourceError::Malformed { message })
14193    }
14194    /// Every issue of the repository a create of `target` files in whose body carries `key` in
14195    /// its metadata slot, read off that repository's issues newest first back to `since` —
14196    /// never through search, whose index lags a fresh issue by seconds.
14197    async fn created_with_key(
14198        &self,
14199        record: onetaskgraph_plugin_api::MetadataRecord,
14200        key: &str,
14201        since: Option<DateTime<Utc>>,
14202        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14203    ) -> Result<Vec<NativeId>, SourceError> {
14204        let _ = record;
14205        let onetaskgraph_plugin_api::WriteTarget::New {
14206            repositories,
14207            project,
14208        } = target
14209        else {
14210            return Ok(Vec::new());
14211        };
14212        let repository = self
14213            .creation_rule(repositories, *project, "a new item")
14214            .await?;
14215        let wanted = Value::String(key.to_owned());
14216        let mut found = Vec::new();
14217        let mut after: Option<String> = None;
14218        let mut followed = std::collections::HashSet::new();
14219        loop {
14220            let data = self
14221                .graphql(
14222                    graphql::ISSUES_BY_CREATION,
14223                    json!({"owner": repository.owner, "name": repository.name,
14224                           "first": MAX_PAGE_SIZE, "after": after}),
14225                )
14226                .await?;
14227            let issues =
14228                data.pointer("/repository/issues")
14229                    .ok_or_else(|| SourceError::Malformed {
14230                        message: format!(
14231                            "GitHub answered no issues for repository {}",
14232                            repository.slug()
14233                        ),
14234                    })?;
14235            let malformed = |what: &str| SourceError::Malformed {
14236                message: format!(
14237                    "GitHub answered a page of repository {}'s issues {what}",
14238                    repository.slug()
14239                ),
14240            };
14241            let nodes = issues
14242                .get("nodes")
14243                .and_then(Value::as_array)
14244                .ok_or_else(|| malformed("with no list of issues"))?;
14245            let mut older = false;
14246            for node in nodes {
14247                let id = node
14248                    .get("id")
14249                    .and_then(Value::as_str)
14250                    .filter(|id| !id.is_empty())
14251                    .ok_or_else(|| malformed("holding an issue with no id"))?;
14252                let created = optional_time(node, "createdAt")?
14253                    .ok_or_else(|| malformed("holding an issue with no creation time"))?;
14254                if since.is_some_and(|since| created < since) {
14255                    older = true;
14256                    break;
14257                }
14258                let body = match node.get("body") {
14259                    Some(Value::String(body)) => Some(body.clone()),
14260                    Some(Value::Null) => None,
14261                    _ => return Err(malformed("holding an issue with no body member")),
14262                };
14263                let (_, metadata) = metadata_body(body)?;
14264                if metadata.get(MetadataKey::CREATE_KEY) == Some(&wanted) {
14265                    found.push(NativeId(id.to_owned()));
14266                }
14267            }
14268            let next = issues
14269                .pointer("/pageInfo/hasNextPage")
14270                .and_then(Value::as_bool)
14271                .ok_or_else(|| malformed("that does not say whether another follows"))?;
14272            if older || !next {
14273                return Ok(found);
14274            }
14275            // A page that says another follows names where it starts, and never a page already
14276            // read: anything else would ask for the same pages again for ever.
14277            let cursor = issues
14278                .pointer("/pageInfo/endCursor")
14279                .and_then(Value::as_str)
14280                .filter(|cursor| !cursor.is_empty())
14281                .map(str::to_owned);
14282            if cursor
14283                .as_ref()
14284                .is_none_or(|cursor| !followed.insert(cursor.clone()))
14285            {
14286                return Err(SourceError::Malformed {
14287                    message: format!(
14288                        "GitHub answered a page of repository {}'s issues that says another \
14289                         follows without a new cursor to it",
14290                        repository.slug()
14291                    ),
14292                });
14293            }
14294            after = cursor;
14295        }
14296    }
14297
14298    /// `Repository.issues` lists an issue GitHub committed only once its listing catches up.
14299    fn create_key_lags(&self) -> bool {
14300        true
14301    }
14302
14303    /// The task as this process's own latest write of it left it, from the item it recorded
14304    /// that write as — never a request. Its `version` is always `None`: a board's change token
14305    /// is given only once an item has gone unchanged for [`change_token::SETTLED`], which an
14306    /// item this command has just written has not, so no write's own answer can vouch for one.
14307    async fn written_task(&self, id: &NativeId) -> Option<Task> {
14308        let held = self
14309            .created()
14310            .ok()?
14311            .iter()
14312            .find(|held| held.id == *id)
14313            .cloned()
14314            .or_else(|| {
14315                self.updated()
14316                    .ok()?
14317                    .iter()
14318                    .find(|held| held.id == *id)
14319                    .cloned()
14320            })?;
14321        let mut task = held.task().ok()?;
14322        task.version = self.written_token(id);
14323        Some(task)
14324    }
14325
14326    /// Why `creation_owners` refuses a create of `target`, by [`Self::creation_rule`] — read
14327    /// only when the list is not empty, so a source restricting nothing reads nothing.
14328    async fn creation_refusal(
14329        &self,
14330        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14331    ) -> Result<Option<String>, SourceError> {
14332        let onetaskgraph_plugin_api::WriteTarget::New {
14333            repositories,
14334            project,
14335        } = target
14336        else {
14337            return Ok(None);
14338        };
14339        if self.creation_owners.is_empty() {
14340            return Ok(None);
14341        }
14342        let target = self
14343            .creation_rule(repositories, *project, "a new item")
14344            .await?;
14345        Ok(self.owner_refusal(&target))
14346    }
14347
14348    async fn health(&self) -> Result<Health, SourceError> {
14349        let board = self.board_page(None, 1).await?;
14350        Ok(Health {
14351            reachable: true,
14352            detail: Some(format!(
14353                "reading GitHub project {}/{} ({})",
14354                self.owner,
14355                self.project_number,
14356                required_str(&board, "title")?
14357            )),
14358        })
14359    }
14360    /// The board's own node id, digested: the one request reading its fields, or none when
14361    /// this command has read them already.
14362    async fn designator(&self) -> Result<Option<Designator>, SourceError> {
14363        let board = self.board_fields().await?;
14364        Designator::of_backend(KIND, board.id.as_str())
14365            .map(Some)
14366            .map_err(|message| SourceError::Malformed { message })
14367    }
14368    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
14369        self.item_by_id(id)
14370            .await?
14371            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14372            .map(|item| item.task())
14373            .transpose()
14374    }
14375    async fn task_assets(
14376        &self,
14377        id: &NativeId,
14378    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14379        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
14380    }
14381    async fn task_asset(
14382        &self,
14383        id: &NativeId,
14384        name: &onetaskgraph_plugin_api::AssetName,
14385    ) -> Result<Option<Vec<u8>>, SourceError> {
14386        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
14387            .await
14388    }
14389    async fn set_task_rendering_with_assets(
14390        &self,
14391        id: &NativeId,
14392        content: &str,
14393        provenance: &Value,
14394        _answers: &BTreeMap<String, Value>,
14395        assets: &onetaskgraph_plugin_api::AssetWrite,
14396    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14397        self.replace_rendering(
14398            id,
14399            BoardKind::Work(ItemKind::Task),
14400            content,
14401            provenance,
14402            Some(assets),
14403        )
14404        .await
14405    }
14406    async fn document_assets(
14407        &self,
14408        id: &NativeId,
14409    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14410        self.held_assets(id, BoardKind::Document).await
14411    }
14412    async fn document_asset(
14413        &self,
14414        id: &NativeId,
14415        name: &onetaskgraph_plugin_api::AssetName,
14416    ) -> Result<Option<Vec<u8>>, SourceError> {
14417        self.held_asset(id, BoardKind::Document, name).await
14418    }
14419    async fn set_document_rendering_with_assets(
14420        &self,
14421        id: &NativeId,
14422        content: &str,
14423        provenance: &Value,
14424        _answers: &BTreeMap<String, Value>,
14425        assets: &onetaskgraph_plugin_api::AssetWrite,
14426    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14427        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
14428            .await
14429    }
14430    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
14431        Ok(self
14432            .item_by_id(id)
14433            .await?
14434            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14435            .map(|item| item.project()))
14436    }
14437    /// Every narrowed search and origin lookup asked, a page at a time, as aliased fields of one
14438    /// [`graphql::SEARCH_BATCH`] request a round — a search's issue search in an `s<n>` field,
14439    /// an origin's board filter in an `o<n>` field beside its issue search — and every other
14440    /// query by [`Self::query_tasks`] on its own: one narrowed by a project or by comment
14441    /// activity, one carrying no narrowing, one resumed from a cursor that read handed out, and
14442    /// every query of a process that has written this board, whose own writes complete a
14443    /// narrowed answer. A lone search is its own read too, which costs a batch's point at a
14444    /// page of its own size.
14445    ///
14446    /// Each search's first page asks [`SEARCH_BATCH_FIRST_PAGE`] rows and each later page
14447    /// [`SEARCH_PAGE_SIZE`]. GitHub answers an error about one aliased field at that field's
14448    /// path, beside the others' answers, so a refused page fails that query's answer alone; a
14449    /// request it refuses whole is every query of that request's answer.
14450    async fn query_tasks_batch(
14451        &self,
14452        asks: &[(TaskQuery, PageRequest)],
14453    ) -> Vec<Result<Page<Task>, SourceError>> {
14454        self.search_batched(asks).await
14455    }
14456    async fn query_tasks(
14457        &self,
14458        query: &TaskQuery,
14459        page: &PageRequest,
14460    ) -> Result<Page<Task>, SourceError> {
14461        validate_page(page)?;
14462        refuse_unsearchable(query)?;
14463        // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
14464        // category — `unknown`; every other category reads only off an open issue or a draft. So
14465        // a status filter naming none of those three asks every issue search it sends for open
14466        // issues alone; each candidate's category is still confirmed from its own item below.
14467        let open_only = open_only(query);
14468        if query.origin.is_none()
14469            && !matches!(query.project, ProjectFilter::Is(_))
14470            && let Some(also) = search_qualifiers(query)
14471        {
14472            return self.search_tasks(query, page, &also).await;
14473        }
14474
14475        // A read narrowed to one project asks that project for its own tasks, so nothing
14476        // about it costs what the rest of the board holds. A read carrying a text, metadata
14477        // or origin predicate asks GitHub the narrower question those predicates are, and a
14478        // read narrowed to comment activity alone asks the board's own issue search for the
14479        // issues updated since, which is every issue a comment could have been written or
14480        // edited on since. Every other task read is a question about the whole board and is
14481        // answered by reading it.
14482        let (held, membership) = match (&query.project, query.commented_since) {
14483            (ProjectFilter::Is(project), _) => (
14484                match self.held_task_children(project)? {
14485                    // Taken as `project_children` takes the project's own sub-issue read: with
14486                    // every item this process wrote under it since, and none it moved away.
14487                    Some(held) => self.completed_with_written(
14488                        held.into_iter()
14489                            .filter(|child| child.parent.as_ref() == Some(project))
14490                            .collect(),
14491                        |own| own.parent.as_ref() == Some(project),
14492                    )?,
14493                    None => self.project_children(project).await?,
14494                },
14495                // Answered by where these items came from; see `task_matches`.
14496                &ProjectFilter::Any,
14497            ),
14498            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
14499                match (self.narrowed(query).await?, since) {
14500                    (Some(narrowed), _) => (narrowed, &query.project),
14501                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
14502                    // The board's own items list every draft; only the search half, which
14503                    // completes them with issues they lag behind on, is narrowed — a closed
14504                    // issue that half leaves out is at no category the filter keeps.
14505                    (None, None) if open_only => (
14506                        self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
14507                        &query.project,
14508                    ),
14509                    (None, None) => (self.board().await?.items, &query.project),
14510                }
14511            }
14512        };
14513        // Every candidate this read confirms by its comments is read together.
14514        let candidates = self.comment_candidates(held.iter(), query, membership)?;
14515        self.hold_comment_pages(&candidates).await?;
14516        // Filtered before paged: a page of a filtered result is a page of the survivors,
14517        // never the survivors of a page.
14518        let mut tasks = Vec::new();
14519        for item in held
14520            .iter()
14521            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14522        {
14523            let task = item.task()?;
14524            if task_matches(&task, query, membership)
14525                && self.commented_since(item, query.commented_since).await?
14526            {
14527                tasks.push(task);
14528            }
14529        }
14530        Ok(offset_page(
14531            tasks,
14532            numeric_cursor(page.cursor.as_ref())?,
14533            page.limit.min(MAX_PAGE_SIZE) as usize,
14534        ))
14535    }
14536    /// Read board pages directly, at the board connection's consistency. Unlike
14537    /// `query_tasks`, this does not complete a lagging board with issue search: a complete
14538    /// sweep needs one request per listing page, including the first comments.
14539    async fn query_task_details(
14540        &self,
14541        query: &TaskQuery,
14542        page: &PageRequest,
14543        comments: Option<&PageRequest>,
14544    ) -> Result<Page<TaskDetailRead>, SourceError> {
14545        let Some(comments) = comments else {
14546            let tasks = self.query_tasks(query, page).await?;
14547            return Ok(Page {
14548                items: tasks
14549                    .items
14550                    .into_iter()
14551                    .map(|task| TaskDetailRead {
14552                        task,
14553                        comments: None,
14554                    })
14555                    .collect(),
14556                next: tasks.next,
14557            });
14558        };
14559        validate_page(page)?;
14560        validate_page(comments)?;
14561        refuse_unsearchable(query)?;
14562        if comments.cursor.is_some() || query.commented_since.is_some() {
14563            return Err(SourceError::Refused {
14564                message: "a board listing with comments requires a first comment page and cannot filter by comment activity".into(),
14565            });
14566        }
14567        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
14568        let data = self
14569            .graphql(
14570                graphql::BOARD_WITH_COMMENTS,
14571                json!({
14572                    "owner":self.owner,"number":self.project_number,
14573                    "first":page.limit.min(MAX_PAGE_SIZE),"after":after,
14574                    "commentFirst":comments.limit.min(MAX_PAGE_SIZE),
14575                    "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true,
14576                }),
14577            )
14578            .await?;
14579        let connection = data
14580            .pointer("/owner/projectV2/items")
14581            .filter(|value| !value.is_null())
14582            .ok_or_else(|| SourceError::Refused {
14583                message: "GitHub project was not found or is not visible to the token".into(),
14584            })?;
14585        let mut items = Vec::new();
14586        for node in optional_nodes(Some(connection), "board items")?
14587            .into_iter()
14588            .flatten()
14589        {
14590            let Some(item) = self.resolve(node)? else {
14591                continue;
14592            };
14593            if item.kind != BoardKind::Work(ItemKind::Task) {
14594                continue;
14595            }
14596            let task = item.task()?;
14597            if !task_matches(&task, query, &query.project) {
14598                continue;
14599            }
14600            let read = if item.content_kind == ContentKind::DraftIssue {
14601                Err(self.draft_has_no_comments(&task.id))
14602            } else {
14603                self.expected.learn_comments(&task.id, &node["content"]);
14604                comment_page(&node["content"], &task.id.0, None).map(Some)
14605            };
14606            items.push(TaskDetailRead {
14607                task,
14608                comments: Some(read),
14609            });
14610        }
14611        let next = next_cursor(connection)?;
14612        if let Some(next) = &next {
14613            validate_cursor_progress(after, &next.0)?;
14614        }
14615        Ok(Page { items, next })
14616    }
14617
14618    async fn query_projects(
14619        &self,
14620        query: &ProjectQuery,
14621        page: &PageRequest,
14622    ) -> Result<Page<Project>, SourceError> {
14623        validate_page(page)?;
14624        refuse_unsearchable_text(query.text.as_ref())?;
14625        // The projects a board holds are found by an issue search scoped to that board,
14626        // never by walking the board's own item connection: what tells a project from a
14627        // task is the `parent` each issue carries, which costs nothing to read. A query
14628        // carrying a text asks that search for the text too, so it reads the issues that
14629        // hold it rather than every issue of the board.
14630        let held = match self.text_searched(query.text.as_ref()).await? {
14631            Some(searched) => searched,
14632            None => self.board_issues().await?,
14633        };
14634        let projects = held
14635            .iter()
14636            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14637            .map(Resolved::project)
14638            .filter(|project| project_matches(project, query))
14639            .collect();
14640        Ok(offset_page(
14641            projects,
14642            numeric_cursor(page.cursor.as_ref())?,
14643            page.limit.min(MAX_PAGE_SIZE) as usize,
14644        ))
14645    }
14646    async fn query_projects_by_origin(
14647        &self,
14648        origin: &str,
14649        page: &PageRequest,
14650    ) -> Result<Page<Project>, SourceError> {
14651        validate_page(page)?;
14652        let projects = self
14653            .copied_from(origin, BoardKind::Work(ItemKind::Project))
14654            .await?
14655            .iter()
14656            .map(Resolved::project)
14657            .collect();
14658        Ok(offset_page(
14659            projects,
14660            numeric_cursor(page.cursor.as_ref())?,
14661            page.limit.min(MAX_PAGE_SIZE) as usize,
14662        ))
14663    }
14664    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
14665        let Some(item) = self
14666            .document_by_id(id)
14667            .await?
14668            .filter(|item| item.kind == BoardKind::Document)
14669        else {
14670            return Ok(None);
14671        };
14672        self.expected.read_document(&item);
14673        Ok(Some(item.document()))
14674    }
14675    async fn query_documents_by_origin(
14676        &self,
14677        origin: &str,
14678        page: &PageRequest,
14679    ) -> Result<Page<Document>, SourceError> {
14680        validate_page(page)?;
14681        let documents = self
14682            .copied_from(origin, BoardKind::Document)
14683            .await?
14684            .iter()
14685            .map(Resolved::document)
14686            .collect();
14687        Ok(offset_page(
14688            documents,
14689            numeric_cursor(page.cursor.as_ref())?,
14690            page.limit.min(MAX_PAGE_SIZE) as usize,
14691        ))
14692    }
14693    async fn query_documents(
14694        &self,
14695        query: &DocumentQuery,
14696        page: &PageRequest,
14697    ) -> Result<Page<Document>, SourceError> {
14698        validate_page(page)?;
14699        // Narrowed to one project, this is the same sub-issue read a task list scoped to
14700        // that project makes — a document filed under a project is a sub-issue of it too,
14701        // and which of them come back is the kind this caller asked for. Unscoped, a query
14702        // carrying a text asks the board-scoped issue search for it, as a task query does,
14703        // and only one carrying none reads the board.
14704        let (held, membership) = match &query.project {
14705            ProjectFilter::Is(project) => (
14706                self.project_children(project).await?,
14707                // Answered by where these items came from; see `task_matches`.
14708                &ProjectFilter::Any,
14709            ),
14710            ProjectFilter::Any | ProjectFilter::Orphans => {
14711                refuse_unsearchable_text(query.text.as_ref())?;
14712                match self.text_searched(query.text.as_ref()).await? {
14713                    Some(searched) => (searched, &query.project),
14714                    None => (self.board().await?.items, &query.project),
14715                }
14716            }
14717        };
14718        // Filtered before paged, exactly as a task read is: a page of a filtered result is
14719        // a page of the survivors, never the survivors of a page.
14720        let documents = held
14721            .iter()
14722            .filter(|item| item.kind == BoardKind::Document)
14723            .map(Resolved::document)
14724            .filter(|document| document_matches(document, query, membership))
14725            .collect();
14726        Ok(offset_page(
14727            documents,
14728            numeric_cursor(page.cursor.as_ref())?,
14729            page.limit.min(MAX_PAGE_SIZE) as usize,
14730        ))
14731    }
14732    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
14733        validate_page(page)?;
14734        let offset = numeric_cursor(page.cursor.as_ref())?;
14735        let mut labels = self
14736            .board()
14737            .await?
14738            .items
14739            .into_iter()
14740            .flat_map(|item| item.labels)
14741            .fold(Vec::new(), |mut all, label| {
14742                if !all.iter().any(|x: &Label| x.id == label.id) {
14743                    all.push(label);
14744                }
14745                all
14746            });
14747        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
14748        Ok(offset_page(
14749            labels,
14750            offset,
14751            page.limit.min(MAX_PAGE_SIZE) as usize,
14752        ))
14753    }
14754    async fn task_dependencies(
14755        &self,
14756        id: &NativeId,
14757        direction: Direction,
14758        page: &PageRequest,
14759    ) -> Result<Page<DependencyEdge>, SourceError> {
14760        self.dependencies(id, ItemKind::Task, direction, page).await
14761    }
14762    async fn project_dependencies(
14763        &self,
14764        id: &NativeId,
14765        direction: Direction,
14766        page: &PageRequest,
14767    ) -> Result<Page<DependencyEdge>, SourceError> {
14768        self.dependencies(id, ItemKind::Project, direction, page)
14769            .await
14770    }
14771
14772    fn writes(&self) -> WriteSupport {
14773        WriteSupport::Supported
14774    }
14775
14776    /// Create or update one task.
14777    ///
14778    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
14779    /// neither may name the task itself or name one task twice — and land in the body's
14780    /// metadata slot under their reserved keys, in place of any caller metadata of those
14781    /// names.
14782    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
14783        self.write_task_assets(write, None)
14784            .await
14785            .map(|written| written.id)
14786    }
14787
14788    async fn read_ahead_origins_for_copy(&self, origins: &[String]) -> Result<(), SourceError> {
14789        self.origins_ahead(origins).await
14790    }
14791
14792    async fn member_classifications(
14793        &self,
14794        project: &NativeId,
14795    ) -> Result<Option<Vec<(Classification, Vec<Repository>)>>, SourceError> {
14796        Ok(self
14797            .member_classes
14798            .lock()
14799            .map_err(|_| SourceError::Unavailable {
14800                message: "a project's members held for its classification were left \
14801                          inconsistent; run the command again"
14802                    .into(),
14803            })?
14804            .remove(project))
14805    }
14806
14807    /// The issue's own web address, as the `createIssue` that made it in this command answered
14808    /// it: a task, a project and a document are each an issue here.
14809    fn written_location(
14810        &self,
14811        _record: onetaskgraph_plugin_api::MetadataRecord,
14812        id: &NativeId,
14813    ) -> Option<Location> {
14814        self.written
14815            .lock()
14816            .unwrap_or_else(std::sync::PoisonError::into_inner)
14817            .get(id)
14818            .cloned()
14819            .map(Location::Url)
14820    }
14821
14822    /// [`MUTATION_BATCH`], while writes are held back: [`graphql::CREATE_ISSUES`] creates that many
14823    /// in one request, and a copy's tasks are created together only then, since their fields
14824    /// and edges are what is held back.
14825    fn create_batch_limit(&self) -> usize {
14826        if self.deferring().unwrap_or(false) {
14827            MUTATION_BATCH
14828        } else {
14829            1
14830        }
14831    }
14832
14833    // llmlint: ignore[invalid_states_unrepresentable] The signature is the plugin contract's additive `TaskSource::write_tasks`, which the manager approved taking `ItemWrite<Task>` as `write_task` does; a create-only input type would be a new contract type. A write naming an existing target is refused below before anything is sent.
14834    async fn write_tasks(&self, writes: &[ItemWrite<Task>]) -> Result<Vec<NativeId>, SourceError> {
14835        if let Some(write) = writes.iter().find(|write| write.target.is_some()) {
14836            return Err(SourceError::Refused {
14837                message: format!(
14838                    "write_tasks creates tasks, and the write of {} names the existing task {}; \
14839                     next: update it with write_task",
14840                    write.item.id,
14841                    write.target.as_ref().map_or("", |target| target.0.as_str())
14842                ),
14843            });
14844        }
14845        if !self.deferring()? {
14846            let mut created = Vec::with_capacity(writes.len());
14847            for write in writes {
14848                match self.write_task(write).await {
14849                    Ok(id) => created.push(id),
14850                    Err(error) => {
14851                        let mut left: Vec<&str> = Vec::new();
14852                        for id in &created {
14853                            if self.delete_issue(id).await.is_err() {
14854                                left.push(id.0.as_str());
14855                            }
14856                        }
14857                        return Err(left_behind(error, &left));
14858                    }
14859                }
14860            }
14861            return Ok(created);
14862        }
14863        self.create_together(writes).await
14864    }
14865
14866    fn begin_deferred_writes(&self) {
14867        if let Ok(mut deferred) = self.deferred.lock() {
14868            deferred.get_or_insert_with(Deferred::default);
14869        }
14870    }
14871
14872    async fn finish_writes(&self) -> Result<(), SourceError> {
14873        self.send_held_updates().await?;
14874        self.send_held_back().await
14875    }
14876
14877    async fn write_task_with_assets(
14878        &self,
14879        write: &ItemWrite<Task>,
14880        _answers: Option<&BTreeMap<String, Value>>,
14881        assets: &onetaskgraph_plugin_api::AssetWrite,
14882    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
14883        self.write_task_assets(write, Some(assets)).await
14884    }
14885
14886    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
14887        self.write_item(
14888            &Incoming {
14889                written: Written::Work(ItemKind::Project, &write.item.status),
14890                title: &write.item.title,
14891                content: write.item.content.as_deref(),
14892                assets: None,
14893                labels: &write.item.labels,
14894                metadata: &write.item.metadata,
14895                repositories: &write.item.repositories,
14896                classification: write.item.classification,
14897                parent: None,
14898                delivers: &[],
14899                delivered_by: &[],
14900                priority: None,
14901            },
14902            write.target.as_ref(),
14903            &write.depends_on,
14904        )
14905        .await
14906        .map(|written| written.id)
14907    }
14908
14909    fn task_body_size(
14910        &self,
14911        write: &ItemWrite<Task>,
14912        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
14913    ) -> Option<onetaskgraph_plugin_api::BodySize> {
14914        Some(self.body_size(&self.task_incoming(write, assets).ok()?))
14915    }
14916
14917    fn project_body_size(
14918        &self,
14919        write: &ItemWrite<Project>,
14920    ) -> Option<onetaskgraph_plugin_api::BodySize> {
14921        Some(self.body_size(&Incoming {
14922            written: Written::Work(ItemKind::Project, &write.item.status),
14923            title: &write.item.title,
14924            content: write.item.content.as_deref(),
14925            assets: None,
14926            labels: &write.item.labels,
14927            metadata: &write.item.metadata,
14928            repositories: &write.item.repositories,
14929            classification: write.item.classification,
14930            parent: None,
14931            delivers: &[],
14932            delivered_by: &[],
14933            priority: None,
14934        }))
14935    }
14936
14937    fn document_body_size(
14938        &self,
14939        write: &ItemWrite<Document>,
14940        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
14941    ) -> Option<onetaskgraph_plugin_api::BodySize> {
14942        Some(self.body_size(&Incoming {
14943            written: Written::Document,
14944            title: &write.item.title,
14945            content: write.item.content.as_deref(),
14946            assets,
14947            labels: &write.item.labels,
14948            metadata: &write.item.metadata,
14949            repositories: &write.item.repositories,
14950            classification: write.item.classification,
14951            parent: write.item.project.as_ref(),
14952            delivers: &[],
14953            delivered_by: &[],
14954            priority: None,
14955        }))
14956    }
14957
14958    /// Create or update one document, which is one issue titled the way this board spells
14959    /// a document.
14960    ///
14961    /// Everything else is exactly a task write: caller metadata goes to the same canonical
14962    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
14963    /// or a field this board cannot carry is refused by name rather than dropped, a target
14964    /// naming an issue this board does not hold is refused rather than created, and an
14965    /// issue this call created is taken back when the rest of the write fails.
14966    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
14967        self.write_document_assets(write, None)
14968            .await
14969            .map(|written| written.id)
14970    }
14971
14972    async fn write_document_with_assets(
14973        &self,
14974        write: &ItemWrite<Document>,
14975        _answers: Option<&BTreeMap<String, Value>>,
14976        assets: &onetaskgraph_plugin_api::AssetWrite,
14977    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
14978        self.write_document_assets(write, Some(assets)).await
14979    }
14980
14981    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
14982    /// which reads nothing; then the board's `Status` option. Over an existing item that is
14983    /// read off the item, as the write reads it, and the item is held among this command's
14984    /// resolved records so the write that follows reuses that read rather than repeating it;
14985    /// an item that does not carry the field takes the board's fields, which are held once
14986    /// read. A create is checked against the board's fields only when this command already
14987    /// holds them, because a create reads them together with its repository, in one request,
14988    /// and refuses a missing option before it writes anything.
14989    async fn check_status_write(
14990        &self,
14991        kind: ItemKind,
14992        category: StatusCategory,
14993        target: Option<&NativeId>,
14994    ) -> Result<(), SourceError> {
14995        let status = self.resolved_target(kind, category)?;
14996        if status.option().is_none() {
14997            return Ok(());
14998        }
14999        let fields = match target {
15000            Some(target) => {
15001                // A target this board does not hold is the write's own refusal to make.
15002                let Some(item) = self.bound_item(target).await? else {
15003                    return Ok(());
15004                };
15005                self.resolved_cache()?.insert(target.clone(), item.clone());
15006                // Only the `Status` field decides whether the write can land.
15007                self.fields_for(Some(&item), false, true, false)
15008                    .await?
15009                    .fields
15010            }
15011            None => {
15012                let held = self
15013                    .board_cache()?
15014                    .as_ref()
15015                    .map(|board| board.fields.clone());
15016                match held.or_else(|| {
15017                    self.fields_cache()
15018                        .ok()
15019                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
15020                }) {
15021                    Some(fields) => fields,
15022                    None => return Ok(()),
15023                }
15024            }
15025        };
15026        self.column_for(&fields, kind, category, &status)
15027            .map(|_| ())
15028    }
15029
15030    /// What a write of `status` reads back as: the `Status` option its category maps to for
15031    /// `kind`, and for a closing category the closure that option and its fixed reason read
15032    /// as — exactly what [`BoardStatuses::status`] reports of the item once written. A category
15033    /// this instance has no option for is answered as given, since writing it is refused.
15034    fn status_as_written(&self, kind: ItemKind, status: &Status) -> Status {
15035        match self.statuses.target(kind, status.category) {
15036            StatusTarget::Column(name) => {
15037                self.statuses.status(kind, Some(name.as_str()), false, None)
15038            }
15039            StatusTarget::Terminal(name, reason) => {
15040                self.statuses
15041                    .status(kind, Some(name.as_str()), true, Some(reason.reason()))
15042            }
15043            StatusTarget::Disabled(_) => status.clone(),
15044        }
15045    }
15046
15047    /// Set one task's status alone.
15048    ///
15049    /// An open target reopens a closed issue with an `updateIssue` carrying only its
15050    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
15051    /// terminal target selects its mapped option, then closes with its fixed reason. No
15052    /// request carries a title, a body or a label. The status
15053    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
15054    /// what a re-read reports.
15055    async fn set_task_status(
15056        &self,
15057        id: &NativeId,
15058        category: StatusCategory,
15059    ) -> Result<Option<Status>, SourceError> {
15060        self.set_status(id, ItemKind::Task, category).await
15061    }
15062
15063    /// Set one task's status and answer the task with it, reading the item once: the read the
15064    /// task is answered from is handed to the status write, which would otherwise read the
15065    /// same issue again — two requests on a fresh process, the read and the field write,
15066    /// where the default's `get_task` before `set_task_status` sent three.
15067    async fn set_task_status_reading(
15068        &self,
15069        id: &NativeId,
15070        category: StatusCategory,
15071    ) -> Result<Option<Task>, SourceError> {
15072        // Refused before anything is read, in the words the write itself refuses it.
15073        self.resolved_target(ItemKind::Task, category)?;
15074        let Some(item) = self
15075            .bound_item(id)
15076            .await?
15077            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15078        else {
15079            return Ok(None);
15080        };
15081        let mut task = item.task()?;
15082        self.held_cache
15083            .lock()
15084            .map_err(|_| SourceError::Unavailable {
15085                message: "items read by issue reference were left inconsistent; run the \
15086                          command again"
15087                    .into(),
15088            })?
15089            .insert(id.clone(), item);
15090        let Some(status) = self.set_status(id, ItemKind::Task, category).await? else {
15091            return Ok(None);
15092        };
15093        task.status = status;
15094        Ok(Some(task))
15095    }
15096
15097    /// Set one project's status alone, by exactly the paired write a task's status takes, for
15098    /// the project kind: a terminal category selects the project kind's mapped option and then
15099    /// closes the issue with the category's fixed reason — an issue already closed with a
15100    /// reason that reads that category is left as it is — and an open category reopens a closed
15101    /// issue and then selects its option. A board draft is refused a terminal category as a
15102    /// task's is.
15103    async fn set_project_status(
15104        &self,
15105        id: &NativeId,
15106        category: StatusCategory,
15107    ) -> Result<Option<Status>, SourceError> {
15108        self.set_status(id, ItemKind::Project, category).await
15109    }
15110
15111    /// Every task and project whose card contradicts its issue's closure, read through the
15112    /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
15113    async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
15114        self.contradictions().await
15115    }
15116
15117    /// What this board holds for each issue reference, one request per fifty of them; see
15118    /// [`TaskSource::issue_holdings`].
15119    async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
15120        self.holdings(issues).await
15121    }
15122
15123    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
15124    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
15125    /// for `none`. Refused by an instance with no `priority_mapping`.
15126    async fn set_task_priority(
15127        &self,
15128        id: &NativeId,
15129        priority: Priority,
15130    ) -> Result<Option<Priority>, SourceError> {
15131        self.set_priority(id, priority).await
15132    }
15133
15134    /// Replace one task's content with a single body update that keeps the metadata slot
15135    /// byte for byte.
15136    async fn set_task_content(
15137        &self,
15138        id: &NativeId,
15139        content: &str,
15140    ) -> Result<Option<()>, SourceError> {
15141        self.replace_content(id, content).await
15142    }
15143
15144    /// Replace one task issue's content and its provenance slot entry with a single body
15145    /// update. The answers are not kept: see `replace_rendering`.
15146    async fn set_task_rendering(
15147        &self,
15148        id: &NativeId,
15149        content: &str,
15150        provenance: &Value,
15151        _answers: &BTreeMap<String, Value>,
15152    ) -> Result<Option<()>, SourceError> {
15153        self.replace_rendering(
15154            id,
15155            BoardKind::Work(ItemKind::Task),
15156            content,
15157            provenance,
15158            None,
15159        )
15160        .await
15161        .map(|written| written.map(|_| ()))
15162    }
15163
15164    /// Replace one design-document issue's content and its provenance slot entry, on exactly
15165    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15166    async fn set_document_rendering(
15167        &self,
15168        id: &NativeId,
15169        content: &str,
15170        provenance: &Value,
15171        _answers: &BTreeMap<String, Value>,
15172    ) -> Result<Option<()>, SourceError> {
15173        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
15174            .await
15175            .map(|written| written.map(|_| ()))
15176    }
15177
15178    /// Replace one project issue's content and its provenance slot entry, on exactly the
15179    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15180    async fn set_project_rendering(
15181        &self,
15182        id: &NativeId,
15183        content: &str,
15184        provenance: &Value,
15185        _answers: &BTreeMap<String, Value>,
15186    ) -> Result<Option<()>, SourceError> {
15187        self.replace_rendering(
15188            id,
15189            BoardKind::Work(ItemKind::Project),
15190            content,
15191            provenance,
15192            None,
15193        )
15194        .await
15195        .map(|written| written.map(|_| ()))
15196    }
15197
15198    /// Apply a targeted update with one read of the item and a write only for what differs:
15199    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
15200    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
15201    async fn update_task(
15202        &self,
15203        id: &NativeId,
15204        update: &TaskUpdate,
15205    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
15206        self.targeted_update(id, update).await
15207    }
15208
15209    /// Every task read together — [`DETAIL_BATCH`] to a [`graphql::ISSUE_DETAILS`] request —
15210    /// and each update's hold and guard decided on that read; then each update the read lets
15211    /// through composed exactly as [`Self::update_task`] composes one, its board field writes
15212    /// and its issue update held back and sent in [`graphql::WRITE_TASKS`] requests: every
15213    /// field write first, then the issue update of every task whose field writes all landed.
15214    ///
15215    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15216    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15217    /// that batch is read by itself, the missing one answered missing and the rest as
15218    /// themselves. A batch of one, an update of a board draft, one naming dependencies and one
15219    /// of an item whose body this process has not read are each made on their own, as
15220    /// [`Self::update_task`] makes them, because each sends writes of its own in an order the
15221    /// batch does not hold. A field write GitHub refuses fails that task's update alone and
15222    /// sends nothing of its body.
15223    async fn update_tasks(
15224        &self,
15225        updates: &[onetaskgraph_plugin_api::BatchUpdate],
15226    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<TaskUpdateOutcome>, SourceError>> {
15227        self.updates_batched(updates).await
15228    }
15229
15230    /// Replace one task's `delivered_by` with a single body update that changes the
15231    /// metadata slot and nothing outside it.
15232    async fn set_delivered_by(
15233        &self,
15234        id: &NativeId,
15235        delivered_by: &[TaskRef],
15236    ) -> Result<Option<()>, SourceError> {
15237        self.replace_delivered_by(id, delivered_by).await
15238    }
15239
15240    /// Set one key of one task issue's metadata with a single body update that changes the
15241    /// metadata slot and nothing outside it — no title, label, state or board field request —
15242    /// and sends nothing when the task already holds that value under the key.
15243    async fn set_task_metadata(
15244        &self,
15245        id: &NativeId,
15246        key: &MetadataKey,
15247        value: &Value,
15248    ) -> Result<Option<Task>, SourceError> {
15249        Ok(self
15250            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
15251            .await?
15252            .map(|item| item.task())
15253            .transpose()?)
15254    }
15255
15256    /// Set one key of one project issue's metadata, on exactly the terms of
15257    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15258    async fn set_project_metadata(
15259        &self,
15260        id: &NativeId,
15261        key: &MetadataKey,
15262        value: &Value,
15263    ) -> Result<Option<Project>, SourceError> {
15264        Ok(self
15265            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
15266            .await?
15267            .map(|item| item.project()))
15268    }
15269
15270    /// Set one key of one design-document issue's metadata, on exactly the terms of
15271    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15272    async fn set_document_metadata(
15273        &self,
15274        id: &NativeId,
15275        key: &MetadataKey,
15276        value: &Value,
15277    ) -> Result<Option<Document>, SourceError> {
15278        Ok(self
15279            .set_slot_key(id, BoardKind::Document, key, value)
15280            .await?
15281            .map(|item| item.document()))
15282    }
15283
15284    /// Take one key out of one issue's metadata slot with a single body update that changes the
15285    /// slot and nothing outside it, sending nothing when the slot does not hold the key.
15286    async fn remove_record_metadata(
15287        &self,
15288        record: onetaskgraph_plugin_api::MetadataRecord,
15289        id: &NativeId,
15290        key: &MetadataKey,
15291    ) -> Result<Option<Option<String>>, SourceError> {
15292        let kind = match record {
15293            onetaskgraph_plugin_api::MetadataRecord::Task => BoardKind::Work(ItemKind::Task),
15294            onetaskgraph_plugin_api::MetadataRecord::Project => BoardKind::Work(ItemKind::Project),
15295            onetaskgraph_plugin_api::MetadataRecord::Document => BoardKind::Document,
15296        };
15297        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
15298            return Ok(None);
15299        };
15300        if item.slot.contains_key(key.as_str()) {
15301            let mut slot = item.slot.clone();
15302            slot.remove(key.as_str());
15303            self.write_slot(&mut item, &slot).await?;
15304            self.remember_written(item.clone(), false)?;
15305        }
15306        Ok(Some(match record {
15307            onetaskgraph_plugin_api::MetadataRecord::Task => item.task()?.version,
15308            onetaskgraph_plugin_api::MetadataRecord::Project => item.project().version,
15309            onetaskgraph_plugin_api::MetadataRecord::Document => item.document().version,
15310        }))
15311    }
15312
15313    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
15314        self.delete_item(id).await
15315    }
15316
15317    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
15318        self.delete_item(id).await
15319    }
15320
15321    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
15322        self.delete_item(id).await
15323    }
15324
15325    /// One page of the task issue's own comments, walked by GitHub's own cursor.
15326    ///
15327    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
15328    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
15329    ///
15330    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
15331    /// board is the read of its comments. A draft this process already resolved is refused
15332    /// without one.
15333    async fn task_comments(
15334        &self,
15335        task: &NativeId,
15336        page: &PageRequest,
15337    ) -> Result<Option<Page<Comment>>, SourceError> {
15338        validate_page(page)?;
15339        let cached = self.resolved_cache()?.get(task).cloned();
15340        if let Some(item) = cached {
15341            if item.kind != BoardKind::Work(ItemKind::Task) {
15342                return Ok(None);
15343            }
15344            if item.content_kind == ContentKind::DraftIssue {
15345                return Err(self.draft_has_no_comments(task));
15346            }
15347        }
15348        // The first page a comment-activity read just read of this task, at the size asked, is
15349        // that read's answer: taken, so it answers once.
15350        if page.cursor.is_none()
15351            && page.limit >= MAX_PAGE_SIZE
15352            && let Some(held) = self.held_comments()?.remove(task)
15353        {
15354            return Ok(Some(held));
15355        }
15356        match self.issue_detail(task, page).await? {
15357            Some(TaskDetailRead {
15358                comments: Some(comments),
15359                ..
15360            }) => comments,
15361            _ => Ok(None),
15362        }
15363    }
15364
15365    /// Every id's task, with the first page of its comments when `comments` names it:
15366    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
15367    /// comments in one [`graphql::ISSUE_DETAIL`] request.
15368    async fn get_task_details(
15369        &self,
15370        ids: &[NativeId],
15371        comments: Option<&PageRequest>,
15372    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15373        if let Some(page) = comments
15374            && let Err(error) = validate_page(page)
15375        {
15376            return ids.iter().map(|_| Err(error.clone())).collect();
15377        }
15378        match (ids, comments) {
15379            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
15380            ([id], None) => vec![self.task_read(id).await],
15381            _ => self.issue_details(ids, comments).await,
15382        }
15383    }
15384
15385    /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
15386    /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
15387    /// request, so the dependency read a copy makes of each next is answered from this one.
15388    async fn get_task_details_for_copy(
15389        &self,
15390        ids: &[NativeId],
15391        comments: &PageRequest,
15392    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15393        // A page past the first is a page of one task's comments, which the detail read of it
15394        // alone answers.
15395        if comments.cursor.is_some() {
15396            return self.get_task_details(ids, Some(comments)).await;
15397        }
15398        if let Err(error) = validate_page(comments) {
15399            return ids.iter().map(|_| Err(error.clone())).collect();
15400        }
15401        let mut read = Vec::with_capacity(ids.len());
15402        for batch in ids.chunks(COPY_DETAIL_BATCH) {
15403            match self
15404                .graphql(
15405                    graphql::ISSUE_COPY_DETAILS,
15406                    copy_detail_batch(batch, comments, false),
15407                )
15408                .await
15409            {
15410                Ok(data) => {
15411                    for (slot, id) in batch.iter().enumerate() {
15412                        let read_one = match data.get(format!("i{slot}")) {
15413                            Some(node) => self.detail_of(id, node, true, None).await,
15414                            None => Err(SourceError::Malformed {
15415                                message: format!(
15416                                    "GitHub answered a batch read with no item for {}",
15417                                    id.0
15418                                ),
15419                            }),
15420                        };
15421                        read.push(read_one);
15422                    }
15423                }
15424                Err(error) if unresolvable_node(&error) => {
15425                    for id in batch {
15426                        read.push(self.issue_detail(id, comments).await);
15427                    }
15428                }
15429                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
15430            }
15431        }
15432        read
15433    }
15434
15435    /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
15436    /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
15437    /// and held for the next read of each by id; see `read_ahead`.
15438    ///
15439    /// What this run wrote is never read: its own record answers, as it does for a read by id.
15440    /// Nor is an item a listing of its project carried whole earlier in this command — see
15441    /// `carried` — which is held in place of reading it.
15442    /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
15443    /// so each of its ids is read on its own and answered as that read answers; a draft, and an
15444    /// answer naming another issue than the one asked for, are left to their own reads too.
15445    async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
15446        // The origins asked just before go out now, beside the members of the one project this
15447        // names when it names one; a project those origins answered whole is held for its next
15448        // read by id rather than read again here.
15449        let hinted = match ids {
15450            [one] => Some(one),
15451            _ => None,
15452        };
15453        self.send_pending_origins(hinted).await?;
15454        if let Some(project) = hinted {
15455            let answered = self.resolved_cache()?.get(project).cloned();
15456            if let Some(answered) = answered
15457                && answered.kind == BoardKind::Work(ItemKind::Project)
15458            {
15459                self.read_ahead()?.insert(project.clone(), Some(answered));
15460                return Ok(());
15461            }
15462        }
15463        let mut unread: Vec<NativeId> = Vec::new();
15464        // Read whole by a listing of its project in this command, which a mutation naming it
15465        // would have dropped: held as a batch would have held it, once every batch is answered.
15466        let mut listed: Vec<NativeId> = Vec::new();
15467        {
15468            let created = self.created()?;
15469            let held = self.read_ahead()?;
15470            let carried = self.carried()?;
15471            for id in ids {
15472                if unread.contains(id)
15473                    || listed.contains(id)
15474                    || held.contains_key(id)
15475                    || created.iter().any(|own| own.id == *id)
15476                {
15477                    continue;
15478                }
15479                // An issue a listing carried without the whole of what blocks it is read in a
15480                // batch too, which carries that, rather than having its dependency read sent
15481                // on its own once the copy reconciles its edges.
15482                let whole = carried.get(id).is_some_and(|item| {
15483                    item.content_kind != ContentKind::Issue || item.blocked_by.is_some()
15484                });
15485                if whole {
15486                    listed.push(id.clone());
15487                } else {
15488                    unread.push(id.clone());
15489                }
15490            }
15491        }
15492        // Nothing of an item's comments is wanted here: the smallest page keeps the read to
15493        // what the item itself costs.
15494        let comments = PageRequest {
15495            cursor: None,
15496            limit: 1,
15497        };
15498        // Held only once every batch has been answered, so a read-ahead that fails part way
15499        // holds nothing — what a listing carried included, which stays carried — and every
15500        // item is read on its own, as the method promises.
15501        let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
15502        // The issues the first id names as sub-issues, as `ISSUE_COPY_DETAILS`'s `children`
15503        // answered them: what a project copy's orphan check asks, when that first id is its
15504        // project.
15505        let mut children: Option<(NativeId, Vec<(String, String)>)> = None;
15506        for (index, batch) in unread.chunks(COPY_DETAIL_BATCH).enumerate() {
15507            let data = match self
15508                .graphql(
15509                    graphql::ISSUE_COPY_DETAILS,
15510                    copy_detail_batch(batch, &comments, index == 0),
15511                )
15512                .await
15513            {
15514                Ok(data) => data,
15515                Err(error) if unresolvable_node(&error) => continue,
15516                Err(error) => return Err(error),
15517            };
15518            for (slot, id) in batch.iter().enumerate() {
15519                let Some(node) = data.get(format!("i{slot}")) else {
15520                    continue;
15521                };
15522                let held = if node.is_null() {
15523                    None
15524                } else {
15525                    match optional_str(node, "__typename")? {
15526                        Some("DraftIssue") => continue,
15527                        Some("Issue") if required_str(node, "id")? != id.0 => continue,
15528                        _ => {}
15529                    }
15530                    // Remembered below with the rest, so a later batch refused leaves no
15531                    // dependency read answered from this one either.
15532                    self.resolve_issue_as(node, false).await?
15533                };
15534                read.push((id.clone(), held));
15535            }
15536            if index == 0
15537                && let Some(node) = data.get("children").filter(|node| !node.is_null())
15538                && node.get("id").and_then(Value::as_str) == batch.first().map(|id| id.0.as_str())
15539                && node.pointer("/subIssues/pageInfo/hasNextPage") == Some(&Value::Bool(false))
15540            {
15541                // A list GitHub did not answer is not an empty one: it would answer the project's
15542                // task listing with nothing, and the orphan check with no orphans.
15543                let listed = node
15544                    .pointer("/subIssues/nodes")
15545                    .and_then(Value::as_array)
15546                    .ok_or_else(|| SourceError::Malformed {
15547                        message: format!(
15548                            "GitHub answered the sub-issues of project {} with no list of them",
15549                            batch[0].0
15550                        ),
15551                    })?
15552                    .iter()
15553                    .map(|child| {
15554                        Ok((
15555                            required_str(child, "id")?.to_owned(),
15556                            required_str(child, "title")?.to_owned(),
15557                        ))
15558                    })
15559                    .collect::<Result<Vec<_>, SourceError>>()?;
15560                children = Some((batch[0].clone(), listed));
15561            }
15562        }
15563        // The project's tasks are known from this read alone when every child it named is held
15564        // from it, but for a document, which its title says is one and which a task listing
15565        // never answers: then a listing of the project's tasks is answered from them.
15566        if let Some((project, listed)) = children
15567            && read.iter().any(|(id, held)| {
15568                *id == project
15569                    && held
15570                        .as_ref()
15571                        .is_some_and(|held| held.kind == BoardKind::Work(ItemKind::Project))
15572            })
15573        {
15574            let mut tasks = Vec::new();
15575            let mut whole = true;
15576            for (child, title) in &listed {
15577                match read.iter().find(|(id, _)| id.0 == *child) {
15578                    Some((_, Some(held))) if held.parent.as_ref() == Some(&project) => {
15579                        if held.kind == BoardKind::Work(ItemKind::Task) {
15580                            tasks.push(held.clone());
15581                        }
15582                    }
15583                    _ if title.starts_with(DESIGN_TITLE_PREFIX) => {}
15584                    _ => whole = false,
15585                }
15586            }
15587            if whole {
15588                self.task_children()?.insert(project, tasks);
15589            }
15590        }
15591        let mut resolved = self.resolved_cache()?;
15592        let mut ahead = self.read_ahead()?;
15593        let mut carried = self.carried()?;
15594        for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
15595            resolved.insert(item.id.clone(), item.clone());
15596        }
15597        // Taken only if still carried: a mutation naming it while the batches ran dropped it,
15598        // and its next read is then its own.
15599        for id in &listed {
15600            let Some(item) = carried.remove(id) else {
15601                continue;
15602            };
15603            // The whole of what it is blocked by came with it, so the dependency read a copy
15604            // makes next is answered from this one, as a copy's detail read is.
15605            if item.blocked_by.is_some() {
15606                resolved.insert(item.id.clone(), item.clone());
15607            }
15608            read.push((id.clone(), Some(item)));
15609        }
15610        for (id, held) in read {
15611            ahead.insert(id, held);
15612        }
15613        Ok(())
15614    }
15615
15616    /// Add one comment to the task's issue, as the account the token belongs to.
15617    ///
15618    /// The author is refused before anything is sent — not even the task is read — because
15619    /// no answer GitHub could give would make posting under another name than the one asked
15620    /// for the right outcome.
15621    async fn add_comment(
15622        &self,
15623        task: &NativeId,
15624        comment: &NewComment,
15625    ) -> Result<Option<Comment>, SourceError> {
15626        if let Some(author) = &comment.author {
15627            return Err(SourceError::Refused {
15628                message: format!(
15629                    "source {} cannot post a comment as {author:?}: GitHub records the account \
15630                     the token signs in as the author of every comment; next: leave --author \
15631                     out, and the comment is posted as that account",
15632                    self.name
15633                ),
15634            });
15635        }
15636        let Some(issue) = self.commented_issue(task).await? else {
15637            return Ok(None);
15638        };
15639        let data = self
15640            .graphql(
15641                graphql::ADD_COMMENT,
15642                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
15643            )
15644            .await?;
15645        let subject = data
15646            .pointer("/addComment/subject")
15647            .filter(|value| !value.is_null())
15648            .ok_or_else(|| SourceError::Malformed {
15649                message: "GitHub comment addition returned no subject".into(),
15650            })?;
15651        if required_str(subject, "id")? != issue.0 {
15652            return Err(SourceError::Malformed {
15653                message: "GitHub comment addition answered about another issue".into(),
15654            });
15655        }
15656        let added = data
15657            .pointer("/addComment/commentEdge/node")
15658            .filter(|value| !value.is_null())
15659            .ok_or_else(|| SourceError::Malformed {
15660                message: "GitHub comment addition returned no comment".into(),
15661            })?;
15662        let added = comment_from(added)?;
15663        self.remember_commented(&issue)?;
15664        Ok(Some(added))
15665    }
15666
15667    /// Every task commented on read together — [`DETAIL_BATCH`] to a
15668    /// [`graphql::ISSUE_DETAILS`] request — and each comment's hold decided on that read; then
15669    /// every comment it lets through added by aliased `addComment`s of one
15670    /// [`graphql::ADD_COMMENTS`] request per [`COMMENT_BATCH`]. GitHub answers a refusal of one
15671    /// alias at that alias's path, so it fails that comment alone.
15672    ///
15673    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15674    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15675    /// that batch is read by itself, the missing one answered missing. A batch of one is the
15676    /// comment alone, as [`Self::add_comment`] adds it.
15677    async fn add_comments(
15678        &self,
15679        comments: &[onetaskgraph_plugin_api::BatchComment],
15680    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<Comment>, SourceError>> {
15681        self.comments_batched(comments).await
15682    }
15683
15684    async fn edit_comment(
15685        &self,
15686        task: &NativeId,
15687        comment: &NativeId,
15688        body: &CommentBody,
15689    ) -> Result<Option<Comment>, SourceError> {
15690        let Some(issue) = self.commented_issue(task).await? else {
15691            return Ok(None);
15692        };
15693        let Some(held) = self.comment_on(&issue, comment).await? else {
15694            return Ok(None);
15695        };
15696        // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
15697        let body = CommentOrigin::kept(&held, body)?;
15698        let data = self
15699            .graphql(
15700                graphql::UPDATE_COMMENT,
15701                json!({"input":{"id":comment.0,"body":body.as_str()}}),
15702            )
15703            .await?;
15704        let edited = data
15705            .pointer("/updateIssueComment/issueComment")
15706            .filter(|value| !value.is_null())
15707            .ok_or_else(|| SourceError::Malformed {
15708                message: "GitHub comment update returned no comment".into(),
15709            })?;
15710        let edited = comment_from(edited)?;
15711        if edited.id != *comment {
15712            return Err(SourceError::Malformed {
15713                message: "GitHub comment update returned the wrong comment".into(),
15714            });
15715        }
15716        self.remember_commented(&issue)?;
15717        Ok(Some(edited))
15718    }
15719
15720    async fn delete_comment(
15721        &self,
15722        task: &NativeId,
15723        comment: &NativeId,
15724    ) -> Result<Option<NativeId>, SourceError> {
15725        let Some(issue) = self.commented_issue(task).await? else {
15726            return Ok(None);
15727        };
15728        if !self.comment_is_on(&issue, comment).await? {
15729            return Ok(None);
15730        }
15731        self.held_comments()?.remove(&issue);
15732        let data = self
15733            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
15734            .await?;
15735        // The payload says nothing about the comment it removed, so what is checked is that
15736        // GitHub answered the mutation at all rather than leaving it unanswered.
15737        data.get("deleteIssueComment")
15738            .filter(|value| !value.is_null())
15739            .ok_or_else(|| SourceError::Malformed {
15740                message: "GitHub comment deletion returned no payload".into(),
15741            })?;
15742        Ok(Some(comment.clone()))
15743    }
15744
15745    /// Every request this source has recorded, and what each of GitHub's two budgets was
15746    /// attributed — read off the same accounting the session report is rendered from, so
15747    /// the two cannot count one request two ways.
15748    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
15749        Ok(Some(self.ledger.snapshot().metering()))
15750    }
15751
15752    /// Drop every item, search answer and board read this source holds, so the next command
15753    /// reads the board as a person has since left it.
15754    ///
15755    /// Every one of those is held on the assumption that nothing but this source writes the
15756    /// board while a command runs, which stops being true the moment the command is over: a
15757    /// body a person edited would be overwritten from the record held here, and a card they
15758    /// moved would be read as still where this source left it. The board's own field
15759    /// definitions go too, because a person can add or delete a `Status` option and a write
15760    /// resolved against the held list would not re-read on a miss. What stays is what stays
15761    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
15762    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
15763    /// accounting [`metering`](TaskSource::metering) answers from.
15764    ///
15765    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
15766    /// refused, because clearing it is what puts it right.
15767    async fn end_command(&self) -> Result<(), SourceError> {
15768        fn clear<T: Default>(held: &Mutex<T>) {
15769            *held
15770                .lock()
15771                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
15772            held.clear_poison();
15773        }
15774        clear(&self.created);
15775        clear(&self.updated);
15776        clear(&self.commented);
15777        clear(&self.board_cache);
15778        clear(&self.search_cache);
15779        clear(&self.narrowed_cache);
15780        clear(&self.search_next);
15781        clear(&self.resolved_cache);
15782        clear(&self.read_ahead);
15783        clear(&self.pending_origins);
15784        clear(&self.member_classes);
15785        clear(&self.carried);
15786        clear(&self.held_cache);
15787        clear(&self.children_cache);
15788        clear(&self.task_children);
15789        clear(&self.written);
15790        clear(&self.fields_cache);
15791        clear(&self.held_comments);
15792        clear(&self.task_writes);
15793        clear(&self.written_tokens);
15794        self.expected.end_command();
15795        Ok(())
15796    }
15797}
15798
15799/// One member of a project as a classification-only read answers it: its classification and
15800/// the repositories its work changes.
15801type MemberClass = (Classification, Vec<Repository>);
15802
15803/// Each project's sub-issues as one command read them, keyed by the selector they were asked
15804/// for under, beside the project that selector named; see `children_cache`.
15805type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
15806
15807/// One issue comment as the contract carries it.
15808///
15809/// `author` is absent both when GitHub answers `null` for an account that no longer exists
15810/// and when it answers an actor with no login, because either way the source did not say who
15811/// wrote it — which is what an absent author means, rather than an author called nothing.
15812///
15813/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
15814/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
15815/// and is read back with the marker taken out and reported as its origin.
15816fn comment_from(value: &Value) -> Result<Comment, SourceError> {
15817    let actor = value.get("author").unwrap_or(&Value::Null);
15818    // A bot's login is spelled as GitHub's own pages spell it, `<login>[bot]`, so a reader —
15819    // and every copy of the comment — can tell it from a person's.
15820    let bot = actor.get("__typename").and_then(Value::as_str) == Some("Bot");
15821    Ok(Comment {
15822        origin: None,
15823        id: NativeId(required_str(value, "id")?.to_owned()),
15824        author: optional_str(actor, "login")?.map(|login| {
15825            if bot && !login.ends_with("[bot]") {
15826                format!("{login}[bot]")
15827            } else {
15828                login.to_owned()
15829            }
15830        }),
15831        created_at: optional_time(value, "createdAt")?,
15832        updated_at: optional_time(value, "updatedAt")?,
15833        body: required_str(value, "body")?.to_owned(),
15834        url: optional_str(value, "url")?.map(str::to_owned),
15835    }
15836    .unmarked())
15837}
15838
15839/// The page of comments one issue node carries, resumed from `after`.
15840fn comment_page(
15841    node: &Value,
15842    issue: &str,
15843    after: Option<&str>,
15844) -> Result<Page<Comment>, SourceError> {
15845    let connection = node
15846        .get("comments")
15847        .filter(|value| !value.is_null())
15848        .ok_or_else(|| SourceError::Malformed {
15849            message: format!("GitHub issue {issue} answered with no comments connection"),
15850        })?;
15851    let items = optional_nodes(Some(connection), "issue comments")?
15852        .into_iter()
15853        .flatten()
15854        .map(comment_from)
15855        .collect::<Result<Vec<_>, _>>()?;
15856    let next = next_cursor(connection)?;
15857    if let Some(next) = &next {
15858        validate_cursor_progress(after, &next.0)?;
15859    }
15860    Ok(Page { items, next })
15861}
15862
15863/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
15864/// end — `None` when it carried none, or a page with more past it.
15865fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
15866    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
15867        return Ok(None);
15868    };
15869    if next_cursor(connection)?.is_some() {
15870        return Ok(None);
15871    }
15872    Ok(Some(
15873        optional_nodes(Some(connection), "blocked-by issues")?
15874            .into_iter()
15875            .flatten()
15876            .cloned()
15877            .collect(),
15878    ))
15879}
15880
15881/// Where the recorded tail of a dependency walk resumes; see
15882/// [`GitHubProjectsSource::recorded_edges`].
15883const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
15884
15885/// The board text field this source keeps a copy's origin in.
15886///
15887/// Named after the key it holds, and held to that name by the guard below rather than by
15888/// a reader noticing.
15889const ORIGIN_FIELD: &str = "onetaskgraph.origin";
15890
15891/// The metadata key that field holds.
15892///
15893/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
15894/// constructs or interprets the qualified id it carries. This source names it only to
15895/// route it — a short, typed value belongs in a typed field rather than in the body slot
15896/// a caller's own prose shares.
15897///
15898/// Restated rather than imported, because no plugin crate may depend on the engine. What
15899/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
15900/// target in `check`: it reads the engine's own literal and fails naming the file and the
15901/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
15902/// that creates a second item every run instead of finding the one it wrote — and that is
15903/// too late to learn it.
15904const ORIGIN_KEY: &str = "onetaskgraph.origin";
15905
15906/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
15907///
15908/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
15909/// is derived from the far end, never written down on the near item — so only a forward
15910/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
15911/// it did not come from, and it is told so rather than answered with an empty page that
15912/// reads as a walk which ended.
15913fn recorded_offset(
15914    cursor: Option<&str>,
15915    direction: Direction,
15916) -> Result<Option<usize>, SourceError> {
15917    cursor
15918        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
15919        .map(|offset| {
15920            if direction != Direction::DependsOn {
15921                return Err(SourceError::Config {
15922                    message: format!(
15923                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
15924                         reverse dependency read never issues; resume it in the direction \
15925                         that reported it"
15926                    ),
15927                });
15928            }
15929            offset.parse().map_err(|_| SourceError::Config {
15930                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
15931            })
15932        })
15933        .transpose()
15934}
15935
15936/// What a dependency read's own answer says of a board draft: `None` when the item is not a
15937/// draft, or the answer did not carry its board item; `Some(None)` for a draft whose one board
15938/// item is not on this board, which this source does not hold; and `Some(Some(slot))`, the
15939/// draft's metadata, for one it does — its board item naming this board by its number and by
15940/// the id the same answer read of the board, as [`GitHubProjectsSource::draft_by_id`] decides.
15941fn draft_slot(
15942    data: &Value,
15943    id: &NativeId,
15944    project_number: u32,
15945) -> Result<Option<Option<BTreeMap<String, Value>>>, SourceError> {
15946    let Some(draft) = data
15947        .get("node")
15948        .filter(|node| node.get("__typename").and_then(Value::as_str) == Some("DraftIssue"))
15949    else {
15950        return Ok(None);
15951    };
15952    let Some(items) = data.pointer("/draft/projectV2Items") else {
15953        return Ok(None);
15954    };
15955    if required_str(draft, "id")? != id.0 {
15956        return Err(SourceError::Malformed {
15957            message: format!("GitHub answered a different draft for {}", id.0),
15958        });
15959    }
15960    let nodes = items
15961        .get("nodes")
15962        .and_then(Value::as_array)
15963        .ok_or_else(|| SourceError::Malformed {
15964            message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
15965        })?;
15966    let info = items
15967        .get("pageInfo")
15968        .ok_or_else(|| SourceError::Malformed {
15969            message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
15970        })?;
15971    if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
15972        return Err(SourceError::Malformed {
15973            message: format!(
15974                "GitHub draft {} reports more board items than the one GitHub links a draft to",
15975                id.0
15976            ),
15977        });
15978    }
15979    let board = data
15980        .pointer("/board/projectV2/id")
15981        .and_then(Value::as_str)
15982        .ok_or_else(|| SourceError::Malformed {
15983            message: format!(
15984                "GitHub answered draft {} without this board's own id beside it",
15985                id.0
15986            ),
15987        })?;
15988    let mut held = false;
15989    for node in nodes {
15990        let project = node.get("project").ok_or_else(|| SourceError::Malformed {
15991            message: format!("GitHub draft {} board item has no project", id.0),
15992        })?;
15993        let number = project
15994            .get("number")
15995            .and_then(Value::as_u64)
15996            .ok_or_else(|| SourceError::Malformed {
15997                message: format!(
15998                    "GitHub draft {} board item has no numeric project number",
15999                    id.0
16000                ),
16001            })?;
16002        let project_id = required_str(project, "id")?;
16003        held |= number == u64::from(project_number) && project_id == board;
16004    }
16005    if !held {
16006        return Ok(Some(None));
16007    }
16008    Ok(Some(Some(
16009        metadata_body(optional_str(draft, "body")?.map(str::to_owned))?.1,
16010    )))
16011}
16012
16013fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
16014    let mut page = offset_page(edges, offset, limit.max(1));
16015    page.next = page
16016        .next
16017        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
16018    page
16019}
16020
16021/// The kind of one issue reached through a dependency connection.
16022///
16023/// The same questions the board scan asks, over the fields the dependency document
16024/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
16025/// then anything with sub-issues or the marker is a project.
16026///
16027/// # Errors
16028///
16029/// A far end this board holds as a document is refused rather than reported. The two
16030/// answers that are not refusals would both be wrong: reporting it as a task names an id
16031/// no task read of this source can find, and reporting it as a project names one no
16032/// project read can. There is no third value to return — `ItemKind` has no document
16033/// variant, because nothing may point at a document — so the relationship itself is what
16034/// the person is told about.
16035fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
16036    let id = required_str(value, "id")?;
16037    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
16038        return Err(SourceError::Refused {
16039            message: format!(
16040                "GitHub issue {id} is a document of this board — its title begins \
16041                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
16042                 on by one; next: remove that issue's blocking relationship on this board"
16043            ),
16044        });
16045    }
16046    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
16047    if parent.is_some() {
16048        return Ok(ItemKind::Task);
16049    }
16050    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
16051    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
16052        message: format!("GitHub issue {id}: {message}"),
16053    })?;
16054    let sub_issues = sub_issue_total(value)?;
16055    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
16056        ItemKind::Project
16057    } else {
16058        ItemKind::Task
16059    })
16060}
16061
16062/// The `stateInput` that closes an issue for `reason`.
16063fn closed_input(reason: ClosedState) -> Value {
16064    json!({"value":"CLOSED","stateReason":reason.reason()})
16065}
16066
16067/// The `IssueStateUpdateInput` one status target asks for.
16068///
16069/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
16070/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
16071/// a currently-closed issue: without that the item would read back `Unknown` and a copy
16072/// would report a change forever. A document has no status at all, and asks for neither.
16073fn state_input(target: Option<&StatusTarget>) -> Value {
16074    match target {
16075        Some(StatusTarget::Terminal(_, reason)) => closed_input(*reason),
16076        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
16077        // A document has no status, so a write of one says nothing about the issue's open
16078        // or closed state rather than forcing it open: `stateInput` is what carries that
16079        // instruction, and an explicit null asks for no change to it.
16080        None => Value::Null,
16081    }
16082}
16083
16084/// The metadata one write stores in the item's body slot.
16085///
16086/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
16087/// rather than carried: the kind marker so an empty project stays readable, the
16088/// repository list only when it is not exactly the issue's own repository, and the far
16089/// ends no relationship here can name.
16090///
16091/// The copy origin is the one typed field that is also mirrored here, and only as a
16092/// mirror: it lands in the board's origin field as well, which stays the one every reader
16093/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
16094/// and catches up with a write in seconds rather than minutes — can find the item by it.
16095/// A reader of the release before this one drops the slot's copy and reads the field, so an
16096/// item written here still reads with exactly one origin there.
16097fn slot_metadata(
16098    incoming: &Incoming<'_>,
16099    own_repository: Option<&Repository>,
16100    fallback: &[DependencyEdge],
16101) -> BTreeMap<String, Value> {
16102    let mut metadata = incoming.metadata.clone();
16103    match metadata.remove(ORIGIN_KEY) {
16104        Some(Value::String(origin)) if !origin.is_empty() => {
16105            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
16106        }
16107        _ => {}
16108    }
16109    match incoming.written.kind() {
16110        BoardKind::Work(kind) => metadata.insert(
16111            ItemKind::METADATA_KEY.to_owned(),
16112            Value::String(kind.marker().to_owned()),
16113        ),
16114        // A document is told by its title, so it carries no kind marker: that key names
16115        // what a dependency endpoint points at, and nothing may point at a document.
16116        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
16117    };
16118    incoming.classification.record(&mut metadata);
16119    let derivable = own_repository
16120        .map(|own| incoming.repositories == [own.clone()])
16121        .unwrap_or(incoming.repositories.is_empty());
16122    if derivable {
16123        metadata.remove(Repository::METADATA_KEY);
16124    } else {
16125        metadata.insert(
16126            Repository::METADATA_KEY.to_owned(),
16127            Value::Array(
16128                incoming
16129                    .repositories
16130                    .iter()
16131                    .map(|repository| Value::String(repository.as_str().to_owned()))
16132                    .collect(),
16133            ),
16134        );
16135    }
16136    // The typed lists are what land, whatever the caller's own metadata held under their
16137    // keys: a key of either name travelling beside the field would otherwise be a second
16138    // answer to the same question, and the field is the one the contract names.
16139    for (key, entries) in [
16140        (TaskRef::DELIVERS_KEY, incoming.delivers),
16141        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
16142    ] {
16143        set_task_list(&mut metadata, key, entries);
16144    }
16145    record_edges(&mut metadata, fallback);
16146    metadata
16147}
16148
16149/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
16150/// one slot's metadata, or no such key when there are none.
16151fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
16152    if fallback.is_empty() {
16153        metadata.remove(DependencyEdge::RECORDED_KEY);
16154    } else {
16155        metadata.insert(
16156            DependencyEdge::RECORDED_KEY.to_owned(),
16157            Value::Array(
16158                fallback
16159                    .iter()
16160                    .map(|edge| {
16161                        serde_json::to_value(&edge.to).expect("an endpoint renders as JSON")
16162                    })
16163                    .collect(),
16164            ),
16165        );
16166    }
16167}
16168
16169/// Every label one item carries, from its content's own connection and nowhere else.
16170///
16171/// There is no second place to read one from: no document this source sends selects the
16172/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
16173/// cannot carry one at all. The module documentation records the three schema facts that
16174/// settle it.
16175fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
16176    optional_nodes(content.get("labels"), "content labels")?
16177        .into_iter()
16178        .flatten()
16179        .map(|v| {
16180            Ok(Label {
16181                id: NativeId(required_str(v, "id")?.to_owned()),
16182                name: required_str(v, "name")?.to_owned(),
16183                color: optional_str(v, "color")?.map(str::to_owned),
16184            })
16185        })
16186        .collect()
16187}
16188
16189/// Each board field value one item holds, as the change token's facets name it: the field's
16190/// name, with the single-select option's name or the text. A value of a type no read here
16191/// selects, and one naming no field, says nothing a person can rename and is left out.
16192fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
16193    field_values
16194        .iter()
16195        .filter_map(|value| {
16196            let field = value.pointer("/field/name")?.as_str()?;
16197            let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
16198            Some((field.to_owned(), held.to_owned()))
16199        })
16200        .collect()
16201}
16202
16203/// The digest of the facets of the issue `content`, whose board item's values are
16204/// `field_values` — or `None` when the response did not carry, whole, every member they are read
16205/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
16206/// label, and every field value.
16207///
16208/// What a write's own response spells its token from, by exactly the rules a read takes them by.
16209fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
16210    let ended = |connection: &Value| {
16211        connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
16212    };
16213    let labels = content.get("labels").filter(|labels| ended(labels))?;
16214    if !ended(field_values) {
16215        return None;
16216    }
16217    let parent = content.get("parent")?;
16218    let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
16219    let kind = BoardKind::of(
16220        content.get("title")?.as_str()?,
16221        !parent.is_null(),
16222        sub_issue_total(content).ok()?,
16223        ItemKind::from_metadata(&slot).ok()?,
16224    );
16225    let labels = self::labels(&json!({"labels": labels})).ok()?;
16226    let nodes = field_values.get("nodes")?.as_array()?;
16227    Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
16228}
16229
16230/// The definition of each board field one item's values are values of, in the shape a read
16231/// of the board's own `fields` gives one.
16232///
16233/// A value names its field through a fragment on that field's own type, so the type is
16234/// known from which kind of value it is: a single-select value's field is a
16235/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
16236/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
16237fn field_definitions(field_values: &[Value]) -> Vec<Value> {
16238    field_values
16239        .iter()
16240        .filter_map(|value| {
16241            let field = value.get("field")?.as_object()?;
16242            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
16243            let typename = if value.get("text").is_some() {
16244                "ProjectV2Field"
16245            } else if value.get("name").is_some() {
16246                "ProjectV2SingleSelectField"
16247            } else {
16248                return None;
16249            };
16250            let mut defined = field.clone();
16251            defined.insert("__typename".to_owned(), json!(typename));
16252            // Only a text field holds a text value, so the value says the field's type too.
16253            if typename == "ProjectV2Field" {
16254                defined.insert("dataType".to_owned(), json!("TEXT"));
16255            }
16256            Some(Value::Object(defined))
16257        })
16258        .collect()
16259}
16260
16261fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
16262    let Some(node) = field_values
16263        .iter()
16264        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
16265    else {
16266        return Ok(None);
16267    };
16268    Ok(optional_str(node, "text")?.map(str::to_owned))
16269}
16270
16271fn valid_github_owner(owner: &str) -> bool {
16272    !owner.is_empty()
16273        && owner.len() <= 39
16274        && !owner.starts_with('-')
16275        && !owner.ends_with('-')
16276        && !owner.contains("--")
16277        && owner
16278            .bytes()
16279            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
16280}
16281
16282/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
16283/// neither of the two names a path segment already means.
16284fn valid_github_repository_name(name: &str) -> bool {
16285    !name.is_empty()
16286        && name.len() <= 100
16287        && name != "."
16288        && name != ".."
16289        && name
16290            .bytes()
16291            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
16292}
16293
16294fn valid_environment_name(name: &str) -> bool {
16295    let mut bytes = name.bytes();
16296    bytes
16297        .next()
16298        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
16299        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
16300}
16301
16302/// How many sub-issues one issue has.
16303///
16304/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
16305/// absent or non-integer one is a response this source cannot read — and reading it as
16306/// zero would classify a project as a task, which is exactly the mistake the marker
16307/// exists to keep from happening quietly.
16308fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
16309    let summary = issue
16310        .get("subIssuesSummary")
16311        .ok_or_else(|| SourceError::Malformed {
16312            message: "GitHub issue is missing subIssuesSummary".into(),
16313        })?;
16314    summary
16315        .get("total")
16316        .and_then(Value::as_u64)
16317        .ok_or_else(|| SourceError::Malformed {
16318            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
16319        })
16320}
16321
16322/// One issue's own `number`.
16323///
16324/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
16325/// an issue in this module asks for it. So a read of one that comes back without it, or
16326/// with something that is not an unsigned integer, is a response this source cannot read —
16327/// absence here is **not** "this issue has no number". A draft is the content that has
16328/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
16329/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
16330fn issue_number(issue: &Value) -> Result<u64, SourceError> {
16331    issue
16332        .get("number")
16333        .and_then(Value::as_u64)
16334        .ok_or_else(|| SourceError::Malformed {
16335            message: "GitHub issue number is missing or is not an unsigned integer".into(),
16336        })
16337}
16338
16339/// The `number` a creating mutation answered with, and `None` when it answered without one;
16340/// why a missing one is tolerated is at the call in `create_and_file_issue`.
16341fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
16342    match created.get("number") {
16343        None | Some(Value::Null) => Ok(None),
16344        Some(value) => value
16345            .as_u64()
16346            .map(Some)
16347            .ok_or_else(|| SourceError::Malformed {
16348                message: "GitHub created issue number is not an unsigned integer".into(),
16349            }),
16350    }
16351}
16352
16353fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16354    value
16355        .get(field)
16356        .and_then(Value::as_str)
16357        .ok_or_else(|| SourceError::Malformed {
16358            message: format!("GitHub response is missing string field {field}"),
16359        })
16360}
16361
16362fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16363    let found = required_str(value, field)?;
16364    if found.trim().is_empty() {
16365        return Err(SourceError::Malformed {
16366            message: format!("GitHub response has blank string field {field}"),
16367        });
16368    }
16369    Ok(found)
16370}
16371
16372/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
16373/// needs one — Linear spells them too, in its own description field.
16374///
16375/// Restated rather than shared, because a plugin crate depends on the contract crate and
16376/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
16377/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
16378/// source round-trips its own writes perfectly well under its own spelling.
16379const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
16380const METADATA_CLOSE: &str = "\n-->";
16381
16382/// What the composer puts between a non-empty visible body and the slot, and the one thing
16383/// the parser takes off the visible body when it takes the slot off — exactly once, so every
16384/// other trailing byte of the body comes back as it was written.
16385// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] How a composer lays the slot after prose is this source's own; `docs/metadata.md` and its gate settle only the delimiters, and no other source declares a separator to reconcile against.
16386const METADATA_SEPARATOR: &str = "\n\n";
16387
16388/// The visible body and the metadata slot at the end of it.
16389///
16390/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
16391/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
16392/// own content and is left alone. The visible body is everything before the slot less the
16393/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
16394fn metadata_body(
16395    body: Option<String>,
16396) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
16397    let Some(body) = body else {
16398        return Ok((None, BTreeMap::new()));
16399    };
16400    let Some(slot) = slot_span(&body)? else {
16401        return Ok((Some(body), BTreeMap::new()));
16402    };
16403    let metadata =
16404        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
16405            SourceError::Malformed {
16406                message: format!(
16407                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
16408                ),
16409            }
16410        })?;
16411    let before = &body[..slot.start];
16412    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
16413    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
16414}
16415
16416/// Where the metadata slot sits in one body, as byte offsets into it.
16417struct SlotSpan {
16418    /// Where [`METADATA_OPEN`] begins.
16419    start: usize,
16420    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
16421    encoded_start: usize,
16422    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
16423    encoded_end: usize,
16424    /// Just past [`METADATA_CLOSE`].
16425    end: usize,
16426}
16427
16428/// The slot at the very end of `body`, or `None` when it has none.
16429///
16430/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
16431/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
16432/// slot.
16433fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
16434    let Some(start) = body.rfind(METADATA_OPEN) else {
16435        return Ok(None);
16436    };
16437    let encoded_start = start + METADATA_OPEN.len();
16438    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
16439        return Err(SourceError::Malformed {
16440            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
16441        });
16442    };
16443    let encoded_end = encoded_start + relative_end;
16444    let end = encoded_end + METADATA_CLOSE.len();
16445    if !body[end..].trim().is_empty() {
16446        return Ok(None);
16447    }
16448    Ok(Some(SlotSpan {
16449        start,
16450        encoded_start,
16451        encoded_end,
16452        end,
16453    }))
16454}
16455
16456/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
16457/// slot as it was.
16458///
16459/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
16460/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
16461/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
16462/// or alone in an empty body — and a body with no slot that is given no metadata is
16463/// returned as it is.
16464fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
16465    let encoded = if metadata.is_empty() {
16466        None
16467    } else {
16468        Some(
16469            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16470                message: error.to_string(),
16471            })?,
16472        )
16473    };
16474    Ok(match (slot_span(body)?, encoded) {
16475        (Some(slot), Some(encoded)) => format!(
16476            "{}{encoded}{}",
16477            &body[..slot.encoded_start],
16478            &body[slot.encoded_end..]
16479        ),
16480        (Some(slot), None) => {
16481            let before = &body[..slot.start];
16482            format!(
16483                "{}{}",
16484                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
16485                &body[slot.end..]
16486            )
16487        }
16488        (None, None) => body.to_owned(),
16489        (None, Some(encoded)) if body.is_empty() => {
16490            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16491        }
16492        (None, Some(encoded)) => {
16493            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16494        }
16495    })
16496}
16497
16498/// `body` with everything before its metadata slot replaced by `content`, and the slot
16499/// itself kept byte for byte.
16500///
16501/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
16502/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
16503/// `content` is empty — so a read of the result reports `content` as the visible body and
16504/// the slot's metadata exactly as it was.
16505fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
16506    let Some(slot) = slot_span(body)? else {
16507        return Ok(content.to_owned());
16508    };
16509    let kept = &body[slot.start..];
16510    Ok(if content.is_empty() {
16511        kept.to_owned()
16512    } else {
16513        format!("{content}{METADATA_SEPARATOR}{kept}")
16514    })
16515}
16516
16517/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
16518fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
16519    if entries.is_empty() {
16520        metadata.remove(key);
16521    } else {
16522        metadata.insert(
16523            key.to_owned(),
16524            Value::Array(
16525                entries
16526                    .iter()
16527                    .map(|entry| Value::String(entry.as_str().to_owned()))
16528                    .collect(),
16529            ),
16530        );
16531    }
16532}
16533
16534fn compose_body(
16535    content: Option<&str>,
16536    metadata: &BTreeMap<String, Value>,
16537) -> Result<Option<String>, SourceError> {
16538    let visible = content.unwrap_or_default();
16539    if metadata.is_empty() {
16540        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
16541    }
16542    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16543        message: error.to_string(),
16544    })?;
16545    Ok(Some(if visible.is_empty() {
16546        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16547    } else {
16548        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16549    }))
16550}
16551
16552fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
16553    value
16554        .get(field)
16555        .and_then(Value::as_bool)
16556        .ok_or_else(|| SourceError::Malformed {
16557            message: format!("GitHub response is missing boolean field {field}"),
16558        })
16559}
16560fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
16561    match value.get(field) {
16562        None | Some(Value::Null) => Ok(None),
16563        Some(value) => value
16564            .as_str()
16565            .map(Some)
16566            .ok_or_else(|| SourceError::Malformed {
16567                message: format!("GitHub response field {field} is not a string or null"),
16568            }),
16569    }
16570}
16571fn optional_nodes<'a>(
16572    connection: Option<&'a Value>,
16573    name: &str,
16574) -> Result<Option<&'a Vec<Value>>, SourceError> {
16575    match connection {
16576        None | Some(Value::Null) => Ok(None),
16577        Some(value) => value
16578            .get("nodes")
16579            .and_then(Value::as_array)
16580            .map(Some)
16581            .ok_or_else(|| SourceError::Malformed {
16582                message: format!("GitHub {name}.nodes is not an array"),
16583            }),
16584    }
16585}
16586fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
16587    let page_info = connection
16588        .get("pageInfo")
16589        .ok_or_else(|| SourceError::Malformed {
16590            message: format!("GitHub {name} has no pageInfo"),
16591        })?;
16592    if required_bool(page_info, "hasNextPage")? {
16593        return Err(SourceError::Malformed {
16594            message: format!(
16595                "GitHub {name} exceeds the supported nested connection size of {size}"
16596            ),
16597        });
16598    }
16599    Ok(())
16600}
16601fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
16602    optional_str(value, field)?
16603        .map(|timestamp| {
16604            timestamp.parse().map_err(|error| SourceError::Malformed {
16605                message: format!("GitHub response field {field} is not a timestamp: {error}"),
16606            })
16607        })
16608        .transpose()
16609}
16610fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
16611    if page.limit == 0 {
16612        Err(SourceError::Config {
16613            message: "page limit must be at least 1".into(),
16614        })
16615    } else {
16616        Ok(())
16617    }
16618}
16619fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
16620    let page = connection
16621        .get("pageInfo")
16622        .filter(|value| value.is_object())
16623        .ok_or_else(|| SourceError::Malformed {
16624            message: "GitHub connection is missing pageInfo".into(),
16625        })?;
16626    if required_bool(page, "hasNextPage")? {
16627        let cursor = required_str(page, "endCursor")?;
16628        validate_cursor_progress(None, cursor)?;
16629        Ok(Some(Cursor(cursor.into())))
16630    } else {
16631        Ok(None)
16632    }
16633}
16634fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
16635    if next.is_empty() || previous == Some(next) {
16636        Err(SourceError::Malformed {
16637            message: "GitHub pagination cursor is empty or did not advance".into(),
16638        })
16639    } else {
16640        Ok(())
16641    }
16642}
16643/// The version of this plugin's opaque narrowing-search cursor.
16644pub const SEARCH_CURSOR_VERSION: u32 = 4;
16645
16646#[derive(Serialize, Deserialize)]
16647#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
16648enum SearchConnection {
16649    Initial {},
16650    Continuing { after: Cursor },
16651    Exhausted {},
16652}
16653impl SearchConnection {
16654    fn after(&self) -> Option<&str> {
16655        match self {
16656            Self::Continuing { after } => Some(&after.0),
16657            _ => None,
16658        }
16659    }
16660    fn exhausted(&self) -> bool {
16661        matches!(self, Self::Exhausted { .. })
16662    }
16663    /// Whether a cursor naming this position, `offset` rows into its page, is one this
16664    /// plugin could have handed out: a page is resumed only part of the way through it — an
16665    /// offset of a whole page or more would skip rows nobody was given — an initial page
16666    /// only once some of it was handed out, and an exhausted connection has no page to be
16667    /// part of the way through.
16668    fn valid_resume(&self, offset: usize) -> bool {
16669        let within = offset < SEARCH_PAGE_SIZE as usize;
16670        match self {
16671            Self::Initial { .. } => offset > 0 && within,
16672            Self::Continuing { after } => !after.0.is_empty() && within,
16673            Self::Exhausted { .. } => offset == 0,
16674        }
16675    }
16676}
16677
16678/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
16679#[derive(Serialize, Deserialize)]
16680#[serde(deny_unknown_fields)]
16681struct SearchPosition {
16682    version: u32,
16683    connection: SearchConnection,
16684    /// How many rows of the page `connection` starts were already handed out.
16685    #[serde(default, skip_serializing_if = "is_zero")]
16686    offset: usize,
16687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16688    seen: Vec<NativeId>,
16689    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16690    own: Vec<NativeId>,
16691}
16692impl Default for SearchPosition {
16693    fn default() -> Self {
16694        Self {
16695            version: SEARCH_CURSOR_VERSION,
16696            connection: SearchConnection::Initial {},
16697            offset: 0,
16698            seen: Vec::new(),
16699            own: Vec::new(),
16700        }
16701    }
16702}
16703
16704fn is_zero(offset: &usize) -> bool {
16705    *offset == 0
16706}
16707
16708fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
16709    cursor.map_or(Ok(0), |c| {
16710        c.0.parse().map_err(|_| SourceError::Config {
16711            message: "page cursor is invalid".into(),
16712        })
16713    })
16714}
16715fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
16716    if offset > items.len() {
16717        return Page::last(vec![]);
16718    }
16719    let tail = items.split_off(offset);
16720    let mut selected = tail;
16721    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
16722    selected.truncate(limit);
16723    Page {
16724        items: selected,
16725        next,
16726    }
16727}
16728
16729impl GitHubProjectsSource {
16730    /// The body [`Self::write_item`] would compose for `incoming`, sized in the characters
16731    /// [`BODY_LIMIT`] counts, without a request: its slot as `slot_metadata` writes it, every
16732    /// asset reference rewritten to its recorded URL or — for one not uploaded yet — to an
16733    /// address as long as GitHub's, and the heading markup this board's bodies carry.
16734    ///
16735    /// What the write reads rather than knows is taken as it most often lands: the issue in
16736    /// the one repository the item names, else the configured one, and every edge on the
16737    /// board's own relationships rather than in the slot.
16738    fn body_size(&self, incoming: &Incoming<'_>) -> onetaskgraph_plugin_api::BodySize {
16739        let own = match incoming.repositories {
16740            [named] => Some(named.clone()),
16741            _ => self
16742                .repository
16743                .as_ref()
16744                .and_then(|target| Repository::try_from(target.origin()).ok()),
16745        };
16746        let mut slot = slot_metadata(incoming, own.as_ref(), &[]);
16747        let content = match incoming.assets {
16748            Some(assets) => {
16749                let mut uploads = onetaskgraph_plugin_api::AssetUploads::default();
16750                for asset in &assets.assets {
16751                    let url = assets
16752                        .recorded_assets
16753                        .as_ref()
16754                        .and_then(|recorded| recorded.reusable(&asset.name, &asset.sha256))
16755                        .unwrap_or(PREDICTED_ATTACHMENT_URL)
16756                        .to_owned();
16757                    uploads.0.insert(
16758                        asset.name.clone(),
16759                        onetaskgraph_plugin_api::AssetUpload {
16760                            sha256: asset.sha256.clone(),
16761                            url,
16762                        },
16763                    );
16764                }
16765                incoming.content.map(|content| {
16766                    onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads)
16767                })
16768            }
16769            None => incoming.content.map(str::to_owned),
16770        };
16771        let marked = content.as_deref().map(|content| self.marked(content));
16772        let size = compose_body(marked.as_deref(), &slot)
16773            .ok()
16774            .flatten()
16775            .map_or(0, |body| body.chars().count());
16776        onetaskgraph_plugin_api::BodySize {
16777            size: u64::try_from(size).unwrap_or(u64::MAX),
16778            limit: Some(BODY_LIMIT),
16779        }
16780    }
16781
16782    async fn write_task_assets(
16783        &self,
16784        write: &ItemWrite<Task>,
16785        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
16786    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
16787        self.write_item(
16788            &self.task_incoming(write, assets)?,
16789            write.target.as_ref(),
16790            &write.depends_on,
16791        )
16792        .await
16793    }
16794
16795    /// One task write as [`Self::write_item`] takes it, once what it delivers and its priority
16796    /// are known to be ones this source can write.
16797    fn task_incoming<'a>(
16798        &self,
16799        write: &'a ItemWrite<Task>,
16800        assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
16801    ) -> Result<Incoming<'a>, SourceError> {
16802        let near = write.target.as_ref().unwrap_or(&write.item.id);
16803        for (key, entries) in [
16804            (TaskRef::DELIVERS_KEY, &write.item.delivers),
16805            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
16806        ] {
16807            TaskRef::listed(key, near, Some(&self.name), entries.clone())
16808                .map_err(|message| SourceError::Refused { message })?;
16809        }
16810        if self.priorities.is_none() && write.item.priority != Priority::None {
16811            return Err(self.holds_no_priority());
16812        }
16813        Ok(Incoming {
16814            written: Written::Work(ItemKind::Task, &write.item.status),
16815            title: &write.item.title,
16816            content: write.item.content.as_deref(),
16817            assets,
16818            labels: &write.item.labels,
16819            metadata: &write.item.metadata,
16820            repositories: &write.item.repositories,
16821            classification: write.item.classification,
16822            parent: write.item.project.as_ref(),
16823            delivers: &write.item.delivers,
16824            delivered_by: &write.item.delivered_by,
16825            priority: self.priorities.as_ref().map(|_| write.item.priority),
16826        })
16827    }
16828    async fn write_document_assets(
16829        &self,
16830        write: &ItemWrite<Document>,
16831        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
16832    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
16833        // A document takes part in no dependency graph, so there is no far end to write
16834        // natively and none to record: a caller naming one is told so rather than having it
16835        // stored under the reserved key, where a later read would report an edge the
16836        // contract says cannot exist.
16837        if !write.depends_on.is_empty() {
16838            return Err(SourceError::Refused {
16839                message: format!(
16840                    "this write names {} dependencies for a document, and a document takes \
16841                     part in no dependency graph; next: put the dependency on the task or \
16842                     project the document is about",
16843                    write.depends_on.len()
16844                ),
16845            });
16846        }
16847        self.write_item(
16848            &Incoming {
16849                written: Written::Document,
16850                title: &write.item.title,
16851                content: write.item.content.as_deref(),
16852                assets,
16853                labels: &write.item.labels,
16854                metadata: &write.item.metadata,
16855                repositories: &write.item.repositories,
16856                classification: write.item.classification,
16857                parent: write.item.project.as_ref(),
16858                delivers: &[],
16859                delivered_by: &[],
16860                priority: None,
16861            },
16862            write.target.as_ref(),
16863            &[],
16864        )
16865        .await
16866    }
16867}
16868
16869/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
16870/// itself, for the two things no journey can observe.
16871///
16872/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
16873/// with and without the call, that a settlement, a board listing and a metadata search each
16874/// read afresh after it — the resolved records, the written-item overlay, the board and its
16875/// search, and the narrowed searches. What they cannot reach is the held field definitions,
16876/// because a status write naming an option a person deleted is refused the same whether or
16877/// not the list is held, and a poisoned lock, because nothing outside the source can panic
16878/// while one of its locks is held. So these assert those directly, and every other holder
16879/// beside them so a holder added later without a clear in the call fails here.
16880#[cfg(test)]
16881mod end_command_tests {
16882    use super::*;
16883
16884    struct Token;
16885
16886    impl SecretResolver for Token {
16887        fn get(&self, var: &str) -> Option<SecretString> {
16888            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
16889        }
16890    }
16891
16892    fn source() -> GitHubProjectsSource {
16893        let config = serde_json::from_value(json!({
16894            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
16895            // Nothing here is sent: the source is only built and its state inspected.
16896            "endpoint": "http://127.0.0.1:9/graphql",
16897        }))
16898        .expect("a usable configuration");
16899        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
16900            .expect("the source builds")
16901    }
16902
16903    /// One issue as a board read answers it.
16904    fn resolved(source: &GitHubProjectsSource) -> Resolved {
16905        source
16906            .resolve(&json!({
16907                "id": "ITEM-1",
16908                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
16909                            "body": "what a person may since have edited", "state": "OPEN",
16910                            "stateReason": null, "url": null, "number": 1,
16911                            "subIssuesSummary": {"total": 0},
16912                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
16913                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
16914            }))
16915            .expect("the item reads")
16916            .expect("an issue")
16917    }
16918
16919    /// Hold something in every holder the call clears, and the repository id it keeps.
16920    fn fill(source: &GitHubProjectsSource) {
16921        let item = resolved(source);
16922        source.created.lock().unwrap().push(item.clone());
16923        source.updated.lock().unwrap().push(item.clone());
16924        *source.board_cache.lock().unwrap() = Some(Board {
16925            id: "PVT-board".into(),
16926            fields: json!({"nodes": []}),
16927            items: vec![item.clone()],
16928        });
16929        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
16930        source
16931            .narrowed_cache
16932            .lock()
16933            .unwrap()
16934            .insert("status:todo".into(), vec![item.clone()]);
16935        source.children_cache.lock().unwrap().insert(
16936            NativeId("P-1".into()),
16937            (NativeId("P-1".into()), vec![item.clone()]),
16938        );
16939        source
16940            .search_next
16941            .lock()
16942            .unwrap()
16943            .insert("status:todo".into(), Some("cursor".into()));
16944        source
16945            .resolved_cache
16946            .lock()
16947            .unwrap()
16948            .insert(item.id.clone(), item);
16949        *source.fields_cache.lock().unwrap() = Some(BoardFields {
16950            id: BoardId::parse("PVT-board").unwrap(),
16951            fields: json!({"nodes": []}),
16952        });
16953        source
16954            .repository_cache
16955            .lock()
16956            .unwrap()
16957            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
16958    }
16959
16960    fn assert_dropped(source: &GitHubProjectsSource) {
16961        assert!(source.created().unwrap().is_empty(), "created");
16962        assert!(source.updated().unwrap().is_empty(), "updated");
16963        assert!(source.board_cache().unwrap().is_none(), "board");
16964        assert!(source.search_cache.lock().unwrap().is_none(), "search");
16965        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
16966        assert!(
16967            source.children_cache.lock().unwrap().is_empty(),
16968            "project children"
16969        );
16970        assert!(
16971            source.search_next.lock().unwrap().is_empty(),
16972            "search paging"
16973        );
16974        assert!(
16975            source.resolved_cache().unwrap().is_empty(),
16976            "resolved records"
16977        );
16978        assert!(source.fields_cache().unwrap().is_none(), "board fields");
16979        assert_eq!(
16980            source.repository_cache().unwrap().len(),
16981            1,
16982            "a repository's node id stays valid and is kept"
16983        );
16984    }
16985
16986    fn end(source: &GitHubProjectsSource) {
16987        tokio::runtime::Builder::new_current_thread()
16988            .build()
16989            .unwrap()
16990            .block_on(source.end_command())
16991            .expect("the command ends");
16992    }
16993
16994    #[test]
16995    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
16996        let source = source();
16997        fill(&source);
16998        end(&source);
16999        assert_dropped(&source);
17000    }
17001
17002    #[test]
17003    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
17004        fn poison<T: Send>(held: &Mutex<T>) {
17005            std::thread::scope(|scope| {
17006                let _ = scope
17007                    .spawn(|| {
17008                        let _guard = held.lock().unwrap();
17009                        panic!("a failure while the lock is held");
17010                    })
17011                    .join();
17012            });
17013            assert!(held.is_poisoned());
17014        }
17015        let source = source();
17016        fill(&source);
17017        poison(&source.created);
17018        poison(&source.updated);
17019        poison(&source.board_cache);
17020        poison(&source.search_cache);
17021        poison(&source.narrowed_cache);
17022        poison(&source.search_next);
17023        poison(&source.resolved_cache);
17024        poison(&source.fields_cache);
17025        assert!(
17026            source.resolved_cache().is_err(),
17027            "a poisoned lock is refused before the call"
17028        );
17029        end(&source);
17030        assert_dropped(&source);
17031    }
17032}
17033
17034#[cfg(test)]
17035mod unprojectable_fields_tests {
17036    use super::*;
17037
17038    /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
17039    fn documented() -> std::collections::BTreeSet<String> {
17040        let document = std::fs::read_to_string(
17041            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
17042        )
17043        .expect("docs/metadata.md is readable");
17044        let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
17045        let lead = "a `field` equal, ignoring case, to ";
17046        let start = document.find(lead).expect("the refused-field sentence") + lead.len();
17047        let sentence = &document[start..];
17048        let sentence = &sentence[..sentence
17049            .find("; and two entries naming one field")
17050            .expect("the refused-field sentence ends at the duplicate rule")];
17051        let (own, github) = sentence
17052            .split_once(" or a field GitHub owns on every board (")
17053            .expect("the sentence names GitHub's own fields");
17054        own.split('`')
17055            .skip(1)
17056            .step_by(2)
17057            .chain(
17058                github
17059                    .strip_suffix(')')
17060                    .expect("GitHub's own fields close the sentence")
17061                    .split(", "),
17062            )
17063            .map(str::to_owned)
17064            .collect()
17065    }
17066
17067    #[test]
17068    fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
17069        let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
17070            .iter()
17071            .map(|&name| name.to_owned())
17072            .collect();
17073        assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
17074        let instance = SourceName::new("work").unwrap();
17075        for name in UNPROJECTABLE_FIELDS {
17076            for spelled in [name.to_owned(), name.to_uppercase()] {
17077                let entry = MetadataFieldConfig {
17078                    field: spelled.clone(),
17079                    key: "team.machine".to_owned(),
17080                    path: Vec::new(),
17081                };
17082                let refused = MetadataField::resolve(vec![entry], &instance)
17083                    .expect_err("an unprojectable field is refused");
17084                assert!(
17085                    refused.to_string().contains(&format!("{name:?}")),
17086                    "{spelled}: {refused}"
17087                );
17088            }
17089        }
17090    }
17091}