Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83//! **A caller's value can also be projected onto a board text field**, so the board can filter
84//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
85//! metadata key and a path inside its value. The slot stays the value's one home — every read
86//! reports metadata from it alone — and the field follows it on every write of an item, a
87//! create, an update and a copy alike: a string is written, and not sent again while the field
88//! holds it — an empty string included, which is written as itself; nothing there or `null`
89//! clears a field holding a value; any other
90//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
91//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
92//! own moves its field first and the body last, as every write of an existing item does. A
93//! board without the field, or with a non-text field of that name, is refused before any
94//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
95//! a text field and never changes a field that is there.
96//!
97// 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.
98//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
99//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
100//! option of the board's one `Status` field for a task and for a project: a bare name is
101//! the option for both kinds, `null` disables the category for both, and `{task, project}`
102//! names it per kind. A category the mapping does not mention keeps its shipped default for
103//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
104//! gets the shipped default for the kind it leaves out. Two categories one kind would read
105//! back from one option are refused as the configuration is read, ignoring case, while one
106//! option may stand for different categories of the two kinds. Writes go by the kind of the
107//! item written: a status that kind has no option for, or whose option the board lacks, is
108//! refused before any mutation, naming the source, the kind, the category and the key
109//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
110//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
111//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
112//! before selecting its option. Reads give a closed issue's reason precedence over its
113//! option, while an open issue's option decides its category through its own kind's
114//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
115//! The guarded [`GitHubProjectsSource::status_options`] and
116//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
117//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
118//! existing option id and verify the field and item assignments immediately afterwards.
119//! They ask for both kinds' options, counting a terminal category's mapped option as
120//! configured because a terminal write refuses without it. No ordinary source read or
121//! write calls that mutation, whose
122//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
123//! and a mistake destroys every item's status. A status this board cannot represent is a
124//! refusal naming the status and the instance instead.
125//!
126//! `unknown` has no shipped option because this source cannot preserve an open-ended
127//! status word: it writes an existing board option and never
128//! creates an option. An operator may map `unknown` to one existing option, in which case
129//! every unknown word lands on that option and reads back as `unknown` under the option's
130//! name. This differs from `local-md`, which writes and reads the original word itself.
131//!
132//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
133//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
134//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
135//! finished tasks were only moved to a "Done" column would read 0% complete forever.
136// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
137//!
138//! # What this source declares, field by field
139//!
140//! One verdict per field of [`Capabilities`], and what `Native` means when this source
141//! says it. *Proven* means a shared journey drives it against the real
142//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
143//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
144//! [`capabilities`](TaskSource::capabilities) from parting.
145//!
146//! | Field | Verdict |
147//! | --- | --- |
148//! | `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. |
149//! | `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. |
150//! | `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. |
151//! | `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. |
152//! | `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. |
153//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
154//! | `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. |
155//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
156//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
157//! | `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. |
158//! | `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. |
159//! | `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. |
160//! | `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. |
161//! | `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. |
162//! | `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. |
163//! | `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. |
164//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
165//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
166//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
167//!
168//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
169//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
170//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
171//! and the raw bytes as the request body. The numeric repository id is read at most once
172//! per repository per source instance. A loopback API endpoint also selects the loopback
173//! uploads host; production needs no assets repository, commits or extra configuration.
174//! Every returned JSON `url` is read with the same token before a body is written: only
175//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
176//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
177//! asset and HTTP status and says the token type may not be accepted by the upload
178//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
179//! fine-grained PATs or OAuth tokens.
180//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
181//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
182//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
183//! and verified afresh, in the repository the issue already lives in on an update.
184//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
185//! Uploads take the same mutation spacing as content writes. Both uploads and verification
186//! reads wait out classified rate limits within the configured per-call retry budget,
187//! on the supplied clock; verifying reads take no mutation slot.
188//!
189//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
190//! source has documents and that its tasks have comments, both of which hold — and the three
191//! facts behind the uniform `Native` on the
192//! predicates beside it are recorded below rather than re-derived, because a reader who
193//! takes `Native` to mean *the remote service filters* will read that uniformity as a
194//! lie.
195//!
196//! First, the plugin contract defines `Support::Native` as *the source applies this
197//! predicate itself*, and says nothing about where it applies it. What the declaration
198//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
199//! — so that the engine may push it down and apply nothing of its own.
200//!
201//! Second, this source can keep that promise for every predicate at no additional API
202//! cost, because whichever of the reads below answers a query has already read every
203//! candidate that query will return before it filters anything. Filtering those items is
204//! in-process work over data already in hand.
205//!
206//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
207//! applied in process over what that question returned. A project filter has a relationship — a
208//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
209//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
210//! a search for metadata values, is the board-scoped issue search carrying the text and each
211//! value as quoted phrases; an origin is the board's own field filter over its origin field
212//! beside the same search for the id. **The text search narrows, and that is this source's
213//! declared semantics:** GitHub matches whole words where the substring rule this source and
214//! the local Markdown source confirm with would match inside one, so an item holding the text
215//! only inside a longer word is never a candidate. Every item returned does contain the text.
216//! A project query's text, and a document query's scoped to no project, is that same search
217//! and narrows on the same terms, its candidates confirmed by their kind as well.
218//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
219//! so those three are applied in process over the candidates, and a query carrying none of
220//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
221//! engine compensate for work this source has already done, and declaring `projects` native
222//! while ignoring the filter (which this source once did) silently returns another project's
223//! tasks, because the engine trusts the declaration and applies nothing locally.
224//!
225//! # The three ways this source reaches an item, and what each costs
226//!
227//! A board read is charged for what its *nested* connections could return rather than for
228//! what was asked, so one whole-board read costs the same whether the question was about
229//! one project or about all of them. That is why a question about one project is never
230//! answered by reading the board:
231//!
232//! | The question | What is sent | What it costs |
233//! | --- | --- | --- |
234//! | 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 |
235//! | 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 |
236//! | 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 |
237//! | 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 |
238//! | 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 |
239//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
240//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
241//! | 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 |
242//! | 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 |
243//! | 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 |
244//! | 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 |
245//! | 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 |
246//! | 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 |
247//!
248//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
249//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
250//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
251//! equal to declared points equal to the row. They include the origin lookup and the
252//! field/repository discovery a create needs. A bound re-copy changes status, priority,
253//! content and metadata; comment recount means a subsequent detail read. Each request here
254//! costs one declared point. A membership beyond the embedded page can additionally require
255//! the one-point membership recovery described above. A bound re-copy of a task filed under a
256//! project adds one read, the engine confirming that project's link by its own id once per
257//! command; and the same-source far ends a write newly names — those that do not already block
258//! the item, whose own read answered for them — are read together by their own ids,
259//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
260//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
261//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
262//!
263//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
264//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
265//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
266//! holds both halves.
267//!
268//! **An existing item is written body last.** A bound re-copy and a `task update` send its
269//! board fields first — the `Status` option and the `Priority` together, in one request — then
270//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
271//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
272//! undoing an earlier field when a later one fails, so that order is what makes a write
273//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
274//! the one piece of metadata written before the body, an origin a copy re-points, is put back
275//! when a later write is refused — and when putting it back is refused too, the write's own
276//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
277//! of those writes in turn, whole and as one aliased field failing after the one before it.
278//!
279//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
280//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
281//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
282//!
283//! - **A board is accepted at creation but its item is not answered, so a create still files
284//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
285//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
286//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
287//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
288//!   against a real board on 2026-10-01 with a create sending the board there and reading the
289//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
290//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
291//!   refused "Content already exists in this project" — GitHub had filed the issue after
292//!   answering, and refuses a second filing rather than answering with the item it holds. A
293//!   create therefore sends no `projectV2Ids` and files the issue with
294//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
295//!   real is the read before it: the board's fields and the repository's id together, in
296//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
297//! - **A comment still reads its target first, so a comment is 2 requests.**
298//!   `AddCommentInput.subjectId: ID!` is declared there with
299//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
300//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
301//!   project's issue, a document's issue, an issue on no board of this source and a pull
302//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
303//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
304//!   refuses those by name.
305//!
306//! | Verb | Requests / points | Documents |
307//! | --- | --- | --- |
308//! | 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 |
309//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, CREATED_BOARD_ITEMS, WRITE_FIELD_VALUES: the new copy with no origin to ask about |
310//! | 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 |
311//! | 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 |
312//! | 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 |
313//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
314//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
315//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
316//! | 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 |
317//! | recount | 1 | ISSUE_DETAIL |
318//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
319//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
320//! | content | 2 | ISSUE, UPDATE_ISSUE |
321//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
322//! | 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 |
323//! | 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 |
324//! | record only | 1 | ISSUE |
325//!
326//! <!-- github-search-paging:start -->
327//! Board-scoped text, metadata, project-name and comment-activity searches send every
328//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
329//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
330//! needs rows. A page is never resized to the rows still needed: GitHub orders one
331//! search differently at different page sizes, so one fixed size makes a paged walk
332//! send exactly the requests one whole read sends, and the answer's order is the order
333//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
334//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
335//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
336//! returned and fetched pages: a limit is sliced from the pages it needs, and local
337//! confirmation can require more candidates than matching rows. Walking all pages
338//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
339//! cursor and how far into that page the last answer stopped, and resumes in the same
340//! process or a new one, without duplicates or gaps. It carries no rows: one process
341//! sends each page's search once, and a new process re-reads only the page it resumes
342//! in, then sends a further page once, never as a re-read, only when its limit still
343//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
344//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
345//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
346//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
347//! not required to include the original process's writes still omitted by the index.
348//! <!-- github-search-paging:end -->
349//!
350//! The board half of an issue — its board item's id, its `Status` option and this
351//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
352//! an item reached any of those ways resolves through the same
353//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
354//! same status, the same labels and the same qualified id. That connection comes back a
355//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
356//! on the page in hand and — only if that page reports more of the connection — in the
357//! last row's read of that one issue's memberships, resumed from the page's own cursor and
358//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
359//! report, which is what keeps an id naming another repository's issue from being answered
360//! as an item of this board; and because the page is where the search starts rather than
361//! where it ends, that answer is one about a connection read to exhaustion and never about
362//! an unread page. Nothing costs the extra read but an issue on more boards than a page
363//! holds: an issue this board really does not hold reports no next page, so its
364//! memberships are already exhausted where they arrived.
365//!
366//! **No document here selects the board's own `Labels` field, and nothing is lost by
367//! that.** An item's labels are read from its content alone, wherever that content is
368//! reached: the three documents above select `Issue.labels` on the fragment, and
369//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
370//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
371//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
372//! create one, and `ProjectV2FieldValue` — the whole of what
373//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
374//! it from the content, for every content type it exists on, and there is nothing it can
375//! hold that the content does not already say: for an `Issue` it *is* that issue's own
376//! labels, so selecting it beside them unions a set with itself.
377//!
378//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
379//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
380//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
381//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
382//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
383//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
384//! label set, and that set is the fixture issue's own, by
385//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
386//! item whose content is a draft reports an empty set, by
387//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
388//! selection is held over [`graphql::DOCUMENTS`] by
389//! `no_document_selects_the_boards_own_labels_field`.
390//!
391//! The whole-board row is still the board's own item connection, and deliberately: a
392//! **draft** board item is not an issue, so no search can list one, and the reads that have
393//! to answer for the whole board are the ones whose cost is the board's size anyway.
394//!
395//! **A question about one item this source already names by id never lists the board.**
396//! Whether that item is on this board, and what its board fields are, is answered by reading
397//! that item — its own `Issue.projectItems`, walked to exhaustion by
398//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
399//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
400//! holds. That covers a write's destination, the project a new item is filed under, a
401//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
402//! and the delete that takes back an item a copy made. What such a write needs of the board
403//! and the item does not carry — the board's id, the `Status` and origin field definitions —
404//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
405//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
406//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
407//! minutes. Scanning this host's 842-item board has refused a document copy and an update
408//! even though the items' own reads named that board. A scan there gives the wrong answer
409//! as well as paying for every page. So a `board.items` lookup does not belong on any of
410//! those paths.
411//!
412//! **What a read may return is capped too, and that cap is on the document rather than on
413//! the board.** GitHub limits the number of nodes **one query may return** to
414//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
415//! an error naming the connection the count crossed at, not a slow or a partial result.
416//! Every board this source reads is refused the same way, so no board is too big for these
417//! documents and none is small enough to save one that is over.
418//!
419//! The count is arithmetic over the document's own text: each connection contributes the
420//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
421//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
422//! restate them — `github-graphql-node-count` implements them, and
423//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
424//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
425//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
426//! same text on every run and fails naming any that reaches the limit — so a connection
427//! added to a shared fragment is caught there rather than by GitHub.
428//!
429//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
430//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
431//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
432//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
433//! effectively squared there, which is why it is the one the limit is most sensitive to.
434//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
435//! page of memberships misses is recovered by one further read rather than refused, so it
436//! buys a bound every read pays for at the price of a request only a multi-board issue
437//! pays.
438//!
439//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
440//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
441//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
442//! `cost` is rate-limit points, metered per hour across everything one credential does; it
443//! is what the two limiters [`Limiter`] tells apart meter, and a document under
444//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
445//! that second number, and `tests/point_cost.rs` pins every document in
446//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
447//! one under, the pin itself is the check. The credentialed lane reconciles both figures
448//! against GitHub's own, off a probe it already sends.
449//!
450//! **What is pinned that way is a per-document price and never a session's.** The record in
451//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
452//! **requests** and **worst-case nodes** — and neither is points. What one whole session
453//! consumes of the hourly point allowance is observable only from a credentialed run's own
454//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
455//! and what `tests/live.rs` prints at the end of every run.
456//!
457//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
458//!
459//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
460//! enumerations of a board can supply one alone.** Resolving a node id is strongly
461//! consistent, so a read by id and a project's own sub-issues are already current. The
462//! other two are not, and they are behind by different amounts and in different directions:
463//!
464//! - GitHub's **issue search** is an index and answers a write made moments ago with the
465//!   value from before it — usually for a second or two.
466//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
467//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
468//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
469//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
470//!
471//! That second one is a measurement rather than a caution. This repository's own
472//! credentialed journey writes a project and waits for the board to report it, then writes a
473//! task and waits for the same thing seconds later on the same board: the project wait is
474//! answered through the search and converged in two or three attempts in each of three runs,
475//! and the task wait is answered through `ProjectV2.items` and converged in none of them
476//! inside thirty. Separately, an item added to a second and larger board was read back by
477//! `Issue.projectItems` on that board's own id while every one of that connection's nine
478//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
479//! through the lagging one alone is what had a board read deny an issue that had certainly
480//! landed on it.
481//!
482//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
483//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
484//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
485//! search reports what the projection is behind on. What closes the last
486//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
487//! source answers is completed with what this process itself wrote, so an item created
488//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
489//! nothing is written down, and the record dies with the process. **A wait that has to
490//! observe GitHub's own data cannot be answered from that record** — which is why the
491//! credentialed journey asks through a source built afresh, and why the union above rather
492//! than a longer wait is what makes such a wait converge.
493//!
494//! **A narrowed read is the same bargain, stated for each of the three predicates it
495//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
496//! than walking the board, and every such answer is completed with what this process wrote —
497//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
498//! each filtered by the same predicates as the rest — so an item this command wrote a moment
499//! ago is returned by a query that matches it whether or not the index has caught up. An item
500//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
501//! consistent. What is left is stated rather than papered over:
502//!
503//! | Read | Finds | Behind by |
504//! | --- | --- | --- |
505//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
506//! | 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 |
507//! | 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 |
508//! | origin, third read | this process's own writes | nothing |
509//!
510//! So an origin carrier another process added within the last second or two, before either
511//! index has it, can be missing from an origin query, and one written by the release before
512//! this one — its origin in the field alone — can be missing for as long as the board's own
513//! item connection is behind on it. A copy that must not duplicate its own earlier write
514//! relies on the link it records, not on either index. **A board draft is not an issue**, so
515//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
516//! lists one, the origin lookup drops any the board's own field filter names, and one this
517//! process wrote is not added back either.
518//!
519//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
520//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
521//! body's metadata slot, so the issue search can find it in seconds. The field is
522//! authoritative: this source reads an item's origin from the field alone, so a slot that
523//! disagrees with it, or holds one where the field holds none, is never read as a second
524//! origin — and the release before this one reads the slot, drops that key's copy for the
525//! field's, and sees the same one origin.
526//!
527//! Filtering happens before paging, so a page of a filtered result is a page of the
528//! survivors rather than the survivors of a page. Label matching and the substring rule a
529//! text candidate is confirmed by answer the same question the same way the local Markdown
530//! source's do; which candidates a text search has to confirm is GitHub's word match, which
531//! is the one place the two sources can answer the same text differently.
532//!
533//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
534//! has one source, `capabilities`, and the note above is the reasoning behind it rather
535//! than a second copy of it: without the three facts recorded here a reader takes the
536//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
537//! crate's own capabilities test, which pins every field of it against a fully spelled-out
538//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
539//! fails to compile there rather than going unasserted. -->
540//! The fixture-server tests above run wherever this crate is selected; the credentialed
541//! lane runs in the same required check, beside them, and can fail it — it verifies the
542//! 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,
543//! one filed under neither, a label on one of the three and a closed status on another —
544//! because that shape is what tells an honoured predicate from an ignored one: a board
545//! holding a single project answers a project filter the same way whether or not this
546//! source applies it, which is exactly how the defect above went unseen.
547//!
548//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
549//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
550//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
551//! session or request, independently of the live demand. Fix that variable in the machine's
552//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
553//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
554//! policy crate; absent credentials or nominations otherwise skip.
555//!
556//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
557//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
558//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
559//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
560//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
561//! after its run reads back as `completed`, immediately before the listed-artifact batch.
562//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
563//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
564//! and the board's `onetaskgraph.origin` field untouched.
565//!
566//! # What a session of requests costs, and where the report is
567//!
568//! This source records **every** request it sends into [`accounting::Accounting`], at
569//! `send_once` — the one place a request leaves this crate, which is why a read path added
570//! later is counted without anybody remembering to count it. That is the whole of what this
571//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
572//! session's spend is arrived at, and what it deliberately does not know are set out.
573//!
574//! What one whole session of the live journey costs, counted that way against this crate's
575//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
576//! reduction it came out of, and with what it does and does not say about rate-limit points.
577//!
578//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
579//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
580//! code path — no environment variable, no feature, no build configuration — because an
581//! instrument nobody switches on measures nothing, and
582//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
583//! source's counts the whole session rather than this source's share. The credentialed lane
584//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
585//! passed or failed.
586//!
587//! **A live session refuses to start unless the account can afford it.** Before it does any
588//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
589//! GitHub documents as not counting against the REST rate limit and which answers both of
590//! its budgets at once — and starts only if, for each of them, what remains minus this
591//! session's estimated cost is still at least
592//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
593//! allowance. A session that cannot **declines**: it did not run, so it is
594//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
595//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
596//! waiting for the budget to come back. The estimate is derived offline from
597//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
598//! which is also where the published rule that model rests on is cited; the accounting
599//! above records the gate's own read like any other request, and
600//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
601//!
602//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
603//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
604//! text, which is what lets it run on every platform and on a pull request from a fork with
605//! no credential — and that is what actually stops a regression merging. But an offline
606//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
607//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
608//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
609//! number of nodes this query may return"* and whose `cost` is what that document would
610//! spend, both for a document **without executing it**, and the lane asks it for every query
611//! document this source sends, under the largest bindings this source sends, and fails when
612//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
613//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
614//! one; the offline pins still cover it. It records what those calls reported about the
615//! account's own allowance, because whether asking is free is a thing to observe rather than
616//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
617//! and `cost` is metered against an hourly allowance the accounting above reads off a
618//! credentialed run's own response headers.
619//!
620//! **GitHub has two rate limiters and this source is refused by both, so nothing here
621//! treats them as one thing.** The primary budget is the hourly allowance `gh api
622//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
623//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
624//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
625//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
626//! one part of.
627#![deny(missing_docs)]
628
629use std::collections::BTreeMap;
630use std::sync::{Arc, Mutex};
631use std::time::Duration;
632
633use chrono::{DateTime, Utc};
634use onetaskgraph_plugin_api::{
635    Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
636    ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
637    DependencySupport, Designator, Direction, Document, DocumentQuery, DocumentVersionCoverage,
638    Health, HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
639    MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
640    ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
641    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
642    StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
643    TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
644    WaitCause, WriteJournal, WriteSupport, system_clock,
645};
646use reqwest::{Client, StatusCode, Url};
647use schemars::{Schema, schema_for};
648use secrecy::{ExposeSecret, SecretString};
649use serde::{Deserialize, Serialize};
650use serde_json::{Value, json};
651
652pub mod accounting;
653mod assets;
654pub mod change_token;
655mod expected;
656pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
657mod pacing;
658mod projection;
659mod visibility;
660
661use accounting::Accounting;
662
663/// The registry name for this plugin.
664pub const KIND: &str = "github-projects";
665/// GitHub's maximum connection page size.
666pub const MAX_PAGE_SIZE: u32 = 100;
667/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
668/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
669/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
670pub const SEARCH_PAGE_SIZE: u32 = 20;
671/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
672/// its comments: the largest batch the node-count model prices at one point.
673///
674/// Each aliased item is resolved once, and what GitHub charges for it is the connections
675/// under it — its labels, its page of board memberships, the field values of each of those
676/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
677/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
678/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
679/// one point and fails if one item more would still be priced at one.
680pub const DETAIL_BATCH: usize = 24;
681/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
682///
683/// Each reference asks GitHub for one connection — a page of its board memberships, each with
684/// its `Status` value read by name rather than through a connection of field values — so fifty
685/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
686/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
687/// issues per source. `tests/point_cost.rs` prices the document at one point.
688pub const REFERENCE_BATCH: usize = 50;
689/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
690/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
691/// point.
692///
693/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
694/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
695/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
696/// if one item more would still be priced at one.
697pub const COPY_DETAIL_BATCH: usize = 21;
698/// How many mutations one aliased request of a copy's creates, closes and updates carries:
699/// [`graphql::CREATE_ISSUES`], [`graphql::CLOSE_ISSUES`] and [`graphql::UPDATE_ISSUES`].
700///
701/// Eleven is what GitHub was seen to accept in one request — the timing spike
702/// (`board-copy-speed-2026-10-18-spike-copy-timing-report`) sent eleven aliased mutations of four
703/// kinds, three `createIssue`, three `addProjectV2ItemById`, three field updates, an
704/// `addSubIssue` and an `addBlockedBy`, and every one ran — and no more was established.
705pub const MUTATION_BATCH: usize = 11;
706/// How many aliased writes one request of a copy's board field values and blocked-by edges
707/// carries: [`graphql::WRITE_FIELD_VALUES`], [`graphql::ADD_BLOCKED_BYS`] and, for a request
708/// holding both kinds, [`graphql::WRITE_FIELDS_AND_EDGES`].
709///
710/// Nineteen is the most aliases the feasibility spike
711/// (`board-copy-gaps-2026-10-24-spike-feasibility-report`) sent in one mutation request and
712/// saw GitHub price at the one point every mutation request costs; no larger request was
713/// established, so a copy holding more is split.
714pub const WRITE_BATCH: usize = 19;
715/// How many issues one [`graphql::DELETE_ISSUES`] request deletes: one request of eleven
716/// aliased `deleteIssue` met `RESOURCE_LIMITS_EXCEEDED` from the eighth alias on, and batches
717/// of five succeeded (the feasibility spike's undo-batching run).
718pub const DELETE_BATCH: usize = 5;
719/// How many origins one [`graphql::ORIGIN_LOOKUPS`] request asks about: the nine records of a
720/// plan and the project's document, and no more, since each carries a page of the board's field
721/// filter and one of the issue search.
722pub const ORIGIN_BATCH: usize = 10;
723
724/// The most nodes any one document this source sends may be asked to return.
725///
726/// GitHub's own published per-query ceiling, taken from
727/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
728/// workspace cannot hold a stale copy of somebody else's number. A query above it is
729/// **refused before it is executed**, whoever is asking and whatever board they are
730/// asking about — so this is a bound on the documents rather than a budget that runs out.
731///
732/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
733/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
734/// everything the credential does — two numbers against two limits, and this constant
735/// bounds only the first. The second is computed offline too, per document:
736/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
737/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
738/// lane. There is no constant like this one to hold a price under, because points are an
739/// hourly allowance rather than a per-call bound.
740///
741/// Neither is a session's price. What `session-cost.md` records of a whole session is its
742/// **requests** and its **worst-case nodes**; what a whole session spends in points is
743/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
744/// [`accounting`]. The module section on the three ways this source reaches an item says how
745/// the count is arrived at, and which of the page sizes below decide it.
746pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
747
748/// Nested connection size for the connections that hang off one item.
749///
750/// It multiplies through every document that reaches an item under a page — the count
751/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
752/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
753/// every document under these constants and fails naming any that reaches the limit, so
754/// raising this is caught there rather than by GitHub.
755const NESTED_PAGE_SIZE: u32 = 50;
756/// How many of one issue's board memberships are read when an issue is reached directly.
757///
758/// An issue reached through a search or through its own node id carries its board half in
759/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
760/// under a page of issues, so every point of it multiplies through the whole document and
761/// is paid for whether or not any issue is on a second board — which is why it is
762/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
763///
764/// **Three, because what a page misses is now recovered rather than refused**, and the
765/// recovery is what the value is chosen against. An issue whose entry for this board sits
766/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
767/// that page's own cursor — so the value trades a bound every read pays for a request only
768/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
769/// boards would pay that request *per issue*, which is order N against the one page per
770/// hundred issues a read costs today. At three it is only reached by an issue on four or
771/// more boards at once, which keeps the recovery path exceptional rather than routine for
772/// a plausible deployment.
773const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
774/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
775/// its two connections for.
776///
777/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
778/// second is a duplicate a copy already takes the first of. Both connections are walked to
779/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
780/// never what the answer is. It is small because every point of it is paid on every lookup,
781/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
782/// worst-case nodes than the one whole-board read they replaced.
783const ORIGIN_PAGE_SIZE: u32 = 3;
784/// How many carriers of each origin the batched [`graphql::ORIGIN_LOOKUPS`] asks each of its two
785/// connections for: one, the one carrier an origin names.
786///
787/// A second carrier still shows: the page then says there is more, and that origin is left to
788/// its own [`graphql::ORIGIN_LOOKUP`], which walks every page at `ORIGIN_PAGE_SIZE` — so a
789/// duplicate is found exactly as before, by the lookup that reads all of them. The feasibility
790/// spike (`board-copy-gaps-2026-10-24-spike-feasibility-report`) priced the batch of ten
791/// origins at two points with a first page of three and at one with a first page of one.
792const ORIGIN_LOOKUPS_PAGE_SIZE: u32 = 1;
793
794/// How many of one project's sub-issues one [`graphql::SUB_ISSUES`] page asks for: the most
795/// one point buys, since every row carries six connections GitHub charges for once per row.
796/// `session-cost.md` derives it; `tests/point_cost.rs` prices the document at one point and
797/// fails if one row more would still be priced at one.
798pub const MEMBER_PAGE_SIZE: u32 = 23;
799
800pub use github_graphql_node_count::{NodeCountError, Variables};
801
802/// The largest value this source can bind to each page-size variable its documents name.
803///
804/// Every `first:` in [`graphql`] reads one of these seven, and each is capped at the
805/// constant above it wherever a caller's own limit could reach it — `$first` at
806/// [`MAX_PAGE_SIZE`], `$commentFirst` at [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
807/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`, `$lookupFirst` at
808/// `ORIGIN_LOOKUPS_PAGE_SIZE`, `$memberFirst` at [`MEMBER_PAGE_SIZE`]. So this is the worst case a caller can drive this source to,
809/// not one configuration of it, which is what makes a bound computed under it a bound on
810/// every read.
811pub fn largest_page_sizes() -> Variables {
812    Variables::from([
813        ("first".to_owned(), MAX_PAGE_SIZE),
814        ("commentFirst".to_owned(), MAX_PAGE_SIZE),
815        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
816        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
817        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
818        ("lookupFirst".to_owned(), ORIGIN_LOOKUPS_PAGE_SIZE),
819        ("memberFirst".to_owned(), MEMBER_PAGE_SIZE),
820    ])
821}
822
823/// The most nodes `document` could be asked to return, by GitHub's published rules.
824///
825/// Computed offline from the document's own text under [`largest_page_sizes`] — no
826/// network, no credential and no schema — by
827/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
828/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
829/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
830///
831/// # Errors
832///
833/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
834/// no single operation, or binds a page size this source does not name — each of which is
835/// a defect in the document rather than a number.
836pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
837    node_count(document, &largest_page_sizes())
838}
839
840/// The most rate-limit points one call of `document` could spend, by GitHub's published
841/// rules.
842///
843/// Computed offline from the document's own text under [`largest_page_sizes`] — no
844/// network, no credential and no schema — by
845/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
846/// This is `cost`, metered **per hour** against the allowance one credential shares across
847/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
848/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
849/// under, so what `tests/point_cost.rs` does with it is pin every document in
850/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
851/// figures against GitHub's own reported `cost`.
852///
853/// # Errors
854///
855/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
856/// no single operation, or binds a page size this source does not name — each of which is
857/// a defect in the document rather than a number.
858pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
859    github_graphql_node_count::point_cost(document, &largest_page_sizes())
860}
861
862/// The most nodes `document` could be asked to return under `variables`.
863///
864/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
865/// [`accounting`] is this under the bindings one request really sent — one spelling of the
866/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
867/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
868///
869/// # Errors
870///
871/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
872/// no single operation, or binds a page size `variables` does not name.
873pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
874    github_graphql_node_count::node_count(document, variables)
875}
876
877/// The issue-title prefix that makes a board issue a document.
878///
879/// A GitHub Projects board has no document type — it holds issues — so the discriminator
880/// is the title, and this is the whole of it: an issue whose title begins with these bytes
881/// is a document and every other issue is the task or project the sub-issue rule makes it.
882///
883/// It is spelled **once**, here, and read rather than restated everywhere else — including
884/// by the shared journeys, which take it from this constant so a board fixture cannot
885/// drift from what this source reads. `docs/metadata.md` records the two consequences that
886/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
887/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
888/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
889pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
890
891/// Exact GraphQL query documents issued by this plugin.
892///
893/// Keeping the production documents here lets the pinned-schema test validate the same
894/// bytes that are sent to GitHub, rather than a test-only copy which could drift
895/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
896/// field, and its guarded caller always supplies the complete existing option set with ids.
897pub mod graphql {
898    /// The board half of one item: the field values every document here reads it from.
899    ///
900    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
901    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
902    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
903    /// *the same value*, because
904    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
905    /// one path. Three spellings of it is what would drift, so there is one.
906    ///
907    /// The `Status` option and this source's own origin text field are the whole of it. It
908    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
909    /// content, so it holds nothing the content's own `labels` do not already say, and it
910    /// would sit a label connection two page sizes deep.
911    macro_rules! board_item_values {
912        () => {
913            r#"fieldValues(first:$nestedFirst){nodes{
914          ... on ProjectV2ItemFieldSingleSelectValue{name field{
915            ... on ProjectV2SingleSelectField{id name options{id name}}
916          }}
917          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
918        }pageInfo{hasNextPage}}"#
919        };
920    }
921
922    /// The variable every document reading an issue declares for the relationship events its
923    /// change token counts, with its value as the default, so no caller binds it.
924    ///
925    /// A variable rather than a literal list because every argument a production document
926    /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
927    /// its default to the pinned enum. Its default is the six event types GitHub records a
928    /// relationship added or removed with: a dependency on either end, as `blockedBy` and
929    /// `blocking`, so one a person makes from the far end moves this end's count too; and a
930    /// project membership on the task, as its parent, which GitHub records there whichever
931    /// issue the move was made from. A sub-issue's events on its parent are not counted: a
932    /// project carries no task token, and a copy writes nothing of a task its own sub-issues
933    /// decide. [`super::change_token`] says why the count is in the token.
934    macro_rules! relationship_events_variable {
935        () => {
936            ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
937        };
938    }
939
940    /// Everything this source reads about one issue, wherever it reaches that issue.
941    ///
942    /// A macro rather than a constant so the three documents below can `concat!` it: one
943    /// spelling of these fields is what makes an issue read through the board-scoped
944    /// search, through its own node id, and through its project's sub-issue relationship
945    /// resolve to *the same* item, which is the whole of what
946    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
947    ///
948    /// `projectItems` is what carries the board half of an issue: the board item's own id
949    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
950    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
951    /// issue rather than on the board, which is what makes the cost of a read proportional
952    /// to what was asked for instead of to the board's size.
953    ///
954    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
955    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
956    /// not on that page: a page here is where the search for the entry starts rather than
957    /// where it ends.
958    ///
959    /// It does **not** select the board's `Labels` field value, and that is the whole of
960    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
961    /// a label connection there sits under `fieldValues` under `projectItems` under a page
962    /// of issues, spending `$nestedFirst` twice down one path, and took
963    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
964    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
965    /// above, and that connection is where every label this source reports comes from. No
966    /// document in this module selects the board field any longer, [`BOARD`] included; the
967    /// module documentation records why nothing it could have held is lost.
968    macro_rules! board_issue {
969        () => {
970            concat!(
971                r#" fragment BoardIssue 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}
972      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
973      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
974        "#,
975                board_item_values!(),
976                r#"}pageInfo{hasNextPage endCursor}}}"#
977            )
978        };
979    }
980
981    /// One page of a repository's issues, newest first, each with when it was created and its
982    /// body: what a retried copy reads to find the issue its earlier attempt created, by the
983    /// create key in that body's metadata slot — never through search, whose index lags.
984    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}}}}";
985    /// Every issue of one board, found by a search scoped to that board.
986    ///
987    /// This is how the projects a board holds are listed, and it selects no `items`
988    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
989    /// container walked page by page, so nothing nested inside a board item is paid for.
990    /// Which of the issues it returns is a project is then read off `parent` — GitHub
991    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
992    /// discriminator has to be applied to the field, which is a scalar on the issue and
993    /// costs nothing.
994    pub const SEARCH_ISSUES: &str = concat!(
995        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
996        relationship_events_variable!(),
997        r#"){
998      search(query:$search,type:$type,first:$first,after:$after){
999        pageInfo{hasNextPage endCursor}
1000        nodes{__typename ...BoardIssue}
1001      }
1002    }"#,
1003        board_issue!()
1004    );
1005
1006    /// What a dependency read selects of each far end: enough to say which kind of item it
1007    /// is, its body included for the kind marker.
1008    macro_rules! related_issue {
1009        () => {
1010            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
1011        };
1012    }
1013
1014    /// One issue by its own node id, which is what a qualified id names here — with what a
1015    /// write of it needs and the issue does not carry in `board_issue!`: the field
1016    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
1017    ///
1018    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
1019    /// answers a write made moments ago with the value from before it, and resolving a node
1020    /// id does not.
1021    ///
1022    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
1023    /// reads it by its own id, and with them that one read answers everything the write
1024    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
1025    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
1026    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
1027    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
1028    /// they sit under one item, and this read is still one point.
1029    pub const ISSUE: &str = concat!(
1030        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1031        relationship_events_variable!(),
1032        r#"){
1033      node(id:$id){__typename ...BoardIssue ... on Issue{
1034        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1035          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1036          ... on ProjectV2Field{__typename id name dataType}
1037        }pageInfo{hasNextPage}}}}}
1038        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1039      }}
1040    }"#,
1041        board_issue!(),
1042        related_issue!()
1043    );
1044
1045    /// One document by its own node id, as [`ISSUE`] reads one, with the project it is filed
1046    /// under and that project's members beside it: the project issue whole, and a page of its
1047    /// sub-issues, each as [`SUB_ISSUES`] carries one but with no page of what blocks it.
1048    ///
1049    /// What a guarded write of a document's metadata reads, and what reading a document by its
1050    /// id is: a write to a board the boundary screens is held to who may read the document,
1051    /// which is the project's and every member's classification as much as its own, and here
1052    /// the one request that holds the version the write is compared against answers all of it.
1053    /// Its members carry no edges, which nothing classifying them reads.
1054    pub const DOCUMENT_IN_PROJECT: &str = concat!(
1055        r#"query($id:ID!,$first:Int!,$memberFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1056        relationship_events_variable!(),
1057        r#"){
1058      node(id:$id){__typename ...BoardIssue ... on Issue{
1059        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1060          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1061          ... on ProjectV2Field{__typename id name dataType}
1062        }pageInfo{hasNextPage}}}}}
1063        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1064        project:parent{__typename ...BoardIssue
1065          subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}
1066        }
1067      }}
1068    }"#,
1069        board_issue!(),
1070        related_issue!()
1071    );
1072
1073    /// One project's tasks: the sub-issues of the issue that project is, each with a page of
1074    /// what blocks it.
1075    ///
1076    /// Paged at `$memberFirst` — [`MEMBER_PAGE_SIZE`](super::MEMBER_PAGE_SIZE), the most rows
1077    /// one point buys — rather than at `$first`, because every row carries six connections and
1078    /// a hundred-row page cost six points to list a project of ten.
1079    ///
1080    /// The work this costs is the project's own size. Nothing about it grows as the board
1081    /// gains projects, or as those projects gain tasks.
1082    ///
1083    /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1084    /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1085    /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1086    /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1087    /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1088    /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1089    /// It adds a connection under each issue of the page — one rate-limit point per page,
1090    /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1091    /// answer questions that never read an edge.
1092    ///
1093    /// **The project issue itself rides here too**, with the page of what blocks it, because
1094    /// the reader of a project's tasks reads the project next: a copy writing the project reads
1095    /// it and its edges before it writes them. Carried on the request that already names the
1096    /// project, the first page answers that read and the dependency read after it, at the
1097    /// state GitHub answered in this command, rather than spending one more read on an item
1098    /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1099    /// copy's read of a task this listing carried is answered by it rather than sent again.
1100    /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1101    pub const SUB_ISSUES: &str = concat!(
1102        r#"query($id:ID!,$memberFirst:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1103        relationship_events_variable!(),
1104        r#"){
1105      node(id:$id){__typename ...BoardIssue
1106        ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1107          subIssues(first:$memberFirst,after:$after){
1108          pageInfo{hasNextPage endCursor}
1109          nodes{__typename ...BoardIssue ... on Issue{
1110            blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1111          }}
1112        }}}
1113    }"#,
1114        board_issue!(),
1115        related_issue!()
1116    );
1117
1118    /// Everything this source reads about one issue comment, wherever it reaches one.
1119    ///
1120    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1121    /// and a comment just edited are handed to one mapper, so they are selected by one
1122    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1123    /// longer exists, and `login` is the one member every kind of actor carries.
1124    macro_rules! issue_comment {
1125        () => {
1126            "id author{login} createdAt updatedAt body url"
1127        };
1128    }
1129
1130    /// What a read of the board's own `items` selects of each item's content.
1131    ///
1132    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1133    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1134    /// content by one spelling.
1135    macro_rules! board_item_content {
1136        () => { board_item_content!("") };
1137        ($comments:expr) => {
1138            concat!(r#" content{
1139        ... 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#"}
1140        ... on PullRequest{__typename id}
1141        ... on DraftIssue{__typename id title body createdAt updatedAt}
1142      }"#)
1143        };
1144    }
1145
1146    macro_rules! board_listing {
1147        ($variables:expr, $comments:expr) => { concat!(
1148        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1149        relationship_events_variable!(), $variables,
1150        r#"){
1151      owner:repositoryOwner(login:$owner){
1152        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1153      }
1154    } fragment Board on ProjectV2 { id title
1155      fields(first:$nestedFirst){nodes{
1156        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1157        ... on ProjectV2Field{__typename id name dataType}
1158      }pageInfo{hasNextPage}}
1159      items(first:$first,after:$after){nodes{id updatedAt "#,
1160        board_item_values!(),
1161        board_item_content!($comments),
1162        r#"} pageInfo{hasNextPage endCursor}}
1163    }"#
1164    ) };
1165    }
1166
1167    /// Reads the board's fields and one page of its items.
1168    pub const BOARD: &str = board_listing!("", "");
1169
1170    /// A board page carrying each issue's first comment page in the same request.
1171    pub const BOARD_WITH_COMMENTS: &str = board_listing!(
1172        ",$commentFirst:Int!",
1173        concat!(
1174            "comments(first:$commentFirst){nodes{",
1175            issue_comment!(),
1176            "}pageInfo{hasNextPage endCursor}}"
1177        )
1178    );
1179
1180    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1181    /// the board.
1182    ///
1183    /// **`originItems`** is the board's own items narrowed by its own field filter —
1184    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1185    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1186    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1187    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1188    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1189    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1190    /// fresh `addProjectV2ItemById` the way that connection does.
1191    ///
1192    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1193    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1194    /// indexes that comment, and the index catches up with a write in a second or two rather
1195    /// than in minutes, so it finds a carrier another process wrote that the first read is
1196    /// still behind on.
1197    ///
1198    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1199    /// — and resumes from its own cursor; a connection already walked to its end is resumed
1200    /// from its last cursor, which answers an empty page. Every candidate either read returns
1201    /// is confirmed against its own origin field before it is reported, so a token match of
1202    /// the search or anything else the filter admits never is.
1203    ///
1204    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1205    /// own whole reads counts this one among them.
1206    pub const ORIGIN_LOOKUP: &str = concat!(
1207        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1208        relationship_events_variable!(),
1209        r#"){
1210      originItems:repositoryOwner(login:$owner){
1211        ... on ProjectV2Owner{projectV2(number:$number){
1212          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1213        board_item_values!(),
1214        board_item_content!(),
1215        r#"} pageInfo{hasNextPage endCursor}}
1216        }}
1217      }
1218      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1219        pageInfo{hasNextPage endCursor}
1220        nodes{__typename ...BoardIssue}
1221      }
1222    }"#,
1223        board_issue!()
1224    );
1225
1226    /// Every closed item of the board, by the board's own field filter — what
1227    /// `task status reconcile` reads, and nothing else of the board.
1228    ///
1229    /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1230    /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1231    /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1232    /// closed, with a reason and a card that contradict each other, from the item node it
1233    /// already carries, so a filter that also let open items through would cost reads and
1234    /// write nothing wrong.
1235    ///
1236    /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1237    /// option writes that follow need no read of the board. The root is aliased `closedItems`
1238    /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1239    /// among them.
1240    pub const CLOSED_ITEMS: &str = concat!(
1241        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1242        relationship_events_variable!(),
1243        r#"){
1244      closedItems:repositoryOwner(login:$owner){
1245        ... on ProjectV2Owner{projectV2(number:$number){id
1246          fields(first:$nestedFirst){nodes{
1247            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1248            ... on ProjectV2Field{__typename id name dataType}
1249          }pageInfo{hasNextPage}}
1250          items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1251        board_item_values!(),
1252        board_item_content!(),
1253        r#"} pageInfo{hasNextPage endCursor}}
1254        }}
1255      }
1256    }"#
1257    );
1258
1259    /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1260    macro_rules! reference_alias {
1261        ($n:literal) => {
1262            concat!(
1263                "\n      r",
1264                stringify!($n),
1265                ":repository(owner:$owner",
1266                stringify!($n),
1267                ",name:$name",
1268                stringify!($n),
1269                "){issue(number:$number",
1270                stringify!($n),
1271                "){...HeldIssue}}"
1272            )
1273        };
1274    }
1275
1276    /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1277    /// number — the one read `task status complete` makes of a source, and nothing else of
1278    /// the board: no `items`, no search and no project read.
1279    ///
1280    /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1281    /// references one point.** It selects no labels, and of each board membership only the
1282    /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1283    /// connection, its page of memberships, where the whole fragment asks for five. What a
1284    /// completion writes is a card and, at most, the issue's state, so the labels and the other
1285    /// field values it leaves out are never what it needs; an item read this way is held for
1286    /// that write alone and never answers another read. An issue whose entry for this board is
1287    /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1288    /// every other read of an issue recovers one.
1289    ///
1290    /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1291    /// fewer references binds the slots it has none for to its last one.
1292    pub const ISSUES_BY_REFERENCE: &str = concat!(
1293        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!"#,
1294        relationship_events_variable!(),
1295        r#"){"#,
1296        reference_alias!(0),
1297        reference_alias!(1),
1298        reference_alias!(2),
1299        reference_alias!(3),
1300        reference_alias!(4),
1301        reference_alias!(5),
1302        reference_alias!(6),
1303        reference_alias!(7),
1304        reference_alias!(8),
1305        reference_alias!(9),
1306        reference_alias!(10),
1307        reference_alias!(11),
1308        reference_alias!(12),
1309        reference_alias!(13),
1310        reference_alias!(14),
1311        reference_alias!(15),
1312        reference_alias!(16),
1313        reference_alias!(17),
1314        reference_alias!(18),
1315        reference_alias!(19),
1316        reference_alias!(20),
1317        reference_alias!(21),
1318        reference_alias!(22),
1319        reference_alias!(23),
1320        reference_alias!(24),
1321        reference_alias!(25),
1322        reference_alias!(26),
1323        reference_alias!(27),
1324        reference_alias!(28),
1325        reference_alias!(29),
1326        reference_alias!(30),
1327        reference_alias!(31),
1328        reference_alias!(32),
1329        reference_alias!(33),
1330        reference_alias!(34),
1331        reference_alias!(35),
1332        reference_alias!(36),
1333        reference_alias!(37),
1334        reference_alias!(38),
1335        reference_alias!(39),
1336        reference_alias!(40),
1337        reference_alias!(41),
1338        reference_alias!(42),
1339        reference_alias!(43),
1340        reference_alias!(44),
1341        reference_alias!(45),
1342        reference_alias!(46),
1343        reference_alias!(47),
1344        reference_alias!(48),
1345        reference_alias!(49),
1346        "\n    }",
1347        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}
1348      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1349        status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1350          ... on ProjectV2SingleSelectField{id name options{id name}}
1351        }}}
1352      }pageInfo{hasNextPage endCursor}}}"#
1353    );
1354
1355    /// The board's own id and field definitions, and not one of its items.
1356    ///
1357    /// What a write needs of the board when the item it writes does not say: the id a field
1358    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1359    /// origin fields. It selects no `items`, so what it costs is the board's field list
1360    /// however many items the board holds — and it decides nothing about which items those
1361    /// are, which is the question a read of one item by its own id answers instead.
1362    ///
1363    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1364    /// board's item reads by their root counts this one among them.
1365    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1366      boardFields:repositoryOwner(login:$owner){
1367        ... on ProjectV2Owner{projectV2(number:$number){id
1368          fields(first:$nestedFirst){nodes{
1369            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1370            ... on ProjectV2Field{__typename id name dataType}
1371          }pageInfo{hasNextPage}}
1372        }}
1373      }
1374    }"#;
1375
1376    /// One board draft by its own node id, with the board item it sits in.
1377    ///
1378    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1379    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1380    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1381    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1382    /// board listing hands it to, and nothing has to list the board to find one.
1383    pub const DRAFT: &str = concat!(
1384        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1385      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1386        projectV2Items(first:$boardItems){nodes{id project{id number}
1387        "#,
1388        board_item_values!(),
1389        r#"}pageInfo{hasNextPage endCursor}}}}
1390    }"#
1391    );
1392
1393    /// One issue's board memberships alone, walked past the page a read of it carried.
1394    ///
1395    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1396    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1397    /// boards than that page holds may have this board's entry past its end. This asks that
1398    /// one issue for its memberships and nothing else — the caller already holds the issue —
1399    /// so an answer of "this board does not hold it" is only ever given about a connection
1400    /// read to exhaustion.
1401    ///
1402    /// It selects the board item's id, its project number and the same
1403    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1404    /// very same resolver: an issue recovered this way reports the same title, the same
1405    /// status, the same labels and the same qualified id as one whose entry was on the
1406    /// page.
1407    ///
1408    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1409    /// multiplies through it and the membership connection can be walked at
1410    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1411    /// further request for any issue a person really keeps.
1412    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1413        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1414      node(id:$id){
1415        ... on Issue{projectItems(first:$first,after:$after){
1416          nodes{id updatedAt project{id number}
1417        "#,
1418        board_item_values!(),
1419        r#"}
1420          pageInfo{hasNextPage endCursor}}}
1421      }
1422    }"#
1423    );
1424    /// Whether the board's Project is public — half of what decides whether a write here can
1425    /// be read by anybody. Needs the `read:project` scope.
1426    pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1427    /// Resolves the configured repository's node id, which creating an issue requires.
1428    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1429    /// What creating an issue needs and has not read yet: the board's own id and field
1430    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1431    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1432    ///
1433    /// Sent at the point a create knows which repository it is for, when neither half is
1434    /// already known to this process; a create needing only one of them sends that one's own
1435    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1436    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1437    /// status.
1438    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1439      boardFields:repositoryOwner(login:$owner){
1440        ... on ProjectV2Owner{projectV2(number:$number){id
1441          fields(first:$nestedFirst){nodes{
1442            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1443            ... on ProjectV2Field{__typename id name dataType}
1444          }pageInfo{hasNextPage}}
1445        }}
1446      }
1447      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1448    }"#;
1449    /// Reads both dependency directions for one issue, with each far end's own kind — and
1450    /// the issue's own body, which is where an edge to another source is recorded, so that
1451    /// half of a dependency read needs no second read of the issue or of the board.
1452    pub const ISSUE_DEPENDENCIES: &str = concat!(
1453        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1454      ... on Issue{body
1455        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1456        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1457      }}}"#,
1458        related_issue!()
1459    );
1460    /// What one item depends on: [`ISSUE_DEPENDENCIES`] narrowed to `blockedBy`, so a
1461    /// dependency read asks GitHub for the one direction it answers. The issue's `body` rides
1462    /// along because its metadata slot is where an edge to another source is recorded — and a
1463    /// board draft's `body` too, with the one board item GitHub links a draft to and, aliased
1464    /// `board`, this board's own id, so a draft's recorded edges are read off this same answer
1465    /// and only when that item is this board's: no draft read and no board read follow it.
1466    ///
1467    /// The draft's board item is asked for under a root of its own, aliased `draft`, rather
1468    /// than beside its `body` under `node`. GitHub counts the type-conditional fragments of
1469    /// one selection as the largest of them — `blockedBy`'s 100 alone, where this workspace's
1470    /// calculation adds the draft's 3 — while separate roots are summed by both, so this is
1471    /// the spelling whose node count GitHub and the calculation agree on. Both price it at
1472    /// one point; the credentialed lane's reconciliation is what holds the two to that.
1473    pub const ISSUE_DEPENDS_ON: &str = concat!(
1474        r#"query($id:ID!,$first:Int!,$after:String,$owner:String!,$number:Int!,$boardItems:Int!){
1475      node(id:$id){__typename
1476      ... on Issue{body
1477        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1478      }
1479      ... on DraftIssue{id body}}
1480      draft:node(id:$id){... on DraftIssue{projectV2Items(first:$boardItems){nodes{project{id number}}pageInfo{hasNextPage}}}}
1481      board:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id}}}}"#,
1482        related_issue!()
1483    );
1484    /// What depends on one issue: [`ISSUE_DEPENDENCIES`] with its `blocking` alone. No `body`:
1485    /// the reverse of a recorded edge is derived from the far end, never written down here.
1486    pub const ISSUE_DEPENDED_ON_BY: &str = concat!(
1487        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1488      ... on Issue{
1489        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1490      }}}"#,
1491        related_issue!()
1492    );
1493    /// Creates one issue. Outside a copy it is created on no board, and [`ADD_TO_BOARD`] files
1494    /// it; inside one, its input names the board in `projectV2Ids` and its project in
1495    /// `parentIssueId`, so this one request files it under both — see
1496    /// [`GitHubProjectsSource::create_filed_issue`](super::GitHubProjectsSource).
1497    pub const CREATE_ISSUE: &str =
1498        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1499    /// Puts an existing issue on the configured board.
1500    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1501    /// Updates an issue's visible fields and its open or closed state in one call.
1502    pub const UPDATE_ISSUE: &str =
1503        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1504    /// Updates an existing draft's user-visible fields.
1505    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1506    /// Updates a text or single-select value on one project item.
1507    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}}}}}}}}"#;
1508    /// Writes up to six board fields and clears up to three in one ordered mutation: every
1509    /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1510    /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1511    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}}}"#;
1512    /// Clears one project item's value of one field, which is what a `none` priority is.
1513    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}}}}}}}}"#;
1514    /// Creates one field: a single-select one with its options, or a text one for a projected
1515    /// metadata value. Only the guarded field setup may use this document, and only for a
1516    /// field the board lacks.
1517    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1518    /// Replaces a single-select field's options. Only the guarded field setup — the
1519    /// `status-options` and `fields` operations — may use this document, because GitHub
1520    /// treats the input as the complete option list.
1521    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1522    /// A fresh snapshot of the Status field and every board item's assignment.
1523    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}}}}}}"#;
1524    /// Files one issue under another as a sub-issue, which is what project membership is.
1525    pub const ADD_SUB_ISSUE: &str =
1526        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1527    /// Takes one issue back out of its parent.
1528    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1529    /// Adds GitHub's native issue blocked-by relationship.
1530    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1531    /// One origin of [`ORIGIN_LOOKUPS`]: [`ORIGIN_LOOKUP`]'s two connections for the origin the
1532    /// `$filter<n>` and `$search<n>` of slot `<n>` name, sent only while `$ask<n>` holds.
1533    macro_rules! origin_lookup_alias {
1534        ($n:literal) => {
1535            concat!(
1536                "\n      o",
1537                stringify!($n),
1538                ":repositoryOwner(login:$owner) @include(if:$ask",
1539                stringify!($n),
1540                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$lookupFirst,query:$filter",
1541                stringify!($n),
1542                "){nodes{id updatedAt ",
1543                board_item_values!(),
1544                board_item_content!(),
1545                "} pageInfo{hasNextPage endCursor}}}}}\n      s",
1546                stringify!($n),
1547                ":search(query:$search",
1548                stringify!($n),
1549                ",type:$type,first:$lookupFirst) @include(if:$ask",
1550                stringify!($n),
1551                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}"
1552            )
1553        };
1554    }
1555
1556    /// [`ORIGIN_LOOKUPS`], with `$extra_variables` declared after its own and `$extra_fields`
1557    /// selected beside them: one spelling of the batched lookup for the documents that send it.
1558    macro_rules! origin_lookups {
1559        ($extra_variables:literal, $extra_fields:literal) => {
1560            concat!(
1561                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!"#,
1562                relationship_events_variable!(),
1563                $extra_variables,
1564                r#"){
1565      boardFields:repositoryOwner(login:$owner){
1566        ... on ProjectV2Owner{projectV2(number:$number){id
1567          fields(first:$nestedFirst){nodes{
1568            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1569            ... on ProjectV2Field{__typename id name dataType}
1570          }pageInfo{hasNextPage}}
1571        }}
1572      }
1573      repository(owner:$repositoryOwner,name:$repositoryName){id databaseId nameWithOwner}"#,
1574                $extra_fields,
1575                origin_lookup_alias!(0),
1576                origin_lookup_alias!(1),
1577                origin_lookup_alias!(2),
1578                origin_lookup_alias!(3),
1579                origin_lookup_alias!(4),
1580                origin_lookup_alias!(5),
1581                origin_lookup_alias!(6),
1582                origin_lookup_alias!(7),
1583                origin_lookup_alias!(8),
1584                origin_lookup_alias!(9),
1585                "\n    }",
1586                board_issue!()
1587            )
1588        };
1589    }
1590
1591    /// The first page of [`ORIGIN_LOOKUP`] for up to [`ORIGIN_BATCH`](super::ORIGIN_BATCH)
1592    /// origins a copy is about to look for, and everything creating an item then reads —
1593    /// the board's own id and field definitions, as [`CREATION_CONTEXT`] reads them, and the node
1594    /// id and the numeric id of the repository an issue is created in — in one request.
1595    ///
1596    /// What a copy reads before it writes anything, so that finding none of its records on the
1597    /// board and creating every one costs this read and the writes. An origin whose page says
1598    /// there is more is left to its own [`ORIGIN_LOOKUP`]. The repository's numeric id is what an
1599    /// image upload names, so a copy carrying assets needs no `GET /repos/{owner}/{repo}` either.
1600    pub const ORIGIN_LOOKUPS: &str = origin_lookups!("", "");
1601
1602    /// [`ORIGIN_LOOKUPS`], with a page of the members of the project `$project` beside it, each
1603    /// trimmed to what classifying it reads — its id, its parent, its body and its repository,
1604    /// and no connection under it.
1605    ///
1606    /// What a document copy sends where the public boundary screens its write: the document is
1607    /// held to who may read its project, which is its members' classification as much as the
1608    /// project's own, and this request carries both the project — among the origins it asks —
1609    /// and its members. GitHub's own dry run prices it at the one point [`ORIGIN_LOOKUPS`]
1610    /// costs alone (`docs/measurements/board-copy-gaps-2026-10-24/coalesced-members-dry-run`).
1611    pub const ORIGIN_LOOKUPS_WITH_MEMBERS: &str = origin_lookups!(
1612        ",$project:ID!,$memberFirst:Int!",
1613        "\n      members:node(id:$project){... on Issue{subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{id body parent{id} repository{nameWithOwner}}}}}"
1614    );
1615
1616    /// One slot `<n>` of a copy's aliased writes: `$<prefix><n>` is its input and `$send<n>`
1617    /// whether it is sent.
1618    macro_rules! write_slot {
1619        ($prefix:literal, $field:literal, $selection:literal, $n:literal) => {
1620            concat!(
1621                " ",
1622                $prefix,
1623                stringify!($n),
1624                ":",
1625                $field,
1626                "(input:$",
1627                $prefix,
1628                stringify!($n),
1629                ") @include(if:$send",
1630                stringify!($n),
1631                "){",
1632                $selection,
1633                "}"
1634            )
1635        };
1636    }
1637
1638    /// The variables of a copy's aliased writes: eleven inputs of `$type` named `$<prefix><n>`,
1639    /// and eleven `$send<n>`.
1640    macro_rules! write_variables {
1641        ($prefix:literal, $type:literal) => {
1642            concat!(
1643                "mutation(",
1644                "$",
1645                $prefix,
1646                "0:",
1647                $type,
1648                "!,$",
1649                $prefix,
1650                "1:",
1651                $type,
1652                "!,$",
1653                $prefix,
1654                "2:",
1655                $type,
1656                "!,$",
1657                $prefix,
1658                "3:",
1659                $type,
1660                "!,$",
1661                $prefix,
1662                "4:",
1663                $type,
1664                "!,$",
1665                $prefix,
1666                "5:",
1667                $type,
1668                "!,$",
1669                $prefix,
1670                "6:",
1671                $type,
1672                "!,$",
1673                $prefix,
1674                "7:",
1675                $type,
1676                "!,$",
1677                $prefix,
1678                "8:",
1679                $type,
1680                "!,$",
1681                $prefix,
1682                "9:",
1683                $type,
1684                "!,$",
1685                $prefix,
1686                "10:",
1687                $type,
1688                "!,",
1689                "$send0:Boolean!,$send1:Boolean!,$send2:Boolean!,$send3:Boolean!,$send4:Boolean!,",
1690                "$send5:Boolean!,$send6:Boolean!,$send7:Boolean!,$send8:Boolean!,$send9:Boolean!,",
1691                "$send10:Boolean!){"
1692            )
1693        };
1694    }
1695
1696    /// A copy's aliased writes of one kind: [`MUTATION_BATCH`](super::MUTATION_BATCH) slots of
1697    /// `$field`, each sent only while its `$send<n>` holds.
1698    macro_rules! write_batch {
1699        ($prefix:literal, $type:literal, $field:literal, $selection:literal) => {
1700            concat!(
1701                write_variables!($prefix, $type),
1702                write_slot!($prefix, $field, $selection, 0),
1703                write_slot!($prefix, $field, $selection, 1),
1704                write_slot!($prefix, $field, $selection, 2),
1705                write_slot!($prefix, $field, $selection, 3),
1706                write_slot!($prefix, $field, $selection, 4),
1707                write_slot!($prefix, $field, $selection, 5),
1708                write_slot!($prefix, $field, $selection, 6),
1709                write_slot!($prefix, $field, $selection, 7),
1710                write_slot!($prefix, $field, $selection, 8),
1711                write_slot!($prefix, $field, $selection, 9),
1712                write_slot!($prefix, $field, $selection, 10),
1713                "}"
1714            )
1715        };
1716    }
1717
1718    /// Creates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues in one request, each filed on
1719    /// the board and under its project by the same `createIssue` — its `projectV2Ids` and
1720    /// `parentIssueId` — as [`CREATE_ISSUE`] creates one.
1721    pub const CREATE_ISSUES: &str = write_batch!(
1722        "create",
1723        "CreateIssueInput",
1724        "createIssue",
1725        "issue{id number url}"
1726    );
1727
1728    /// One issue of [`CREATED_BOARD_ITEMS`]: the board items the issue `$id<n>` names is on, with
1729    /// the `Status` value of each, read only while `$read<n>` holds.
1730    macro_rules! created_item_alias {
1731        ($n:literal) => {
1732            concat!(
1733                "\n      i",
1734                stringify!($n),
1735                ":node(id:$id",
1736                stringify!($n),
1737                ") @include(if:$read",
1738                stringify!($n),
1739                "){... on Issue{id projectItems(first:$boardItems){nodes{id project{id} status:fieldValueByName(name:$statusName){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}"
1740            )
1741        };
1742    }
1743
1744    /// The board item of each issue a copy created, up to
1745    /// [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH) at a time, with its `Status` as the board
1746    /// set it: what `createIssue`'s `projectV2Ids` files the issue as, which GitHub adds after
1747    /// answering the create, so its id comes from a read made once every create has been sent.
1748    pub const CREATED_BOARD_ITEMS: &str = concat!(
1749        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!){"#,
1750        created_item_alias!(0),
1751        created_item_alias!(1),
1752        created_item_alias!(2),
1753        created_item_alias!(3),
1754        created_item_alias!(4),
1755        created_item_alias!(5),
1756        created_item_alias!(6),
1757        created_item_alias!(7),
1758        created_item_alias!(8),
1759        created_item_alias!(9),
1760        created_item_alias!(10),
1761        created_item_alias!(11),
1762        created_item_alias!(12),
1763        created_item_alias!(13),
1764        created_item_alias!(14),
1765        created_item_alias!(15),
1766        created_item_alias!(16),
1767        created_item_alias!(17),
1768        created_item_alias!(18),
1769        created_item_alias!(19),
1770        created_item_alias!(20),
1771        "\n    }"
1772    );
1773
1774    /// The variables and the aliases of [`WRITE_BATCH`](super::WRITE_BATCH) slots: `$<prefix><n>`
1775    /// is a slot's input and `$<send><n>` whether it is sent, for the slot numbers given.
1776    macro_rules! wide_variables {
1777        ($prefix:literal, $type:literal, $send:literal; $($n:literal)+) => {
1778            concat!($("$", $prefix, stringify!($n), ":", $type, "!,$", $send, stringify!($n), ":Boolean!,",)+)
1779        };
1780    }
1781    macro_rules! wide_slots {
1782        ($prefix:literal, $field:literal, $selection:literal, $send:literal; $($n:literal)+) => {
1783            concat!($(
1784                " ", $prefix, stringify!($n), ":", $field, "(input:$", $prefix, stringify!($n),
1785                ") @include(if:$", $send, stringify!($n), "){", $selection, "}",
1786            )+)
1787        };
1788    }
1789
1790    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) board field values a copy writes on the items it
1791    /// created, each on its own item, in one ordered request.
1792    pub const WRITE_FIELD_VALUES: &str = concat!(
1793        "mutation(",
1794        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1795        "){",
1796        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),
1797        "}"
1798    );
1799
1800    /// A copy's board field values and its blocked-by edges together, up to
1801    /// [`WRITE_BATCH`](super::WRITE_BATCH) of the two kinds between them, in one ordered request:
1802    /// every field value its `$sendField<n>` sends, then every edge its `$sendBlock<n>` sends.
1803    /// What a copy sends when one request of its writes holds both kinds; one holding a single
1804    /// kind is [`WRITE_FIELD_VALUES`] or [`ADD_BLOCKED_BYS`].
1805    pub const WRITE_FIELDS_AND_EDGES: &str = concat!(
1806        "mutation(",
1807        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1808        wide_variables!("block", "AddBlockedByInput", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1809        "){",
1810        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),
1811        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),
1812        "}"
1813    );
1814
1815    /// Closes up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues a copy created at a closing
1816    /// status, each with its own `stateInput` and nothing else, once its option is selected.
1817    pub const CLOSE_ISSUES: &str =
1818        write_batch!("close", "UpdateIssueInput", "updateIssue", "issue{id}");
1819
1820    /// Updates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) existing issues a copy rewrites,
1821    /// each with the members its own [`UPDATE_ISSUE`] would have sent, in one request.
1822    pub const UPDATE_ISSUES: &str =
1823        write_batch!("update", "UpdateIssueInput", "updateIssue", "issue{id}");
1824
1825    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) native blocked-by relationships a copy adds to the
1826    /// issues it created, in one request.
1827    pub const ADD_BLOCKED_BYS: &str = concat!(
1828        "mutation(",
1829        wide_variables!("block", "AddBlockedByInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1830        "){",
1831        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),
1832        "}"
1833    );
1834
1835    /// Removes one native issue blocked-by relationship.
1836    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1837    /// Deletes one issue, which takes its board item with it.
1838    ///
1839    /// The engine sends this in one situation only: undoing a copy that could not finish,
1840    /// over the items that same copy created. Deleting the issue removes the board item
1841    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1842    pub const DELETE_ISSUE: &str =
1843        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1844    /// Deletes up to [`DELETE_BATCH`](super::DELETE_BATCH) issues in one request, each as
1845    /// [`DELETE_ISSUE`] deletes one: what a write that created several issues and was refused
1846    /// part way sends to take them back.
1847    pub const DELETE_ISSUES: &str = concat!(
1848        "mutation(",
1849        wide_variables!("delete", "DeleteIssueInput", "send"; 0 1 2 3 4),
1850        "){",
1851        wide_slots!("delete", "deleteIssue", "repository{id}", "send"; 0 1 2 3 4),
1852        "}"
1853    );
1854
1855    /// One task's comments: a page of its issue's own `comments` connection.
1856    ///
1857    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1858    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1859    /// list every time somebody edited it; left unordered the connection answers in the order
1860    /// the comments were written, which is the order GitHub documents for the same collection
1861    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1862    /// node count and the caller's own page size is pushed straight down.
1863    pub const ISSUE_COMMENTS: &str = concat!(
1864        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1865        issue_comment!(),
1866        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1867    );
1868    /// One issue by its own node id, with a page of its comments: what `task show` and a
1869    /// comment listing read, in one request.
1870    ///
1871    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1872    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1873    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1874    /// connection there would multiply through both of those documents' price, and neither
1875    /// needs one.
1876    pub const ISSUE_DETAIL: &str = concat!(
1877        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1878        relationship_events_variable!(),
1879        r#"){
1880      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1881        issue_comment!(),
1882        r#"}pageInfo{hasNextPage endCursor}}}}
1883    }"#,
1884        board_issue!()
1885    );
1886
1887    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1888    /// page of its comments when `$comments` asks for them.
1889    macro_rules! issue_details_alias {
1890        ($n:literal) => {
1891            concat!(
1892                "\n      i",
1893                stringify!($n),
1894                ":node(id:$id",
1895                stringify!($n),
1896                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1897                issue_comment!(),
1898                "}pageInfo{hasNextPage endCursor}}}}"
1899            )
1900        };
1901    }
1902
1903    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1904    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1905    ///
1906    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1907    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1908    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1909    /// neither — so every connection under it would be priced at nothing and the pin in
1910    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1911    /// one-item read the model already prices, so the batch costs what its aliases cost.
1912    ///
1913    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1914    /// slots it has no item for to the last item it does, and reads that item again; the
1915    /// price is the document's, whatever its variables, so a short batch costs what a full
1916    /// one does and nothing more.
1917    pub const ISSUE_DETAILS: &str = concat!(
1918        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!"#,
1919        relationship_events_variable!(),
1920        r#"){"#,
1921        issue_details_alias!(0),
1922        issue_details_alias!(1),
1923        issue_details_alias!(2),
1924        issue_details_alias!(3),
1925        issue_details_alias!(4),
1926        issue_details_alias!(5),
1927        issue_details_alias!(6),
1928        issue_details_alias!(7),
1929        issue_details_alias!(8),
1930        issue_details_alias!(9),
1931        issue_details_alias!(10),
1932        issue_details_alias!(11),
1933        issue_details_alias!(12),
1934        issue_details_alias!(13),
1935        issue_details_alias!(14),
1936        issue_details_alias!(15),
1937        issue_details_alias!(16),
1938        issue_details_alias!(17),
1939        issue_details_alias!(18),
1940        issue_details_alias!(19),
1941        issue_details_alias!(20),
1942        issue_details_alias!(21),
1943        issue_details_alias!(22),
1944        issue_details_alias!(23),
1945        "\n    }",
1946        board_issue!()
1947    );
1948
1949    /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
1950    /// page of its comments and the issues it is blocked by.
1951    macro_rules! issue_copy_details_alias {
1952        ($n:literal) => {
1953            concat!(
1954                "\n      i",
1955                stringify!($n),
1956                ":node(id:$id",
1957                stringify!($n),
1958                "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
1959                issue_comment!(),
1960                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
1961            )
1962        };
1963    }
1964
1965    /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
1966    /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
1967    /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
1968    /// at a time, so a copy of a changed task costs this read and nothing else.
1969    ///
1970    /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
1971    /// what `task show-many` is priced by and which reads no edge.
1972    ///
1973    /// `children`, read only while `$children` holds, is which issues the issue `$project` has
1974    /// as sub-issues — each one's id and title, the page `$nestedFirst` allows — so that a
1975    /// project copy, which reads its project and every task at once, knows from this same read
1976    /// which tasks the project holds at the destination, and its orphan check is answered by it
1977    /// rather than by a listing of the project.
1978    pub const ISSUE_COPY_DETAILS: &str = concat!(
1979        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!"#,
1980        relationship_events_variable!(),
1981        r#"){"#,
1982        issue_copy_details_alias!(0),
1983        issue_copy_details_alias!(1),
1984        issue_copy_details_alias!(2),
1985        issue_copy_details_alias!(3),
1986        issue_copy_details_alias!(4),
1987        issue_copy_details_alias!(5),
1988        issue_copy_details_alias!(6),
1989        issue_copy_details_alias!(7),
1990        issue_copy_details_alias!(8),
1991        issue_copy_details_alias!(9),
1992        issue_copy_details_alias!(10),
1993        issue_copy_details_alias!(11),
1994        issue_copy_details_alias!(12),
1995        issue_copy_details_alias!(13),
1996        issue_copy_details_alias!(14),
1997        issue_copy_details_alias!(15),
1998        issue_copy_details_alias!(16),
1999        issue_copy_details_alias!(17),
2000        issue_copy_details_alias!(18),
2001        issue_copy_details_alias!(19),
2002        issue_copy_details_alias!(20),
2003        "\n      children:node(id:$project) @include(if:$children){... on Issue{id subIssues(first:$nestedFirst){nodes{id title} pageInfo{hasNextPage}}}}",
2004        "\n    }",
2005        board_issue!(),
2006        related_issue!()
2007    );
2008
2009    /// Which issue one comment is on, read before that comment is edited or removed.
2010    ///
2011    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
2012    /// comment id given against the wrong task would change a comment on another issue.
2013    pub const COMMENT_ISSUE: &str =
2014        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
2015    /// Adds one comment to an issue, signed as the account the token belongs to.
2016    pub const ADD_COMMENT: &str = concat!(
2017        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
2018        issue_comment!(),
2019        r#"}}}}"#
2020    );
2021    /// Replaces the body of one issue comment.
2022    pub const UPDATE_COMMENT: &str = concat!(
2023        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
2024        issue_comment!(),
2025        r#"}}}"#
2026    );
2027    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
2028    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
2029
2030    /// What a mutation of a write the store's cache expects selects of the issue it wrote:
2031    /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
2032    /// change token's every part and the first page of its comments.
2033    ///
2034    /// Sent **only** while that write runs, in place of the document named beside each
2035    /// below, so every other write's documents, prices and request counts are what they
2036    /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
2037    /// mutation's response is stamped seconds after the change it made, so against GitHub
2038    /// itself a response rarely determines a version; the cache is then left unserveable
2039    /// for the task, and its next read refreshes it.
2040    macro_rules! whole_issue {
2041        () => {
2042            concat!(
2043                r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
2044                issue_comment!(),
2045                r#"}pageInfo{hasNextPage endCursor}}}"#
2046            )
2047        };
2048    }
2049
2050    /// The variables every document of `whole_issue!` declares beside its own.
2051    macro_rules! whole_variables {
2052        () => {
2053            concat!(
2054                r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2055                relationship_events_variable!()
2056            )
2057        };
2058    }
2059
2060    /// [`ADD_COMMENT`], selecting the whole issue commented on.
2061    pub const ADD_COMMENT_WHOLE: &str = concat!(
2062        r#"mutation($input:AddCommentInput!"#,
2063        whole_variables!(),
2064        r#"){addComment(input:$input){subject{id "#,
2065        whole_issue!(),
2066        r#"} commentEdge{node{"#,
2067        issue_comment!(),
2068        r#"}}}}"#,
2069        board_issue!()
2070    );
2071    /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
2072    pub const UPDATE_COMMENT_WHOLE: &str = concat!(
2073        r#"mutation($input:UpdateIssueCommentInput!"#,
2074        whole_variables!(),
2075        r#"){updateIssueComment(input:$input){issueComment{"#,
2076        issue_comment!(),
2077        r#" issue{"#,
2078        whole_issue!(),
2079        r#"}}}}"#,
2080        board_issue!()
2081    );
2082    /// [`UPDATE_ISSUE`], selecting the whole issue updated.
2083    pub const UPDATE_ISSUE_WHOLE: &str = concat!(
2084        r#"mutation($input:UpdateIssueInput!"#,
2085        whole_variables!(),
2086        r#"){updateIssue(input:$input){issue{id "#,
2087        whole_issue!(),
2088        r#"}}}"#,
2089        board_issue!()
2090    );
2091    /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
2092    pub const UPDATE_FIELD_WHOLE: &str = concat!(
2093        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2094        whole_variables!(),
2095        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2096        whole_issue!(),
2097        r#"}}}}"#,
2098        board_issue!()
2099    );
2100    /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
2101    pub const CLEAR_FIELD_WHOLE: &str = concat!(
2102        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2103        whole_variables!(),
2104        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2105        whole_issue!(),
2106        r#"}}}}"#,
2107        board_issue!()
2108    );
2109
2110    /// The document sent in `document`'s place while a write the store's cache expects runs,
2111    /// when it has one.
2112    #[must_use]
2113    pub fn whole_twin(document: &str) -> Option<&'static str> {
2114        [
2115            (ADD_COMMENT, ADD_COMMENT_WHOLE),
2116            (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
2117            (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
2118            (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
2119            (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
2120        ]
2121        .into_iter()
2122        .find(|(plain, _)| *plain == document)
2123        .map(|(_, whole)| whole)
2124    }
2125
2126    /// Every document above, with what this source is doing when it sends one.
2127    ///
2128    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
2129    /// name the call that was refused, and a `match` with a catch-all arm would answer a
2130    /// document added later with "talking to GitHub" and never say so.
2131    ///
2132    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
2133    /// const` here that this list omits, so the two cannot part — which is the same guard
2134    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
2135    pub const DOCUMENTS: [(&str, &str); 57] = [
2136        (
2137            ISSUES_BY_CREATION,
2138            "listing a repository's issues newest first for a create key",
2139        ),
2140        (SEARCH_ISSUES, "searching this board's issues"),
2141        (ISSUE, "reading one issue"),
2142        (
2143            DOCUMENT_IN_PROJECT,
2144            "reading one document with its project and the project's members",
2145        ),
2146        (
2147            ISSUE_BOARD_ITEMS,
2148            "reading one issue's board memberships past the page it came with",
2149        ),
2150        (SUB_ISSUES, "reading a project's tasks"),
2151        (BOARD, "reading the board"),
2152        (
2153            BOARD_WITH_COMMENTS,
2154            "reading a board page with first comments",
2155        ),
2156        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
2157        (CLOSED_ITEMS, "reading the board's closed items"),
2158        (
2159            ISSUES_BY_REFERENCE,
2160            "reading a batch of issues by their references",
2161        ),
2162        (BOARD_FIELDS, "reading the board's fields"),
2163        (DRAFT, "reading one draft"),
2164        (REPOSITORY, "reading the destination repository"),
2165        (
2166            PROJECT_VISIBILITY,
2167            "reading whether the board's project is public",
2168        ),
2169        (
2170            CREATION_CONTEXT,
2171            "reading the board's fields and the destination repository",
2172        ),
2173        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
2174        (ISSUE_DEPENDS_ON, "reading what an item depends on"),
2175        (ISSUE_DEPENDED_ON_BY, "reading what depends on an issue"),
2176        (CREATE_ISSUE, "creating an issue"),
2177        (
2178            ORIGIN_LOOKUPS,
2179            "looking up the items copied from several origins, and what creating one reads",
2180        ),
2181        (
2182            ORIGIN_LOOKUPS_WITH_MEMBERS,
2183            "looking up the items copied from several origins, with a project's members",
2184        ),
2185        (CREATE_ISSUES, "creating several issues together"),
2186        (
2187            CREATED_BOARD_ITEMS,
2188            "reading the board items of the issues a copy created",
2189        ),
2190        (
2191            WRITE_FIELD_VALUES,
2192            "writing board fields of the items a copy created",
2193        ),
2194        (
2195            WRITE_FIELDS_AND_EDGES,
2196            "writing board fields and dependencies of the items a copy created",
2197        ),
2198        (CLOSE_ISSUES, "closing the issues a copy created"),
2199        (UPDATE_ISSUES, "updating several issues together"),
2200        (ADD_BLOCKED_BYS, "recording several dependencies together"),
2201        (ADD_TO_BOARD, "adding an issue to the board"),
2202        (UPDATE_ISSUE, "updating an issue"),
2203        (UPDATE_DRAFT, "updating a draft item"),
2204        (UPDATE_FIELD, "writing a board field"),
2205        (UPDATE_FIELDS, "writing board fields together"),
2206        (CLEAR_FIELD, "clearing a board field"),
2207        (
2208            CREATE_FIELD,
2209            "creating a board single-select field with its options",
2210        ),
2211        (
2212            STATUS_OPTIONS_SNAPSHOT,
2213            "snapshotting board Status options and assignments",
2214        ),
2215        (
2216            STATUS_OPTIONS_UPDATE,
2217            "safely replacing the board Status option list",
2218        ),
2219        (ADD_SUB_ISSUE, "filing an issue under its project"),
2220        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
2221        (ADD_BLOCKED_BY, "recording a dependency"),
2222        (REMOVE_BLOCKED_BY, "removing a dependency"),
2223        (DELETE_ISSUE, "deleting an issue"),
2224        (DELETE_ISSUES, "deleting several issues together"),
2225        (ISSUE_COMMENTS, "reading a task's comments"),
2226        (ISSUE_DETAIL, "reading one issue with its comments"),
2227        (
2228            ISSUE_DETAILS,
2229            "reading a batch of issues with their comments",
2230        ),
2231        (
2232            ISSUE_COPY_DETAILS,
2233            "reading a batch of issues with their comments and dependencies for a copy",
2234        ),
2235        (COMMENT_ISSUE, "reading which issue a comment is on"),
2236        (ADD_COMMENT, "adding a comment"),
2237        (UPDATE_COMMENT, "editing a comment"),
2238        (DELETE_COMMENT, "deleting a comment"),
2239        (
2240            ADD_COMMENT_WHOLE,
2241            "adding a comment, answered with the whole issue",
2242        ),
2243        (
2244            UPDATE_COMMENT_WHOLE,
2245            "editing a comment, answered with the whole issue",
2246        ),
2247        (
2248            UPDATE_ISSUE_WHOLE,
2249            "updating an issue, answered with it whole",
2250        ),
2251        (
2252            UPDATE_FIELD_WHOLE,
2253            "writing a board field, answered with the whole issue",
2254        ),
2255        (
2256            CLEAR_FIELD_WHOLE,
2257            "clearing a board field, answered with the whole issue",
2258        ),
2259    ];
2260}
2261
2262/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
2263/// under, the variable carrying its input, and the boolean variable that includes it.
2264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2265pub struct FieldSlot {
2266    /// The key of the answer's `data` this field's payload is under.
2267    pub alias: &'static str,
2268    /// The variable carrying this field's input.
2269    pub variable: &'static str,
2270    /// The boolean variable whose `true` sends this field.
2271    pub include: &'static str,
2272}
2273
2274/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
2275pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
2276    FieldSlot {
2277        alias: "updateProjectV2ItemFieldValue",
2278        variable: "input",
2279        include: "writeFirst",
2280    },
2281    FieldSlot {
2282        alias: "second",
2283        variable: "second",
2284        include: "writeSecond",
2285    },
2286    FieldSlot {
2287        alias: "third",
2288        variable: "third",
2289        include: "writeThird",
2290    },
2291    FieldSlot {
2292        alias: "fourth",
2293        variable: "fourth",
2294        include: "writeFourth",
2295    },
2296    FieldSlot {
2297        alias: "fifth",
2298        variable: "fifth",
2299        include: "writeFifth",
2300    },
2301    FieldSlot {
2302        alias: "sixth",
2303        variable: "sixth",
2304        include: "writeSixth",
2305    },
2306];
2307
2308/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
2309pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
2310    FieldSlot {
2311        alias: "cleared",
2312        variable: "clear",
2313        include: "writeClear",
2314    },
2315    FieldSlot {
2316        alias: "clearedSecond",
2317        variable: "clearSecond",
2318        include: "writeClearSecond",
2319    },
2320    FieldSlot {
2321        alias: "clearedThird",
2322        variable: "clearThird",
2323        include: "writeClearThird",
2324    },
2325];
2326
2327/// Which of GitHub's two rate limiters refused a request.
2328///
2329/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
2330/// secondary one — so an operator told the wrong one takes the wrong next step, which is
2331/// the whole reason this is carried rather than collapsed into "rate limited".
2332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2333enum Limiter {
2334    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
2335    Primary,
2336    /// The burst limiter over content-generating requests, which nothing reports.
2337    Secondary,
2338}
2339
2340/// The wordings GitHub answers a secondary rate limit with.
2341///
2342/// It sends them under a forbidden status, under a too-many-requests status, and inside
2343/// the `errors` of a *successful* response, which is why the text is what this matches on
2344/// rather than the status. `abuse detection` is the wording GitHub used before the
2345/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
2346/// what a burst of content creation is refused with.
2347///
2348/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
2349/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
2350/// when, and the drift gate reconciles the two lists both ways. Public for that gate
2351/// alone — a caller has no use for it, and matching on a refusal is this source's job.
2352pub const SECONDARY_WORDINGS: [&str; 5] = [
2353    "secondary rate limit",
2354    "temporarily blocked from content creation",
2355    "abuse detection",
2356    "submitted too quickly",
2357    "exceeded a secondary",
2358];
2359
2360/// The wordings GitHub answers an exhausted primary budget with.
2361///
2362/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
2363/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
2364/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
2365/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
2366/// two phrases is a substring of it, so without it that answer read as a refusal that will
2367/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
2368/// one reason.
2369pub const PRIMARY_WORDINGS: [&str; 4] = [
2370    "api rate limit exceeded",
2371    "api rate limit already exceeded",
2372    "rate limit exceeded",
2373    "rate_limited",
2374];
2375
2376/// The wordings GitHub answers a write that collided with another change to the same
2377/// ordering with, in the `errors` of an HTTP 200: "Your attempt to move this item created a
2378/// temporary conflict. Please try again." The write did not take effect and GitHub asks for it
2379/// again, so it is retried on the same growing schedule a rate limit without a hint is, within
2380/// the same retry budget — except a `createIssue`, whose answer cannot say the issue was not
2381/// made, and which is therefore reported rather than replayed into a duplicate.
2382///
2383/// Observed rather than documented, so it is pinned exactly as [`SECONDARY_WORDINGS`] is:
2384/// `tests/fixtures/rate-limits.json` records where it was read under `conflict.observed`, and
2385/// the same drift gate reconciles the two both ways. Public for that gate alone.
2386pub const CONFLICT_WORDINGS: [&str; 1] = ["created a temporary conflict"];
2387
2388/// What a response *says about itself*, which is the only place a refusal can be read.
2389///
2390/// Deliberately not the whole response body. A board is a place people write about their
2391/// own work, and a task on it titled "the secondary rate limit" would, matched across the
2392/// raw text, turn a perfectly good answer into a refusal this source then waited out and
2393/// reported. So the item data is never read: what is read is GitHub's own REST-style
2394/// `message` envelope, which is what a forbidden status carries, and the `message` and
2395/// `type` of each GraphQL error, which is where a *successful* response says it.
2396///
2397/// A body that is not JSON at all has nothing structured to read, so only a failing
2398/// response's own text is taken — a successful response that is not JSON is malformed
2399/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
2400fn refusal_wording(status: StatusCode, body: &str) -> String {
2401    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
2402        return if status.is_success() {
2403            String::new()
2404        } else {
2405            body.to_owned()
2406        };
2407    };
2408    let mut said: Vec<&str> = parsed
2409        .get("message")
2410        .and_then(Value::as_str)
2411        .into_iter()
2412        .collect();
2413    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
2414        for error in errors {
2415            said.extend(
2416                ["message", "type"]
2417                    .into_iter()
2418                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
2419            );
2420        }
2421    }
2422    said.join("; ")
2423}
2424
2425impl Limiter {
2426    /// Which limiter refused this response, or `None` when none of them did.
2427    ///
2428    /// The wording is read first and the status only decides what carries none of it,
2429    /// because GitHub answers a secondary limit with a forbidden status far more often
2430    /// than with too-many-requests — while a forbidden status saying nothing about a limit
2431    /// really is a credential this token lacks.
2432    ///
2433    /// A response is a refusal because of its status or its own wording. A spent budget
2434    /// only ever explains one; it never turns an answer into a refusal.
2435    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
2436        let normalized = refusal_wording(status, body).to_ascii_lowercase();
2437        if SECONDARY_WORDINGS
2438            .iter()
2439            .any(|wording| normalized.contains(wording))
2440        {
2441            return Some(Self::Secondary);
2442        }
2443        if status == StatusCode::TOO_MANY_REQUESTS {
2444            return Some(Self::Primary);
2445        }
2446        // An exhausted budget *explains* a response that failed; it does not make one that
2447        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
2448        // request the budget allowed as well as on the ones it then refuses, so reading
2449        // the header alone threw away a good answer — and, once refusals were retried,
2450        // replayed a request that had already taken effect.
2451        if !status.is_success() && budget_exhausted {
2452            return Some(Self::Primary);
2453        }
2454        // A successful response saying it: GitHub reports a GraphQL rate limit in the
2455        // `errors` of an HTTP 200, where nothing about the status says so at all.
2456        if status.is_success()
2457            && PRIMARY_WORDINGS
2458                .iter()
2459                .any(|wording| normalized.contains(wording))
2460        {
2461            return Some(Self::Primary);
2462        }
2463        None
2464    }
2465
2466    /// What this limiter is called where an operator can look it up.
2467    const fn name(self) -> &'static str {
2468        match self {
2469            Self::Primary => "GitHub's primary API rate limit",
2470            Self::Secondary => "GitHub's secondary rate limit",
2471        }
2472    }
2473
2474    /// What the endpoint an operator would go and check says about this limiter.
2475    const fn where_to_look(self) -> &'static str {
2476        match self {
2477            Self::Primary => {
2478                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
2479                 comes back."
2480            }
2481            Self::Secondary => {
2482                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
2483                 primary budget and does not report this one, so budget showing there says \
2484                 nothing about this refusal, and every further attempt extends it."
2485            }
2486        }
2487    }
2488
2489    /// The next step this limiter actually calls for.
2490    const fn what_to_do(self) -> &'static str {
2491        match self {
2492            Self::Primary => {
2493                "wait for the reset `gh api rate_limit` reports, then run the command again."
2494            }
2495            Self::Secondary => {
2496                "leave this board alone for a few minutes, then run the command again — or \
2497                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
2498            }
2499        }
2500    }
2501}
2502
2503/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
2504#[derive(Debug, Clone, Copy)]
2505struct Limited {
2506    limiter: Limiter,
2507    hint: Option<u64>,
2508    /// Which header the hint came from: `retry-after`, or an exhausted primary budget's
2509    /// `x-ratelimit-reset`.
2510    hinted_by: Hint,
2511}
2512
2513/// Where a refusal's hint came from, which is what its wait is named after.
2514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2515enum Hint {
2516    /// `retry-after`, or no hint at all.
2517    RetryAfter,
2518    /// An exhausted primary budget's `x-ratelimit-reset`.
2519    PrimaryReset,
2520}
2521
2522impl Limited {
2523    /// The wait this refusal is answered with, when the backing-off schedule is at `backoff`,
2524    /// and the cause it is named by: the hint, raised to the schedule's own next wait —
2525    /// GitHub really does send `retry-after: 0`, and retrying at once is the one move that
2526    /// extends a secondary limit — or the schedule alone when there is none.
2527    fn wait(self, backoff: Duration) -> (Duration, WaitCause) {
2528        match (self.hint, self.hinted_by) {
2529            (Some(hint), Hint::RetryAfter) => (
2530                Duration::from_secs(hint).max(backoff),
2531                pacing::cause::RETRY_AFTER,
2532            ),
2533            (Some(hint), Hint::PrimaryReset) => (
2534                Duration::from_secs(hint).max(backoff),
2535                pacing::cause::PRIMARY_RESET,
2536            ),
2537            (None, _) => (backoff, pacing::cause::BACKOFF),
2538        }
2539    }
2540
2541    /// What the caller is told once this source has waited as long as it may.
2542    ///
2543    /// Both limiters report as [`SourceError::RateLimited`], because that is what
2544    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
2545    /// about *which* limiter it was makes it a different kind of failure. What differs is
2546    /// the operator's next step, and that is what the message carries — a secondary
2547    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
2548    /// budget looks fine, and then back to retry the very burst that was refused.
2549    fn exhausted(
2550        self,
2551        doing: &str,
2552        waits: u32,
2553        waited: Duration,
2554        needed: Duration,
2555        budget: Duration,
2556    ) -> SourceError {
2557        SourceError::RateLimited {
2558            retry_after_seconds: self.hint,
2559            message: Some(format!(
2560                "{} refused this source while {doing}; it waited {} out over {} and was refused \
2561                 again, and the next wait of {} would take it past the {} one call may spend \
2562                 waiting. {} next: {}",
2563                self.limiter.name(),
2564                plural(waits, "refusal"),
2565                seconds(waited),
2566                seconds(needed),
2567                seconds(budget),
2568                self.limiter.where_to_look(),
2569                self.limiter.what_to_do(),
2570            )),
2571        }
2572    }
2573}
2574
2575/// One HTTP attempt's result, with what its response said about the rate limit.
2576///
2577/// The two travel together so the record and the outcome are written from the same place:
2578/// what a response said about the budget is only readable while that response is in hand,
2579/// and what the attempt *meant* is only decidable once its body has been read.
2580struct Attempted {
2581    result: Result<Value, Attempt>,
2582    limits: accounting::RateLimit,
2583    /// GitHub's own reported cost for this call, for a document that asked for it.
2584    reported_cost: Option<u64>,
2585    response_outcome: accounting::ResponseOutcome,
2586}
2587
2588/// One attempt's outcome: an error to report, or a rate limit to wait out.
2589enum Attempt {
2590    Failed(SourceError),
2591    Limited(Limited),
2592    /// GitHub refused a write as a temporary conflict and asked for it again — see
2593    /// [`CONFLICT_WORDINGS`]. Carries what GitHub said, for the diagnostic once retrying ends.
2594    Conflicted(String),
2595}
2596
2597fn plural(count: u32, thing: &str) -> String {
2598    if count == 1 {
2599        format!("{count} {thing}")
2600    } else {
2601        format!("{count} {thing}s")
2602    }
2603}
2604
2605fn seconds(duration: Duration) -> String {
2606    format!("{:.1}s", duration.as_secs_f64())
2607}
2608
2609/// The member [`stamp_served`] writes onto every node of a response that says when it last
2610/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
2611const SERVED_AT: &str = "onetaskgraphServedAt";
2612
2613/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
2614/// node's time later knows the server's clock on the response that read it — which is what
2615/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
2616fn stamp_served(data: &mut Value, served: &str) {
2617    match data {
2618        Value::Object(members) => {
2619            if members.contains_key("updatedAt") {
2620                members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
2621            }
2622            for member in members.values_mut() {
2623                stamp_served(member, served);
2624            }
2625        }
2626        Value::Array(items) => {
2627            for item in items {
2628                stamp_served(item, served);
2629            }
2630        }
2631        _ => {}
2632    }
2633}
2634
2635/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
2636///
2637/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
2638/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
2639/// header, and neither is what makes a response a refusal — so the whole cost of one this
2640/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
2641/// instead. Refusing the response over the header would turn a readable refusal into an
2642/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
2643fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
2644    value
2645        .and_then(|value| value.to_str().ok())
2646        .and_then(|value| value.trim().parse::<u64>().ok())
2647}
2648
2649/// A transport failure with every cause beneath it, outermost first.
2650///
2651/// The client's own text for a request it could not complete is `error sending request for
2652/// url (…)`, and the socket error that says why — a refused or reset connection, an exhausted
2653/// port range, a connection closed before its answer — is only in the chain of sources below
2654/// it. Those differ in what to do next, so all of them are kept.
2655pub(crate) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String {
2656    let mut text = error.to_string();
2657    let mut cause = error.source();
2658    while let Some(next) = cause {
2659        let said = next.to_string();
2660        if !text.ends_with(&said) {
2661            text = format!("{text}: {said}");
2662        }
2663        cause = next.source();
2664    }
2665    text
2666}
2667
2668/// Every mutation this source sends creates content — an issue, a board item, a field of
2669/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
2670/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
2671/// and what the keyword says are the same set. That is what makes the keyword a sound test
2672/// rather than a convenient one: counting an edit or a removal the limiter might not have
2673/// counted costs a wait, and not counting one it did count costs the next fifty minutes.
2674pub(crate) fn is_mutation(query: &str) -> bool {
2675    query.trim_start().starts_with("mutation")
2676}
2677
2678/// What this source was doing, for a diagnostic that has to say so.
2679///
2680/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
2681/// a document added without a description is caught by that list's own gate instead of
2682/// falling through to the vague arm below.
2683fn operation_description(query: &str) -> &'static str {
2684    graphql::DOCUMENTS
2685        .iter()
2686        .find(|(document, _)| *document == query)
2687        .map_or("talking to GitHub", |(_, doing)| *doing)
2688}
2689
2690/// GitHub's published ceiling on content-generating requests in any one minute.
2691///
2692/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
2693/// GitHub's number rather than this source's. This source paces at it over its window: a
2694/// content-creating request goes out at once while the minute's creations leave room for it,
2695/// and waits until they do otherwise — see the `pacing` module.
2696pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
2697/// The same ceiling as GitHub publishes it for any one hour, paced over its window as the
2698/// per-minute one is. Pinned beside its sibling so the gate would notice either one moving.
2699pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
2700/// GitHub's published ceiling on secondary points in any one minute: what every GraphQL
2701/// request is charged against, a mutation [`SECONDARY_POINTS_PER_MUTATION`] and a query
2702/// [`SECONDARY_POINTS_PER_QUERY`]. Pinned and paced as [`CONTENT_CREATION_PER_MINUTE`] is.
2703pub const SECONDARY_POINTS_PER_MINUTE: u64 = 2_000;
2704/// The secondary points one GraphQL request carrying a mutation is charged.
2705pub const SECONDARY_POINTS_PER_MUTATION: u64 = 5;
2706/// The secondary points one GraphQL request carrying no mutation is charged.
2707pub const SECONDARY_POINTS_PER_QUERY: u64 = 1;
2708/// The fastest *steady* spacing of content-creating mutations that cannot exceed
2709/// [`CONTENT_CREATION_PER_MINUTE`], in milliseconds: 60000/80 is 750.
2710///
2711/// It is no longer the default. This source paces by GitHub's windows instead, so a copy's
2712/// few dozen writes go out as fast as GitHub answers them while the minute has room, rather
2713/// than 750 ms apart — which was most of a plan copy's wall time. An installation that wants
2714/// mutations spaced as well says so through `pacing.min_mutation_interval_ms`, and this is the
2715/// value that holds a long sustained burst under the per-minute bound by spacing alone.
2716pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
2717/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
2718///
2719/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
2720/// own advice for a secondary limit — wait, and wait longer each time — without spending
2721/// the first minute of a transient refusal doing nothing.
2722pub const RETRY_BACKOFF_MS: u64 = 1_000;
2723/// Total time one call may spend waiting out rate limits before it reports a failure.
2724///
2725/// Two minutes is long enough to ride out the refusals a paced copy still collects and
2726/// short enough that a command an operator is watching returns. The bound is what makes
2727/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
2728/// the limiter, not in a process nobody can tell from a wedged one.
2729pub const RETRY_BUDGET_MS: u64 = 120_000;
2730
2731fn default_token_env() -> String {
2732    "GH_PROJECTS_TOKEN".to_owned()
2733}
2734fn default_endpoint() -> String {
2735    "https://api.github.com/graphql".to_owned()
2736}
2737
2738/// The name of a `Status` single-select option on the board.
2739///
2740/// Validated on the way in rather than checked later, so a blank option name — which
2741/// nothing on a board can be — is a state this type cannot hold.
2742#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2743#[serde(try_from = "String")]
2744#[schemars(extend("minLength" = 1))]
2745pub struct ColumnName(String);
2746
2747impl ColumnName {
2748    /// The option name, as the board spells it.
2749    fn as_str(&self) -> &str {
2750        &self.0
2751    }
2752}
2753
2754impl TryFrom<String> for ColumnName {
2755    type Error = String;
2756
2757    fn try_from(name: String) -> Result<Self, Self::Error> {
2758        if name.trim().is_empty() {
2759            return Err("a status_mapping option name cannot be blank".to_owned());
2760        }
2761        Ok(Self(name))
2762    }
2763}
2764
2765/// The two closed states this product can mean.
2766///
2767/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
2768/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
2769/// product never decides on a person's behalf that two issues are the same work.
2770#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
2771#[serde(rename_all = "kebab-case")]
2772pub enum ClosedState {
2773    /// `COMPLETED` — precisely done.
2774    Completed,
2775    /// `NOT_PLANNED` — precisely cancelled.
2776    NotPlanned,
2777}
2778
2779impl ClosedState {
2780    const fn reason(self) -> &'static str {
2781        match self {
2782            Self::Completed => "COMPLETED",
2783            Self::NotPlanned => "NOT_PLANNED",
2784        }
2785    }
2786}
2787
2788/// Configuration for one GitHub Projects v2 board.
2789///
2790/// It serializes to a document this same type reads back as itself, so a caller writing a
2791/// configuration — a consumer filing onto a board it configures — can build one here rather
2792/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
2793#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2794#[serde(default, deny_unknown_fields)]
2795pub struct GitHubProjectsConfig {
2796    /// Login of the user or organization which owns the board.
2797    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
2798    /// The project number shown in the board's GitHub URL.
2799    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
2800    // 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.
2801    /// `owner/name` of the repository this source creates an issue in when the item's own
2802    /// `repositories` field does not decide it.
2803    ///
2804    /// An item naming exactly one repository is created there — on a source declared
2805    /// `visibility: private`, only when that repository reads private, and otherwise as an
2806    /// item naming none is; a task or a document naming none or several is created in its
2807    /// parent project's repository; and a project, or a task or document with no parent,
2808    /// naming none or several is created here. A board
2809    /// has no repository of its own and `createIssue` requires one, so a write without
2810    /// this is refused naming the field. Reads never need it.
2811    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
2812    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2813    /// GitHub logins whose repositories this source may create an issue in.
2814    ///
2815    /// Absent or empty, the default, restricts nothing. Otherwise a create whose issue the
2816    /// rule `repository` describes would file in a repository owned by anyone else is refused
2817    /// before anything of it is written — by `task copy`, `task copy --create`, a member
2818    /// create of a project copy and a dry run of each — with the error kind `creation-owner`,
2819    /// naming the repository and these owners. Logins compare case-insensitively, as GitHub
2820    /// compares them. An update of an issue that already exists is never refused by it.
2821    /// `task locate` reports such a refusal as `creates_in.refused` rather than failing.
2822    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2823    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
2824    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
2825    #[schemars(!skip_serializing_if)]
2826    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.
2827    /// Environment variable containing a fine-grained token with Projects and Issues
2828    /// read/write plus Pull requests read-only access for every repository represented on
2829    /// the board.
2830    #[serde(default = "default_token_env")]
2831    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
2832    /// GraphQL endpoint. GitHub Enterprise installations may override it.
2833    #[serde(default = "default_endpoint")]
2834    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
2835    /// Per-instance mapping from a status category to the option of the board's one
2836    /// `Status` field it lands on, for a task and for a project.
2837    ///
2838    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
2839    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
2840    /// where a kind left out leaves the category unmapped for that kind. A category this
2841    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
2842    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
2843    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
2844    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
2845    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
2846    /// either kind. No two categories may name one option for the same kind, ignoring case.
2847    /// `unknown` may name one existing option; every unknown word then lands on it and
2848    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
2849    /// each unknown word because it never creates board options.
2850    #[serde(default)]
2851    pub status_mapping: StatusMapping,
2852    /// Per-instance mapping from a task's priority to an option of this board's
2853    /// single-select field named `Priority`.
2854    ///
2855    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
2856    /// other priority is refused before it reaches this board. Present, each of `urgent`,
2857    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
2858    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
2859    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
2860    /// no two levels may name one option. Reads and writes never create the field or an
2861    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
2862    /// the board lacks is refused pointing there.
2863    #[serde(default)]
2864    pub priority_mapping: Option<PriorityMappingConfig>,
2865    /// Caller metadata values this source also writes to board text fields, so the board can
2866    /// filter on them.
2867    ///
2868    /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
2869    /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
2870    /// value. On every item write — create, update and copy, of a task, a project or a
2871    /// document — a string found there is written to the field, and nothing is sent when the
2872    /// field already holds it; nothing there, or `null`, clears the field when it holds a
2873    /// value; any other JSON type is refused before any mutation. The metadata comment in the
2874    /// issue body stays the one home of the value, and every read reports metadata from it.
2875    /// Reads and writes never create the field: `onetaskgraph sources fields <source>
2876    /// --apply` does, as a text field, and a write to a board lacking it, or holding a
2877    /// non-text field of that name, is refused pointing there.
2878    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2879    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
2880    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
2881    #[schemars(!skip_serializing_if)]
2882    pub metadata_fields: Vec<MetadataFieldConfig>,
2883    /// How fast this source writes, and how long it waits out a rate-limit refusal.
2884    ///
2885    /// Every field keeps its shipped default when it is absent, and the defaults are
2886    /// GitHub's own published limits rather than taste. See [`Pacing`].
2887    #[serde(default)]
2888    pub pacing: PacingConfig,
2889}
2890
2891/// Which option of the board's `Priority` field each priority lands on.
2892///
2893/// One member per level rather than a map, so a key that is not a level is refused where
2894/// the configuration is read, naming the levels there are. `none` is not a member: it is no
2895/// value in the field, not an option of it.
2896#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2897#[serde(default, deny_unknown_fields)]
2898pub struct PriorityMappingConfig {
2899    /// The option `urgent` lands on; `Urgent` when absent.
2900    pub urgent: Option<PriorityOptionName>,
2901    /// The option `high` lands on; `High` when absent.
2902    pub high: Option<PriorityOptionName>,
2903    /// The option `medium` lands on; `Medium` when absent.
2904    pub medium: Option<PriorityOptionName>,
2905    /// The option `low` lands on; `Low` when absent.
2906    pub low: Option<PriorityOptionName>,
2907}
2908
2909/// The name of an option of the board's `Priority` single-select field.
2910///
2911/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
2912/// blank name.
2913#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2914#[serde(try_from = "String")]
2915#[schemars(extend("minLength" = 1))]
2916pub struct PriorityOptionName(String);
2917
2918impl PriorityOptionName {
2919    /// The option name, as the board spells it.
2920    fn as_str(&self) -> &str {
2921        &self.0
2922    }
2923}
2924
2925impl TryFrom<String> for PriorityOptionName {
2926    type Error = String;
2927
2928    fn try_from(name: String) -> Result<Self, Self::Error> {
2929        if name.trim().is_empty() {
2930            return Err("a priority_mapping option name cannot be blank".to_owned());
2931        }
2932        Ok(Self(name))
2933    }
2934}
2935
2936/// The name of the board field a priority is held in.
2937pub const PRIORITY_FIELD: &str = "Priority";
2938
2939/// The four priorities a board option can hold, in the order a new `Priority` field lists
2940/// them. `none` is not among them: it is the field holding no value.
2941///
2942/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
2943/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
2944/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
2945/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
2946pub const PRIORITY_LEVELS: [Priority; 4] = [
2947    Priority::Urgent,
2948    Priority::High,
2949    Priority::Medium,
2950    Priority::Low,
2951];
2952
2953/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
2954/// see that list for what this pins.
2955#[must_use]
2956pub const fn level_position(priority: Priority) -> Option<usize> {
2957    match priority {
2958        Priority::None => None,
2959        Priority::Urgent => Some(0),
2960        Priority::High => Some(1),
2961        Priority::Medium => Some(2),
2962        Priority::Low => Some(3),
2963    }
2964}
2965
2966/// This instance's complete priority-to-option mapping, read in both directions.
2967///
2968/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
2969/// two levels name one option.
2970#[derive(Debug, Clone)]
2971struct PriorityMapping {
2972    options: [PriorityOptionName; 4],
2973}
2974
2975impl PriorityMapping {
2976    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2977        let shipped = |name: &str| PriorityOptionName(name.to_owned());
2978        let mapping = Self {
2979            options: [
2980                config.urgent.unwrap_or_else(|| shipped("Urgent")),
2981                config.high.unwrap_or_else(|| shipped("High")),
2982                config.medium.unwrap_or_else(|| shipped("Medium")),
2983                config.low.unwrap_or_else(|| shipped("Low")),
2984            ],
2985        };
2986        for (index, option) in mapping.options.iter().enumerate() {
2987            if let Some(earlier) = mapping.options[..index]
2988                .iter()
2989                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
2990            {
2991                return Err(SourceError::Config {
2992                    message: format!(
2993                        "priority_mapping of source {instance} sends both {} and {} to the board \
2994                         option {:?}; one option cannot read back as two priorities",
2995                        PRIORITY_LEVELS[earlier],
2996                        PRIORITY_LEVELS[index],
2997                        option.as_str()
2998                    ),
2999                });
3000            }
3001        }
3002        Ok(mapping)
3003    }
3004
3005    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
3006    fn option(&self, priority: Priority) -> Option<&str> {
3007        level_position(priority).map(|index| self.options[index].as_str())
3008    }
3009
3010    /// The priority a board option name reports, or `None` when nothing maps to it.
3011    fn priority_of(&self, option: &str) -> Option<Priority> {
3012        self.options
3013            .iter()
3014            .position(|name| name.as_str().eq_ignore_ascii_case(option))
3015            .map(|index| PRIORITY_LEVELS[index])
3016    }
3017
3018    /// Every mapped option name, in the order a new `Priority` field lists them.
3019    fn names(&self) -> impl Iterator<Item = &str> {
3020        self.options.iter().map(PriorityOptionName::as_str)
3021    }
3022}
3023
3024/// One metadata value this source projects onto a board text field.
3025#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3026#[serde(deny_unknown_fields)]
3027pub struct MetadataFieldConfig {
3028    /// The name of the board text field the value is written to.
3029    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.
3030    /// The top-level metadata key the value is read from. A key under the reserved
3031    /// `onetaskgraph.` prefix is refused.
3032    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.
3033    /// Object keys walked inside that key's value, outermost first. Absent or empty, the
3034    /// key's own value is the one projected. Left out of a serialized entry when empty.
3035    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3036    // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
3037    #[schemars(!skip_serializing_if)]
3038    pub path: Vec<String>,
3039}
3040
3041/// The board fields no metadata value may be projected onto, compared ignoring case: the
3042/// three this source writes itself, and those GitHub owns on every board.
3043// 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.
3044const UNPROJECTABLE_FIELDS: [&str; 13] = [
3045    STATUS_FIELD,
3046    PRIORITY_FIELD,
3047    ORIGIN_FIELD,
3048    "Title",
3049    "Assignees",
3050    "Labels",
3051    "Linked pull requests",
3052    "Milestone",
3053    "Repository",
3054    "Reviewers",
3055    "Parent issue",
3056    "Sub-issues progress",
3057    "Type",
3058];
3059
3060/// One validated [`MetadataFieldConfig`] entry.
3061#[derive(Debug, Clone)]
3062struct MetadataField {
3063    field: ProjectedField,
3064    key: ProjectedKey,
3065    path: Vec<String>,
3066}
3067
3068/// The name of a board text field a metadata value is projected onto: never blank, never one
3069/// this source or GitHub writes, and never one another entry of the same source names.
3070#[derive(Debug, Clone, PartialEq, Eq)]
3071struct ProjectedField(String);
3072
3073/// The top-level metadata key a projected value is read from: never blank, and never under
3074/// the namespace this product reserves.
3075#[derive(Debug, Clone, PartialEq, Eq)]
3076struct ProjectedKey(String);
3077
3078impl std::ops::Deref for ProjectedField {
3079    type Target = str;
3080    fn deref(&self) -> &str {
3081        &self.0
3082    }
3083}
3084
3085impl std::ops::Deref for ProjectedKey {
3086    type Target = str;
3087    fn deref(&self) -> &str {
3088        &self.0
3089    }
3090}
3091
3092/// What one item's metadata says one projected field should hold.
3093#[derive(Debug, Clone, PartialEq, Eq)]
3094enum Projected {
3095    /// A string to hold.
3096    Text(String),
3097    /// Nothing: the key or a step of the path is absent, or the value is `null`.
3098    Nothing,
3099}
3100
3101impl MetadataField {
3102    /// Validate every entry of one instance's `metadata_fields`, refusing the first that
3103    /// cannot stand, naming the source and the entry.
3104    fn resolve(
3105        configured: Vec<MetadataFieldConfig>,
3106        instance: &SourceName,
3107    ) -> Result<Vec<Self>, SourceError> {
3108        let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
3109        for (index, entry) in configured.into_iter().enumerate() {
3110            let refuse = |why: String| SourceError::Config {
3111                message: format!(
3112                    "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
3113                    entry.field, entry.key
3114                ),
3115            };
3116            if entry.field.trim().is_empty() {
3117                return Err(refuse("names a blank field".to_owned()));
3118            }
3119            if entry.key.trim().is_empty() {
3120                return Err(refuse("names a blank key".to_owned()));
3121            }
3122            if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
3123                return Err(refuse(format!(
3124                    "names a key under the reserved \"{}.\" prefix, which this product keeps \
3125                     for its own keys; next: project a caller key instead",
3126                    MetadataKey::RESERVED_NAMESPACE
3127                )));
3128            }
3129            if let Some(owned) = UNPROJECTABLE_FIELDS
3130                .iter()
3131                .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
3132            {
3133                return Err(refuse(format!(
3134                    "names the board field {owned:?}, which this source or GitHub already \
3135                     writes; next: name a text field of your own"
3136                )));
3137            }
3138            if let Some(earlier) = resolved
3139                .iter()
3140                .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
3141            {
3142                return Err(refuse(format!(
3143                    "names the same board field as metadata_fields[{earlier}]; one field \
3144                     cannot hold two values"
3145                )));
3146            }
3147            resolved.push(Self {
3148                field: ProjectedField(entry.field),
3149                key: ProjectedKey(entry.key),
3150                path: entry.path,
3151            });
3152        }
3153        Ok(resolved)
3154    }
3155
3156    /// The path as a refusal spells it: `[]` for the key's own value.
3157    fn spelled_path(&self) -> String {
3158        serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
3159    }
3160
3161    /// What `metadata` says this field should hold, or the refusal for a value no text field
3162    /// can hold.
3163    fn projected(
3164        &self,
3165        metadata: &BTreeMap<String, Value>,
3166        instance: &SourceName,
3167    ) -> Result<Projected, SourceError> {
3168        let mut value = metadata.get(&*self.key);
3169        for step in &self.path {
3170            value = value.and_then(|held| held.get(step.as_str()));
3171        }
3172        let found = match value {
3173            None | Some(Value::Null) => return Ok(Projected::Nothing),
3174            Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
3175            Some(Value::Bool(_)) => "a boolean",
3176            Some(Value::Number(_)) => "a number",
3177            Some(Value::Array(_)) => "an array",
3178            Some(Value::Object(_)) => "an object",
3179        };
3180        Err(SourceError::Refused {
3181            message: format!(
3182                "source {instance} projects metadata key {:?} at path {} onto its board text \
3183                 field {:?}, and this item holds {found} there; only a string, null or nothing \
3184                 can be written to a text field; next: store a string there, or remove the \
3185                 value",
3186                &*self.key,
3187                self.spelled_path(),
3188                &*self.field
3189            ),
3190        })
3191    }
3192}
3193
3194/// What one item's `Priority` field says, read through this instance's mapping.
3195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3196enum HeldPriority {
3197    /// A priority this source reports: an option the mapping names, or no value (`none`).
3198    Read(Priority),
3199    /// An option the mapping does not name, which is never read as a level or as `none`.
3200    Unmapped(String),
3201}
3202
3203/// How fast this source writes, and how long it waits out a rate-limit refusal.
3204///
3205/// Configurable because a GitHub Enterprise installation sets its own limits and an
3206/// operator who has already been refused may want to go slower still — not because the
3207/// defaults are guesses.
3208#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3209#[serde(default, deny_unknown_fields)]
3210pub struct PacingConfig {
3211    /// Shortest interval between two content-creating mutations, in milliseconds.
3212    ///
3213    /// Zero sends them as fast as they are asked for, which is what a fixture server on
3214    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
3215    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.
3216    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
3217    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
3218    /// zero while there is a budget to spend, because a schedule of zero-length waits
3219    /// consumes none of it and so never ends.
3220    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.
3221    /// Total time one call may spend waiting out rate limits, in milliseconds.
3222    ///
3223    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
3224    /// the bound is what makes this a wait rather than a hang.
3225    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.
3226}
3227
3228/// The largest any pacing setting may be, in milliseconds.
3229///
3230/// One hour. GitHub's own harshest published bound on content-generating requests works
3231/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
3232/// anything a real limit asks for, and past it the settings stop describing pacing at all:
3233/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
3234/// and an interval beyond it is a command that never sends its second mutation. It also
3235/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
3236/// what a `Duration` can hold on every platform.
3237pub const MAX_PACING_MS: u64 = 3_600_000;
3238
3239/// [`PacingConfig`] with every default resolved and every value checked, which is what the
3240/// source holds.
3241///
3242/// Unset, `min_mutation_interval_ms` spaces nothing, and every request is paced by GitHub's
3243/// secondary windows alone — see the `pacing` module; set, it spaces content-creating mutations
3244/// on top of those windows, [`MIN_MUTATION_INTERVAL_MS`] being the spacing that holds a sustained
3245/// burst under the per-minute bound by spacing alone; and zero sends every request as fast as it
3246/// is asked for, windows included.
3247#[derive(Debug, Clone, Copy)]
3248struct Pacing {
3249    /// Whether requests are held for GitHub's secondary windows: always, unless
3250    /// `min_mutation_interval_ms` is set to exactly zero, which has always asked for no
3251    /// pacing at all — what a fixture server on loopback wants and no board on github.com does.
3252    windows: bool,
3253    min_mutation_interval: Duration,
3254    retry_backoff: Duration,
3255    retry_budget: Duration,
3256}
3257
3258impl Pacing {
3259    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
3260    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3261        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
3262            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
3263                message: format!(
3264                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
3265                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
3266                     GitHub's own harshest published limit"
3267                ),
3268            }),
3269            Some(value) => Ok(Duration::from_millis(value)),
3270            None => Ok(Duration::from_millis(default)),
3271        };
3272        let retry_backoff = bounded(
3273            config.retry_backoff_ms,
3274            RETRY_BACKOFF_MS,
3275            "retry_backoff_ms",
3276        )?;
3277        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
3278        if retry_backoff.is_zero() && !retry_budget.is_zero() {
3279            return Err(SourceError::Config {
3280                message: format!(
3281                    "pacing.retry_backoff_ms of source {instance} is 0 while \
3282                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
3283                     none of that budget, so it would retry a refusal forever. Set a backoff of \
3284                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
3285                     waiting at all",
3286                    retry_budget.as_millis()
3287                ),
3288            });
3289        }
3290        Ok(Self {
3291            windows: config.min_mutation_interval_ms != Some(0),
3292            min_mutation_interval: bounded(
3293                config.min_mutation_interval_ms,
3294                0,
3295                "min_mutation_interval_ms",
3296            )?,
3297            retry_backoff,
3298            retry_budget,
3299        })
3300    }
3301}
3302
3303/// Factory for [`GitHubProjectsSource`].
3304#[derive(Debug, Clone, Copy, Default)]
3305pub struct Plugin;
3306
3307impl SourcePlugin for Plugin {
3308    fn kind(&self) -> &'static str {
3309        KIND
3310    }
3311    /// The endpoint, the owner and the board's number: what names one board.
3312    fn remote_identity(&self, config: &Value) -> Option<String> {
3313        let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
3314        Some(format!(
3315            "{}\n{}\n{}",
3316            config.endpoint, config.owner, config.project_number
3317        ))
3318    }
3319    fn config_schema(&self) -> Schema {
3320        schema_for!(GitHubProjectsConfig)
3321    }
3322    fn build(
3323        &self,
3324        name: &SourceName,
3325        config: &Value,
3326        secrets: &dyn SecretResolver,
3327    ) -> Result<Box<dyn TaskSource>, SourceError> {
3328        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
3329    }
3330
3331    fn build_with_clock(
3332        &self,
3333        name: &SourceName,
3334        config: &Value,
3335        secrets: &dyn SecretResolver,
3336        clock: SharedClock,
3337    ) -> Result<Box<dyn TaskSource>, SourceError> {
3338        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
3339    }
3340}
3341
3342/// The client `cell` holds, building it with `build` the first time it is asked for.
3343///
3344/// A failed build is the request's failure, with the message the source's construction gave it
3345/// before clients were deferred.
3346// 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`.
3347fn built(
3348    cell: &std::sync::OnceLock<Client>,
3349    build: impl FnOnce() -> Result<Client, SourceError>,
3350) -> Result<&Client, SourceError> {
3351    if let Some(client) = cell.get() {
3352        return Ok(client);
3353    }
3354    let client = build()?;
3355    Ok(cell.get_or_init(|| client))
3356}
3357
3358impl Plugin {
3359    /// Build a source recording every request it sends into an accounting the caller holds.
3360    ///
3361    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
3362    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
3363    /// session total rather than two — see [`accounting`] and
3364    /// [`GitHubProjectsSource::recording_into`].
3365    ///
3366    /// # Errors
3367    ///
3368    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
3369    /// [`SourceError::Config`] for configuration this plugin cannot use and
3370    /// [`SourceError::Auth`] for a credential it cannot find.
3371    pub fn build_recording_into(
3372        &self,
3373        name: &SourceName,
3374        config: &Value,
3375        secrets: &dyn SecretResolver,
3376        ledger: Arc<Accounting>,
3377    ) -> Result<Box<dyn TaskSource>, SourceError> {
3378        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
3379    }
3380
3381    fn build_recording_with_clock(
3382        &self,
3383        name: &SourceName,
3384        config: &Value,
3385        secrets: &dyn SecretResolver,
3386        ledger: Arc<Accounting>,
3387        clock: SharedClock,
3388    ) -> Result<Box<dyn TaskSource>, SourceError> {
3389        let config: GitHubProjectsConfig =
3390            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
3391                message: format!("source {name}: {e}"),
3392            })?;
3393        let prefix = format!("source {name}: ");
3394        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
3395            .map_err(|error| match error {
3396                // The shared `StatusMapping::distinct` names the source itself.
3397                SourceError::Config { message } if message.starts_with(&prefix) => {
3398                    SourceError::Config { message }
3399                }
3400                SourceError::Config { message } => SourceError::Config {
3401                    message: format!("{prefix}{message}"),
3402                },
3403                SourceError::Auth { message } => SourceError::Auth {
3404                    message: format!("source {name}: {message}"),
3405                },
3406                other => other,
3407            })?;
3408        source.clock = clock;
3409        Ok(Box::new(source))
3410    }
3411}
3412
3413/// Where a status category lands on this board, once configuration is resolved.
3414#[derive(Debug, Clone, PartialEq, Eq)]
3415enum StatusTarget {
3416    /// Not usable against this instance for this kind, and why.
3417    Disabled(UnmappedStatus),
3418    /// The board's `Status` option of this name.
3419    Column(ColumnName),
3420    /// A closed issue, with both its board option and the reason that says which closed it means.
3421    // 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.
3422    Terminal(ColumnName, ClosedState),
3423}
3424
3425/// Every status category, in the order the vocabulary declares them.
3426///
3427/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
3428/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
3429/// added to the shared vocabulary fails to compile until it is named there, and this
3430/// crate's suite reconciles this list against that enum's own derived schema, which is
3431/// generated from the variants rather than written beside them. The schema is what
3432/// catches a list left one short — a list checking only the positions it already holds
3433/// would pass while every mapping indexed by the new position panicked.
3434pub const CATEGORIES: [StatusCategory; 8] = [
3435    StatusCategory::Draft,
3436    StatusCategory::Backlog,
3437    StatusCategory::Todo,
3438    StatusCategory::Queued,
3439    StatusCategory::InProgress,
3440    StatusCategory::Done,
3441    StatusCategory::Cancelled,
3442    StatusCategory::Unknown,
3443];
3444
3445/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
3446#[must_use]
3447pub const fn category_position(category: StatusCategory) -> usize {
3448    match category {
3449        StatusCategory::Draft => 0,
3450        StatusCategory::Backlog => 1,
3451        StatusCategory::Todo => 2,
3452        StatusCategory::Queued => 3,
3453        StatusCategory::InProgress => 4,
3454        StatusCategory::Done => 5,
3455        StatusCategory::Cancelled => 6,
3456        StatusCategory::Unknown => 7,
3457    }
3458}
3459
3460/// The spelling a status category is configured and reported under.
3461fn category_name(category: StatusCategory) -> &'static str {
3462    match category {
3463        StatusCategory::Draft => "draft",
3464        StatusCategory::Backlog => "backlog",
3465        StatusCategory::Todo => "todo",
3466        StatusCategory::Queued => "queued",
3467        StatusCategory::InProgress => "in-progress",
3468        StatusCategory::Done => "done",
3469        StatusCategory::Cancelled => "cancelled",
3470        StatusCategory::Unknown => "unknown",
3471    }
3472}
3473
3474/// A shipped default's option name.
3475///
3476/// The literals below are this file's own and non-blank, and they are validated by the
3477/// one constructor a configured name goes through rather than beside it.
3478fn shipped_column(name: &'static str) -> ColumnName {
3479    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
3480}
3481
3482/// The shipped default for one category this instance's `status_mapping` does not mention,
3483/// for either kind.
3484fn shipped_default(category: StatusCategory) -> StatusTarget {
3485    match category {
3486        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
3487        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
3488        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
3489        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
3490        StatusCategory::Done => {
3491            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
3492        }
3493        StatusCategory::Cancelled => {
3494            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
3495        }
3496        StatusCategory::Draft | StatusCategory::Unknown => {
3497            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
3498        }
3499    }
3500}
3501
3502/// The two kinds a status is written and read for, each with its own half of the mapping.
3503const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
3504
3505/// This instance's complete category-to-target mapping for each kind, read in both
3506/// directions.
3507///
3508/// One target per category per kind, held at that category's own [`category_position`], so
3509/// a category missing from the mapping, named twice in it, or filed out of order is a state
3510/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
3511/// targets are options of the board's one `Status` field.
3512#[derive(Debug, Clone)]
3513struct BoardStatuses {
3514    tasks: [StatusTarget; CATEGORIES.len()],
3515    projects: [StatusTarget; CATEGORIES.len()],
3516}
3517
3518impl BoardStatuses {
3519    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
3520    /// would read back from one option.
3521    ///
3522    /// A category the mapping does not mention keeps its shipped default for both kinds; one
3523    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
3524    /// omits unmapped rather than defaulted.
3525    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
3526        let resolve_kind =
3527            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
3528                // `CATEGORIES[position] == category` for every category — the crate's suite
3529                // asserts it — so mapping the list in order fills each category's own slot.
3530                let mut targets = CATEGORIES.map(shipped_default);
3531                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
3532                    if !configured.mentions(category) {
3533                        continue;
3534                    }
3535                    *slot = match configured.name_for(category, kind) {
3536                        Err(why) => StatusTarget::Disabled(why),
3537                        Ok(name) => {
3538                            let option = ColumnName::try_from(name.as_str().to_owned())
3539                                .map_err(|message| SourceError::Config { message })?;
3540                            match category {
3541                                StatusCategory::Done => {
3542                                    StatusTarget::Terminal(option, ClosedState::Completed)
3543                                }
3544                                StatusCategory::Cancelled => {
3545                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
3546                                }
3547                                _ => StatusTarget::Column(option),
3548                            }
3549                        }
3550                    };
3551                }
3552                StatusMapping::distinct(
3553                    instance,
3554                    kind,
3555                    CATEGORIES
3556                        .iter()
3557                        .zip(&targets)
3558                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
3559                )?;
3560                Ok(targets)
3561            };
3562        Ok(Self {
3563            tasks: resolve_kind(ItemKind::Task)?,
3564            projects: resolve_kind(ItemKind::Project)?,
3565        })
3566    }
3567
3568    /// Every category's target for `kind`, in category order.
3569    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
3570        match kind {
3571            ItemKind::Task => &self.tasks,
3572            ItemKind::Project => &self.projects,
3573        }
3574    }
3575
3576    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
3577        &self.targets(kind)[category_position(category)]
3578    }
3579
3580    /// The category a board option name reports for `kind`, or `None` when nothing of that
3581    /// kind maps to it.
3582    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
3583        CATEGORIES.into_iter().find(|category| {
3584            self.target(kind, *category)
3585                .option()
3586                .is_some_and(|name| name.eq_ignore_ascii_case(option))
3587        })
3588    }
3589
3590    /// Every option name either kind maps a category to, each once ignoring case, in
3591    /// category order with a task's name before a project's — what the guarded setup asks
3592    /// the `Status` field to hold.
3593    fn wanted(&self) -> Vec<String> {
3594        let mut wanted: Vec<String> = Vec::new();
3595        for category in CATEGORIES {
3596            for kind in STATUS_KINDS {
3597                if let Some(name) = self.target(kind, category).option()
3598                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
3599                {
3600                    wanted.push(name.to_owned());
3601                }
3602            }
3603        }
3604        wanted
3605    }
3606
3607    /// The status an item of `kind` reports, from the three things a read of it says: its
3608    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
3609    ///
3610    /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
3611    /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
3612    /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
3613    /// produce, so it is read permissively rather than refused — reads are faithful, and
3614    /// refusals belong on writes.
3615    ///
3616    /// **Its name follows the closure too, unless the option agrees with it.** The option's
3617    /// own name is kept when this kind's mapping reads that option as the same category, or
3618    /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
3619    /// named `Shipped`. An option the mapping reads as another category contradicts the
3620    /// closure, and so does no option at all: the name is then the option the mapping gives
3621    /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
3622    /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
3623    /// it. An `unknown` closure keeps the option, or `Closed` without one.
3624    ///
3625    /// An open item's option reads through its own kind's mapping, and an option that mapping
3626    /// does not name reads as `Unknown` under its own name.
3627    ///
3628    /// One function of those three rather than of a response, so a narrow status write can
3629    /// answer what a re-read would report by applying it to the state it has just written.
3630    fn status(
3631        &self,
3632        kind: ItemKind,
3633        option: Option<&str>,
3634        closed: bool,
3635        reason: Option<&str>,
3636    ) -> Status {
3637        if closed {
3638            let category = closed_category(reason);
3639            let agrees = option.filter(|option| {
3640                category == StatusCategory::Unknown
3641                    || self
3642                        .category_of(kind, option)
3643                        .is_none_or(|read| read == category)
3644            });
3645            let name = agrees.or_else(|| self.target(kind, category).option());
3646            let fallback = match category {
3647                StatusCategory::Done => "Done",
3648                StatusCategory::Cancelled => "Cancelled",
3649                _ => "Closed",
3650            };
3651            return Status {
3652                category,
3653                name: name.or(option).unwrap_or(fallback).to_owned(),
3654            };
3655        }
3656        let name = option.unwrap_or("Open").to_owned();
3657        Status {
3658            category: self
3659                .category_of(kind, &name)
3660                .unwrap_or(StatusCategory::Unknown),
3661            name,
3662        }
3663    }
3664
3665    /// The option a closed item's `Status` has to hold for its card to agree with its
3666    /// closure, and the category that closure reads as — or `None` when the item is open,
3667    /// its closure reads `unknown`, or its option already agrees.
3668    ///
3669    /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
3670    /// in line, each to the one option the mapping gives its category — an item with no
3671    /// option, or at one the mapping does not name, included.
3672    fn contradicted(
3673        &self,
3674        kind: ItemKind,
3675        option: Option<&str>,
3676        closed: bool,
3677        reason: Option<&str>,
3678    ) -> Option<(ClosedAs, &str)> {
3679        if !closed {
3680            return None;
3681        }
3682        let closed_as = match closed_category(reason) {
3683            StatusCategory::Done => ClosedAs::Done,
3684            StatusCategory::Cancelled => ClosedAs::Cancelled,
3685            _ => return None,
3686        };
3687        let wanted = self.target(kind, closed_as.category()).option()?;
3688        if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
3689            return None;
3690        }
3691        Some((closed_as, wanted))
3692    }
3693}
3694
3695/// The category a closed issue reads as, from the reason it was closed with.
3696///
3697/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
3698/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
3699/// is one this source cannot read a category from.
3700fn closed_category(reason: Option<&str>) -> StatusCategory {
3701    match reason {
3702        None | Some("COMPLETED") => StatusCategory::Done,
3703        Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
3704        Some(_) => StatusCategory::Unknown,
3705    }
3706}
3707
3708impl BoardStatuses {
3709    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
3710    /// case; a kind lacking none is left out.
3711    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
3712        STATUS_KINDS
3713            .into_iter()
3714            .filter_map(|kind| {
3715                let missing: Vec<String> = self
3716                    .targets(kind)
3717                    .iter()
3718                    .filter_map(StatusTarget::option)
3719                    .filter(|wanted| {
3720                        !existing
3721                            .iter()
3722                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3723                    })
3724                    .map(str::to_owned)
3725                    .collect();
3726                (!missing.is_empty()).then_some(KindMissing { kind, missing })
3727            })
3728            .collect()
3729    }
3730}
3731
3732impl StatusTarget {
3733    /// The board option this target selects, or `None` for an unmapped one.
3734    fn option(&self) -> Option<&str> {
3735        match self {
3736            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
3737            Self::Disabled(_) => None,
3738        }
3739    }
3740}
3741
3742// 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.
3743/// One repository this source can create an issue in, as `owner/name`.
3744///
3745/// Every `createIssue` this source sends names one of these: the item's own single
3746/// `repositories` entry — on a source declared private, only when it reads private — else
3747/// its parent project issue's repository, else the configured
3748/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
3749/// that choice and says what it refuses before `createIssue`.
3750// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
3751#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3752#[serde(try_from = "String", into = "String")]
3753struct RepositoryTarget {
3754    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3755    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3756}
3757
3758impl RepositoryTarget {
3759    fn parse(value: &str) -> Result<Self, SourceError> {
3760        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
3761            message: format!(
3762                "repository must be spelled owner/name; {value:?} names no repository"
3763            ),
3764        })?;
3765        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
3766            return Err(SourceError::Config {
3767                message: format!(
3768                    "repository must be spelled owner/name with a GitHub login and one \
3769                     repository name; {value:?} is not"
3770                ),
3771            });
3772        }
3773        Ok(Self {
3774            owner: owner.to_owned(),
3775            name: name.to_owned(),
3776        })
3777    }
3778
3779    /// The one host whose repositories this source creates issues in, spelled once: it is
3780    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
3781    const HOST: &str = "github.com";
3782
3783    fn origin(&self) -> String {
3784        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
3785    }
3786
3787    /// The repository a normalized origin names, or why it is none this source can create
3788    /// an issue in: another host, or more or fewer than `owner/name` under this one.
3789    fn from_origin(origin: &Repository) -> Result<Self, String> {
3790        let not_here = || {
3791            format!(
3792                "{} is not a {}/owner/name repository",
3793                origin.as_str(),
3794                Self::HOST
3795            )
3796        };
3797        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
3798        if host != Self::HOST {
3799            return Err(not_here());
3800        }
3801        Self::parse(rest).map_err(|_| not_here())
3802    }
3803
3804    fn slug(&self) -> String {
3805        format!("{}/{}", self.owner, self.name)
3806    }
3807}
3808
3809/// Read back from a kept context as the `owner/name` it was written as, refused unless
3810/// [`RepositoryTarget::parse`] takes it.
3811impl TryFrom<String> for RepositoryTarget {
3812    type Error = String;
3813
3814    fn try_from(slug: String) -> Result<Self, Self::Error> {
3815        Self::parse(&slug).map_err(|error| error.to_string())
3816    }
3817}
3818
3819impl From<RepositoryTarget> for String {
3820    fn from(target: RepositoryTarget) -> Self {
3821        target.slug()
3822    }
3823}
3824
3825/// A source which reads GitHub afresh for every operation.
3826pub struct GitHubProjectsSource {
3827    /// This source's configured name, used both to tell a far end naming this source
3828    /// from one naming a system it knows nothing about, and to name the instance a
3829    /// status refusal is about.
3830    name: SourceName,
3831    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
3832    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
3833    repository: Option<RepositoryTarget>,
3834    /// The logins whose repositories a create may file in; empty restricts nothing.
3835    creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after each entry passes GitHub's login grammar.
3836    endpoint: Url,
3837    token: SecretString,
3838    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
3839    statuses: BoardStatuses,
3840    /// Where each priority lands on this board, or `None` when this instance holds none.
3841    priorities: Option<PriorityMapping>,
3842    /// The metadata values this instance projects onto board text fields, in configured order.
3843    metadata_fields: Vec<MetadataField>,
3844    /// Built on the first request rather than with the source, as [`Self::client`] says.
3845    client: std::sync::OnceLock<Client>,
3846    /// Built on the first attachment request, as [`Self::asset_client`] says.
3847    asset_client: std::sync::OnceLock<Client>,
3848    /// The markup the engine handed this source for its bodies' headings, applied to every
3849    /// visible body it composes and stripped from every one it reads back: see
3850    /// [`TaskSource::attach_body_markup`].
3851    body_markup: std::sync::OnceLock<onetaskgraph_plugin_api::SharedBodyMarkup>,
3852    /// Every item this source has created in this command, in the order it created them —
3853    /// dropped by [`TaskSource::end_command`].
3854    ///
3855    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
3856    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
3857    /// a copy resolving a dependency on an item it had just created refused it as not
3858    /// found. A board read is completed from this — an item remembered here and absent from
3859    /// the read is added back, because the board really does hold it and only the read is
3860    /// behind.
3861    ///
3862    /// It is not a cache of a user's work: nothing is remembered that this process did not
3863    /// itself just write, it lives and dies with the process, and it is never consulted for
3864    /// an item this source did not create.
3865    created: Mutex<Vec<Resolved>>,
3866    /// Every item that already existed and that this source has written in this command, as
3867    /// it wrote it — dropped by [`TaskSource::end_command`].
3868    ///
3869    /// The other half of [`Self::created`], held on the same terms and for the reason a
3870    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
3871    /// filter is an index behind a write this process made moments ago, so a query matching
3872    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
3873    /// is remembered that this process did not itself just write.
3874    updated: Mutex<Vec<Resolved>>,
3875    /// Every issue this source has added a comment to or edited a comment of in this command
3876    /// — dropped by [`TaskSource::end_command`].
3877    ///
3878    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
3879    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
3880    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
3881    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
3882    /// from before — until the index caught up. Each id here is a candidate of every such
3883    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
3884    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
3885    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
3886    /// process wrote is still found only once the index has it.
3887    commented: Mutex<Vec<NativeId>>,
3888    /// How fast this source writes, and how long it waits out a refusal.
3889    pacing: Pacing,
3890    /// When the last content-creating mutation finished, or the moment the furthest-out
3891    /// reserved slot releases the next one, whichever is later — so the one after it can be
3892    /// spaced from that, when `pacing.min_mutation_interval_ms` asks for a spacing. See
3893    /// [`GitHubProjectsSource::paced`] for why completion rather than release is what it is
3894    /// measured from.
3895    last_mutation: Mutex<Option<Duration>>,
3896    /// GitHub's secondary windows as this source has charged them; see the `pacing` module.
3897    windows: Mutex<pacing::Windows>,
3898    /// The tasks a project holds, as a copy's batched read of the project and every one of them
3899    /// found them — see [`TaskSource::read_ahead_for_copy`] — for the length of one command, so
3900    /// a listing of that project's tasks is answered without a read of its own.
3901    task_children: Mutex<BTreeMap<NativeId, Vec<Resolved>>>,
3902    /// The web address of each issue this source created in the current command, as its
3903    /// `createIssue` answered it — what [`TaskSource::written_location`] answers, without a
3904    /// request.
3905    written: Mutex<BTreeMap<NativeId, String>>,
3906    /// What a copy has held back of the items it created, between
3907    /// [`TaskSource::begin_deferred_writes`] and [`TaskSource::finish_writes`] — `None` while
3908    /// nothing is being held back, when every write lands whole as it is made.
3909    deferred: Mutex<Option<Deferred>>,
3910    /// Each existing issue's update held back while writes are, in the order it was made: sent
3911    /// together by [`Self::send_held_updates`] before any other request goes out, so nothing
3912    /// this source sends or reads ever sees the board without them.
3913    held_updates: Mutex<Vec<HeldUpdate>>,
3914    clock: SharedClock,
3915    /// Whether the configuration declares this source private, as
3916    /// [`TaskSource::declare_visibility`] told it: what decides where a new issue whose one
3917    /// named repository reads public is created — see [`Self::creation_rule`].
3918    declared_private: std::sync::atomic::AtomicBool,
3919    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
3920    /// The board as this process last read it, for the length of one command — dropped by
3921    /// [`TaskSource::end_command`].
3922    ///
3923    /// A copy of a project used to re-read the whole board, paged, before writing each of
3924    /// its items, which is by far the largest part of a copy's request count and none of
3925    /// its work. Nothing else changes this board while a command runs — this source's own
3926    /// writes are the only writer — so one read answers them all.
3927    ///
3928    /// It is not a store of a user's work and it is not the cache the no-persistence
3929    /// invariant forbids: it lives and dies with the process exactly as `created` does,
3930    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
3931    /// an item this command created and then depends on resolves whether or not GitHub's
3932    /// own eventually-consistent read has caught up. A write to an item already on the
3933    /// board updates the entry here too, so what this holds is the last read plus this
3934    /// process's own writes rather than a snapshot taken before them.
3935    board_cache: Mutex<Option<Board>>,
3936    /// Every issue this board's own search reported, for the length of one command — dropped
3937    /// by [`TaskSource::end_command`].
3938    ///
3939    /// The second half of a board read, and cached for the same reason and on the same
3940    /// terms as the first: it lives and dies with the process, nothing is written down, and
3941    /// a write this process makes updates the entry here exactly as it updates the one in
3942    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
3943    /// that lists this board's projects and its tasks pays for one search rather than two.
3944    search_cache: Mutex<Option<Vec<Resolved>>>,
3945    /// What each narrowed question GitHub was asked answered, keyed by that question, for
3946    /// the length of one command — dropped by [`TaskSource::end_command`].
3947    ///
3948    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
3949    /// and dies with the process, nothing is written down, a write this process makes
3950    /// updates the entry here as it updates the other two, and every answer is completed
3951    /// with this process's own writes each time it is given. A command that asks the same
3952    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
3953    /// write — pays for it once, which is what the whole-board read it replaced gave it.
3954    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
3955    search_next: Mutex<BTreeMap<String, Option<String>>>,
3956    /// Records already resolved in this command, reused by writes and for comment identity.
3957    /// Explicit item reads still reach GitHub. Nothing is persisted, and
3958    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
3959    /// its item as a person has since left it.
3960    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3961    /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
3962    /// answered as holding nothing — each taken by the next read of that item by id and by
3963    /// nothing else, dropped by any mutation naming it, and cleared by
3964    /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
3965    /// read-before-write, made in the same request as the others' rather than on its own.
3966    read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
3967    /// The origins [`TaskSource::read_ahead_origins_for_copy`] was asked about and has not sent
3968    /// yet: held for the [`TaskSource::read_ahead_for_copy`] that follows it, which sends them —
3969    /// beside the members of the one project it names, when it names one — or for the next
3970    /// request this source makes, whichever comes first.
3971    pending_origins: Mutex<Vec<String>>,
3972    /// Each project's members as a lookup carrying them read them in this command, trimmed to
3973    /// their classification and repositories, taken by [`TaskSource::member_classifications`]
3974    /// of that project and by nothing else, and cleared by [`TaskSource::end_command`].
3975    member_classes: Mutex<BTreeMap<NativeId, Vec<MemberClass>>>,
3976    /// Every item a listing of one project's members carried whole in this command — the
3977    /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
3978    /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
3979    /// it again. No other read is answered from it, so a read by id outside a copy's
3980    /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
3981    /// item, and cleared by [`TaskSource::end_command`].
3982    carried: Mutex<BTreeMap<NativeId, Resolved>>,
3983    /// Items an issue read by reference reached, held for the one status write each was read
3984    /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
3985    /// what such a write needs. Dropped by [`TaskSource::end_command`].
3986    held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3987    /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
3988    /// asked for under, with the project that selector named — for the length of one command,
3989    /// dropped by [`TaskSource::end_command`].
3990    ///
3991    /// A caller pages through a project's tasks one engine page at a time, and every page
3992    /// is cut from the whole list of them, so without this each page walked every
3993    /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
3994    /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
3995    /// dies with the process, nothing is written down, a write this process makes updates or
3996    /// removes the entry here as it does there, and every answer is completed with this
3997    /// process's own writes each time it is given.
3998    children_cache: Mutex<ProjectChildren>,
3999    /// The board's own id and field definitions as this process last read them on their
4000    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
4001    ///
4002    /// What a write needs of the board and its item does not say, read once per command
4003    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
4004    /// lives and dies with the process and nothing is written down. It holds no item and so
4005    /// can answer no question about one — see [`Self::board_fields`].
4006    fields_cache: Mutex<Option<BoardFields>>,
4007    /// Each destination repository's node id, resolved once per repository
4008    /// rather than per issue created.
4009    ///
4010    /// A repository's node id does not change, and re-reading it for every issue of a copy
4011    /// spent one request per item on an answer this source already had. It is a map rather
4012    /// than one entry because a copy files each item in the repository its own
4013    /// `repositories` field names, so a plan across five repositories asks GitHub five
4014    /// times and not once per item.
4015    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
4016    /// What every request this source sends is recorded into.
4017    ///
4018    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
4019    /// a request leaves this crate, so nothing has to be switched on for a session to be
4020    /// counted. It is shared rather than owned so a caller accounting for a whole session —
4021    /// its own schema verification, board lookups, residue sweep and cleanup beside this
4022    /// source's reads and writes — adds up one accounting instead of two. See
4023    /// [`accounting`] for what a record carries and what a session's spend is and is not.
4024    ledger: Arc<Accounting>,
4025    /// What [`expected`] holds for a write the store's cache expects: the journal while one is
4026    /// open, the issues primed from the cache, and the comments known on each.
4027    expected: expected::Held,
4028    /// What [`projection`] holds for a producer's projection: a kept placement awaiting its
4029    /// setup-context token, and where the last create was placed.
4030    projection: projection::Held,
4031}
4032
4033/// The board item a single field update's answer names, refused unless it is `item_id`.
4034fn field_update_item<'a>(data: &'a Value, item_id: &str) -> Result<&'a Value, SourceError> {
4035    let returned = data
4036        .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
4037        .ok_or_else(|| SourceError::Malformed {
4038            message: "GitHub field update returned no project item".into(),
4039        })?;
4040    if required_str(returned, "id")? != item_id {
4041        return Err(SourceError::Malformed {
4042            message: "GitHub field update returned the wrong project item".into(),
4043        });
4044    }
4045    Ok(returned)
4046}
4047
4048/// GitHub's closed single-select color vocabulary.
4049#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
4050#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
4051pub enum StatusOptionColor {
4052    /// Gray.
4053    Gray,
4054    /// Blue.
4055    Blue,
4056    /// Green.
4057    Green,
4058    /// Yellow.
4059    Yellow,
4060    /// Purple.
4061    Purple,
4062    /// Red.
4063    Red,
4064    /// Orange.
4065    Orange,
4066    /// Pink.
4067    Pink,
4068}
4069
4070/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
4071/// applies its additions.
4072#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4073pub enum SetupMode {
4074    /// Read without mutation.
4075    Plan,
4076    /// Apply and verify.
4077    Apply,
4078}
4079
4080/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
4081/// against it goes on compiling.
4082pub type StatusOptionsMode = SetupMode;
4083
4084/// The explicit result of the requested operation.
4085#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4086#[serde(rename_all = "kebab-case")]
4087pub enum StatusOptionsOutcome {
4088    /// A read-only plan.
4089    Planned,
4090    /// Apply found nothing missing.
4091    Unchanged,
4092    /// Additions were applied and verified.
4093    Applied,
4094}
4095
4096/// A GitHub single-select option's opaque GraphQL node identifier.
4097#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4098#[serde(transparent)]
4099pub struct StatusOptionId(#[schemars(length(min = 1))] String);
4100
4101impl TryFrom<String> for StatusOptionId {
4102    type Error = String;
4103
4104    fn try_from(id: String) -> Result<Self, Self::Error> {
4105        if id.trim().is_empty() {
4106            return Err("a GitHub Status option id cannot be blank".to_owned());
4107        }
4108        Ok(Self(id))
4109    }
4110}
4111
4112/// One existing or proposed option in a guarded Status-field update.
4113#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4114pub struct StatusOption {
4115    /// GitHub's stable id.
4116    pub id: StatusOptionId,
4117    /// The visible option name.
4118    pub name: ColumnName,
4119    /// GitHub's single-select color token.
4120    pub color: StatusOptionColor,
4121    /// The option description, including an empty one.
4122    pub description: String,
4123}
4124
4125/// One board item's Status assignment, retained as recovery data.
4126#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4127pub struct StatusAssignment {
4128    /// The project item id whose assignment this is.
4129    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
4130    // carried verbatim as operator recovery data; introducing a semantic type would claim
4131    // validation rules GitHub does not publish and no operation here interprets.
4132    pub item_id: String,
4133    /// The selected option, absent when the item has no status.
4134    #[serde(skip_serializing_if = "Option::is_none")]
4135    pub option: Option<AssignedStatusOption>,
4136}
4137
4138/// The inseparable id and name of an assigned option.
4139#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4140pub struct AssignedStatusOption {
4141    /// GitHub's stable id.
4142    pub id: StatusOptionId,
4143    /// The visible name.
4144    pub name: ColumnName,
4145}
4146
4147/// The plan and verified outcome of reconciling configured Status options.
4148#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4149pub struct StatusOptionsReport {
4150    /// The configured source name.
4151    pub source: SourceName,
4152    /// Configured option names absent before the operation.
4153    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
4154    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
4155    // serialized string here preserves the report's intentionally simple public contract.
4156    pub missing: Vec<String>,
4157    /// What the requested operation did.
4158    pub outcome: StatusOptionsOutcome,
4159    /// The complete option list observed before any mutation.
4160    pub existing: Vec<StatusOption>,
4161}
4162
4163#[derive(Debug, Clone, PartialEq, Eq)]
4164struct StatusSnapshot {
4165    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4166    // passed back as the mutation's project identity; a newtype could enforce no stronger
4167    // invariant because GitHub publishes no grammar for it.
4168    board_id: String,
4169    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4170    // passed back as the mutation's field identity; a newtype could enforce no stronger
4171    // invariant because GitHub publishes no grammar for it.
4172    field_id: String,
4173    options: Vec<StatusOption>,
4174    assignments: Vec<StatusAssignment>,
4175}
4176
4177/// The name of the board field a status is held in.
4178const STATUS_FIELD: &str = "Status";
4179
4180/// Every item's value of each field `report` names, as it stood before the setup wrote
4181/// anything — what a person puts back when the setup is refused part way.
4182fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
4183    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
4184        .fields
4185        .iter()
4186        .map(|field| (field.field.name(), before.assignments(field.field)))
4187        .collect();
4188    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
4189        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
4190    })
4191}
4192
4193/// One board field the guarded setup reads and writes — every one it reads, and the only
4194/// ones it writes.
4195#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
4196pub enum BoardField {
4197    /// The single-select `Status` field every instance's `status_mapping` resolves into.
4198    Status,
4199    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
4200    Priority,
4201}
4202
4203impl BoardField {
4204    /// The field's name on the board.
4205    #[must_use]
4206    pub const fn name(self) -> &'static str {
4207        match self {
4208            Self::Status => STATUS_FIELD,
4209            Self::Priority => PRIORITY_FIELD,
4210        }
4211    }
4212
4213    /// The field a board calls `name`, or `None` for one this setup does not own.
4214    fn named(name: &str) -> Option<Self> {
4215        [Self::Status, Self::Priority]
4216            .into_iter()
4217            .find(|field| field.name() == name)
4218    }
4219}
4220
4221/// What the guarded setup did to one field.
4222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4223#[serde(rename_all = "kebab-case")]
4224pub enum FieldOutcome {
4225    /// A read-only plan.
4226    Planned,
4227    /// Apply found the field there with every configured option.
4228    Unchanged,
4229    /// Missing options were added to the field that was there, and verified.
4230    Applied,
4231    /// The field was not there; it was created holding the configured options, and verified.
4232    Created,
4233}
4234
4235/// One field's plan, or its verified outcome.
4236#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4237pub struct FieldReport {
4238    /// Which field.
4239    pub field: BoardField,
4240    /// Whether the board had the field before the operation.
4241    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
4242    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
4243    // "outcome", "existing"}` — so folding one into the other would change a published JSON
4244    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
4245    // one constructor, and it derives `outcome` from `exists` in one match.
4246    pub exists: bool,
4247    /// Configured option names the field lacked before the operation — every one of them,
4248    /// in the order a new field lists them, when the field was not there at all.
4249    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4250    // mapping name and has therefore already passed its nonblank validation; the serialized
4251    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
4252    pub missing: Vec<String>,
4253    /// For the `Status` field, which item kind each missing name is configured for: one
4254    /// entry per kind `status_mapping` names a missing option for, task before project, each
4255    /// listing that kind's missing names in category order. A name both kinds use is in
4256    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
4257    /// `Priority`, which only a task holds.
4258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4259    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4260    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
4261    #[schemars(!skip_serializing_if)]
4262    pub kinds: Vec<KindMissing>,
4263    /// What the requested operation did.
4264    pub outcome: FieldOutcome,
4265    /// The field's complete option list observed before any mutation; empty when the field
4266    /// was not there.
4267    pub existing: Vec<StatusOption>,
4268}
4269
4270/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
4271#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4272pub struct KindMissing {
4273    /// The kind these names are configured for.
4274    pub kind: ItemKind,
4275    /// The names that kind maps a category to and the field lacked, in category order.
4276    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4277    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
4278    // intentionally simple public contract.
4279    pub missing: Vec<String>,
4280}
4281
4282/// The plan and verified outcome of setting up every field a source's configuration names.
4283#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4284pub struct FieldsReport {
4285    /// The configured source name.
4286    pub source: SourceName,
4287    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
4288    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
4289    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
4290    // per field would change a published JSON shape. The states the list could hold and the
4291    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
4292    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
4293    pub fields: Vec<FieldReport>,
4294    /// Each board text field the source's `metadata_fields` projects a value onto, in
4295    /// configured order. Empty — and left out of the JSON — when it projects none.
4296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4297    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4298    // both SDKs model an absent list as an empty one rather than as `null`.
4299    #[schemars(!skip_serializing_if)]
4300    pub metadata_fields: Vec<MetadataFieldReport>,
4301}
4302
4303/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
4304/// its GraphQL type when it has none — which is what a projected field's conflict reports.
4305///
4306/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
4307/// no conflict.
4308// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
4309// refuses exactly what `try_from` below does.
4310#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4311#[serde(transparent)]
4312pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
4313
4314/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
4315/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
4316/// neither SDK's validator can be relied on to support.
4317const NON_TEXT_FIELD_TYPE_PATTERN: &str =
4318    r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
4319
4320impl TryFrom<String> for NonTextFieldType {
4321    type Error = String;
4322
4323    fn try_from(kind: String) -> Result<Self, Self::Error> {
4324        if kind.is_empty() || kind.starts_with(char::is_whitespace) {
4325            return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
4326        }
4327        if kind == "TEXT" {
4328            return Err("a TEXT field is no conflict for a projected text field".to_owned());
4329        }
4330        Ok(Self(kind))
4331    }
4332}
4333
4334impl std::fmt::Display for NonTextFieldType {
4335    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4336        formatter.write_str(&self.0)
4337    }
4338}
4339
4340/// One projected metadata text field's plan, or its verified outcome.
4341///
4342/// The setup creates a missing one as a text field and never alters a field that is there:
4343/// a same-named field of another type is reported in `conflict` and left as it is.
4344#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4345pub struct MetadataFieldReport {
4346    /// The board field's name.
4347    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.
4348    /// The metadata key the value is read from.
4349    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
4350    /// The object keys walked inside that key's value; empty for the key's own value.
4351    pub path: Vec<String>,
4352    /// Whether the board had a text field of that name before the operation.
4353    // 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.
4354    pub exists: bool,
4355    /// The type of a field of that name that is not a text field, which the setup leaves as
4356    /// it is; absent — and left out of the JSON — when there is none.
4357    #[serde(default, skip_serializing_if = "Option::is_none")]
4358    pub conflict: Option<NonTextFieldType>,
4359    /// What the requested operation did: `planned`; `created` for a field that was not
4360    /// there; `unchanged` for one that was, and for a conflict, which is never changed.
4361    pub outcome: FieldOutcome,
4362}
4363
4364/// Which options one field is configured with, in the order a new field would list them.
4365struct FieldPlan {
4366    field: BoardField,
4367    wanted: Vec<String>,
4368}
4369
4370/// One single-select field as the guarded setup snapshots it.
4371#[derive(Debug, Clone, PartialEq, Eq)]
4372struct SnapshotField {
4373    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4374    // passed back as the mutation's field identity; a newtype could enforce no stronger
4375    // invariant because GitHub publishes no grammar for it.
4376    field_id: String,
4377    options: Vec<StatusOption>,
4378}
4379
4380/// Every single-select field of a board and every item's value of each.
4381#[derive(Debug, Clone, PartialEq, Eq)]
4382struct BoardSnapshot {
4383    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4384    // passed back as the mutation's project identity; a newtype could enforce no stronger
4385    // invariant because GitHub publishes no grammar for it.
4386    board_id: String,
4387    fields: BTreeMap<BoardField, SnapshotField>,
4388    /// Each board item's id, and its value of each field this setup owns that it holds one of.
4389    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
4390}
4391
4392impl BoardSnapshot {
4393    /// Every item's value of `field`, in board order — the recovery data a drift refusal
4394    /// carries.
4395    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
4396        self.items
4397            .iter()
4398            .map(|(item_id, values)| StatusAssignment {
4399                item_id: item_id.clone(),
4400                option: values.get(&field).cloned(),
4401            })
4402            .collect()
4403    }
4404}
4405
4406impl GitHubProjectsSource {
4407    /// `content` as this source sends it, with the markup the engine handed it applied.
4408    fn marked(&self, content: &str) -> String {
4409        match self.body_markup.get() {
4410            Some(markup) if !content.is_empty() => markup.written(content),
4411            _ => content.to_owned(),
4412        }
4413    }
4414
4415    /// The visible body and the metadata slot of `body` as [`metadata_body`] splits them,
4416    /// the visible body without the markup [`Self::marked`] applies.
4417    fn visible(
4418        &self,
4419        body: Option<String>,
4420    ) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
4421        let (visible, slot) = metadata_body(body)?;
4422        Ok((
4423            match (visible, self.body_markup.get()) {
4424                (Some(visible), Some(markup)) => {
4425                    Some(markup.read(&visible)).filter(|read| !read.is_empty())
4426                }
4427                (visible, _) => visible,
4428            },
4429            slot,
4430        ))
4431    }
4432
4433    /// Report missing configured Status options and, when `apply` is true, add them with
4434    /// a whole-list mutation that preserves every existing id and verifies the result.
4435    ///
4436    /// # Errors
4437    ///
4438    /// Refuses a board without a single-select `Status` field. A post-write difference in
4439    /// any pre-existing option id or item assignment is refused with the complete pre-write
4440    /// assignment snapshot in the diagnostic for recovery.
4441    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
4442    // successful mutation, both drift refusals, source selection, missing Status, casing,
4443    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
4444    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
4445    // responses from entering the defensive malformed-response branches below.
4446    pub async fn status_options(
4447        &self,
4448        mode: StatusOptionsMode,
4449    ) -> Result<StatusOptionsReport, SourceError> {
4450        let before = self.status_snapshot().await?;
4451        // A terminal category's option is as configured as an open one's: a terminal
4452        // write validates it before closing and refuses when the board lacks it. Both
4453        // kinds' names are options of the one field, so both are asked for.
4454        let missing = self
4455            .statuses
4456            .wanted()
4457            .into_iter()
4458            .filter(|wanted| {
4459                !before
4460                    .options
4461                    .iter()
4462                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4463            })
4464            .collect::<Vec<_>>();
4465        let report = StatusOptionsReport {
4466            source: self.name.clone(),
4467            missing: missing.clone(),
4468            outcome: match (mode, missing.is_empty()) {
4469                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
4470                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
4471                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
4472            },
4473            existing: before.options.clone(),
4474        };
4475        if mode == StatusOptionsMode::Plan || missing.is_empty() {
4476            return Ok(report);
4477        }
4478        let mut options = before
4479            .options
4480            .iter()
4481            .map(|option| {
4482                json!({
4483                    "id": option.id, "name": option.name, "color": option.color,
4484                    "description": option.description,
4485                })
4486            })
4487            .collect::<Vec<_>>();
4488        options.extend(missing.iter().map(|name| {
4489            json!({
4490                "name": name, "color": "GRAY", "description": ""
4491            })
4492        }));
4493        self.graphql(
4494            graphql::STATUS_OPTIONS_UPDATE,
4495            json!({"input": {
4496                "fieldId": before.field_id,
4497                "singleSelectOptions": options,
4498            }}),
4499        )
4500        .await?;
4501        let after = self.status_snapshot().await?;
4502        let options_preserved = before
4503            .options
4504            .iter()
4505            .all(|old| after.options.iter().any(|new| new == old));
4506        let additions_present = missing.iter().all(|wanted| {
4507            after
4508                .options
4509                .iter()
4510                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4511        });
4512        if !options_preserved || !additions_present || after.assignments != before.assignments {
4513            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
4514                SourceError::Malformed {
4515                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
4516                }
4517            })?;
4518            return Err(SourceError::Refused {
4519                message: format!(
4520                    "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}"
4521                ),
4522            });
4523        }
4524        Ok(report)
4525    }
4526
4527    /// A fresh snapshot of the Status field and every board item's assignment of it.
4528    ///
4529    /// # Errors
4530    ///
4531    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
4532    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
4533        // Status alone, as this operation has always read it: a `Priority` field is another
4534        // operation's, so nothing about it can refuse this one.
4535        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
4536        let field = board
4537            .fields
4538            .remove(&BoardField::Status)
4539            .ok_or_else(|| self.no_status_field())?;
4540        Ok(StatusSnapshot {
4541            assignments: board.assignments(BoardField::Status),
4542            board_id: board.board_id,
4543            field_id: field.field_id,
4544            options: field.options,
4545        })
4546    }
4547
4548    /// The refusal a board with no `Status` field is answered with by the guarded setup.
4549    fn no_status_field(&self) -> SourceError {
4550        SourceError::Refused {
4551            message: format!("source {} board has no Status field", self.name),
4552        }
4553    }
4554
4555    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
4556    // the real CLI loopback journey, including pagination. The individual malformed guards
4557    // are defensive validation of a schema-pinned third-party response, not separate user
4558    // journeys; drift and missing-field failures cover the operation's recovery behavior.
4559    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
4560    /// every board item's value of each, walked to the end of the board's items. A field not
4561    /// in `owned` is read past whatever it holds.
4562    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
4563        let mut after: Option<String> = None;
4564        let mut snapshot: Option<BoardSnapshot> = None;
4565        loop {
4566            let data = self
4567                .graphql(
4568                    graphql::STATUS_OPTIONS_SNAPSHOT,
4569                    json!({
4570                        "owner": self.owner, "number": self.project_number,
4571                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
4572                    }),
4573                )
4574                .await?;
4575            let board = data
4576                .pointer("/owner/projectV2")
4577                .filter(|board| board.is_object())
4578                .ok_or_else(|| SourceError::Refused {
4579                    message: format!(
4580                        "source {} has no accessible GitHub Projects board",
4581                        self.name
4582                    ),
4583                })?;
4584            if board
4585                .pointer("/fields/pageInfo/hasNextPage")
4586                .and_then(Value::as_bool)
4587                != Some(false)
4588            {
4589                return Err(SourceError::Malformed {
4590                    message:
4591                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
4592                            .into(),
4593                });
4594            }
4595            let mut fields = BTreeMap::new();
4596            // Only the fields this setup owns, by name: a node the single-select fragment did not
4597            // match carries no name, and a person's own single-select field — a `Size`, a
4598            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
4599            // `Status` or `Priority` field without its options is malformed, not absent.
4600            // 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.
4601            for (owned, field) in board
4602                .pointer("/fields/nodes")
4603                .and_then(Value::as_array)
4604                .ok_or_else(|| SourceError::Malformed {
4605                    message: "GitHub project fields.nodes is not an array".into(),
4606                })?
4607                .iter()
4608                .filter_map(|field| {
4609                    let named = BoardField::named(field.get("name")?.as_str()?)?;
4610                    owned.contains(&named).then_some((named, field))
4611                })
4612            {
4613                let options = field
4614                    .get("options")
4615                    .and_then(Value::as_array)
4616                    .ok_or_else(|| SourceError::Malformed {
4617                        message: "GitHub single-select field options is not an array".into(),
4618                    })?
4619                    .iter()
4620                    .map(|option| {
4621                        Ok(StatusOption {
4622                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
4623                                .map_err(|message| SourceError::Malformed { message })?,
4624                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
4625                                .map_err(|message| SourceError::Malformed {
4626                                    message: format!(
4627                                        "GitHub single-select option name is invalid: {message}"
4628                                    ),
4629                                })?,
4630                            color: serde_json::from_value(
4631                                option.get("color").cloned().unwrap_or(Value::Null),
4632                            )
4633                            .map_err(|error| {
4634                                SourceError::Malformed {
4635                                    message: format!(
4636                                        "GitHub single-select option color is invalid: {error}"
4637                                    ),
4638                                }
4639                            })?,
4640                            description: optional_str(option, "description")?
4641                                .unwrap_or_default()
4642                                .to_owned(),
4643                        })
4644                    })
4645                    .collect::<Result<Vec<_>, SourceError>>()?;
4646                let snapshot = SnapshotField {
4647                    field_id: required_nonblank_str(field, "id")?.to_owned(),
4648                    options,
4649                };
4650                // A board's field names are unique, so a second one is an answer that cannot
4651                // say which field the setup would act on — refused rather than one chosen.
4652                if fields.insert(owned, snapshot).is_some() {
4653                    return Err(SourceError::Malformed {
4654                        message: format!(
4655                            "GitHub answered two {} fields for this board",
4656                            owned.name()
4657                        ),
4658                    });
4659                }
4660            }
4661            let board_id = required_nonblank_str(board, "id")?.to_owned();
4662            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
4663                board_id,
4664                fields,
4665                items: Vec::new(),
4666            });
4667            let items = board
4668                .pointer("/items/nodes")
4669                .and_then(Value::as_array)
4670                .ok_or_else(|| SourceError::Malformed {
4671                    message: "GitHub project items.nodes is not an array".into(),
4672                })?;
4673            for item in items {
4674                let field_values =
4675                    item.get("fieldValues")
4676                        .ok_or_else(|| SourceError::Malformed {
4677                            message: "GitHub project item is missing fieldValues".into(),
4678                        })?;
4679                if field_values
4680                    .pointer("/pageInfo/hasNextPage")
4681                    .and_then(Value::as_bool)
4682                    != Some(false)
4683                {
4684                    return Err(SourceError::Malformed {
4685                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
4686                    });
4687                }
4688                let values = item
4689                    .pointer("/fieldValues/nodes")
4690                    .and_then(Value::as_array)
4691                    .ok_or_else(|| SourceError::Malformed {
4692                        message: "GitHub project item fieldValues.nodes is not an array".into(),
4693                    })?;
4694                let item_id = required_nonblank_str(item, "id")?;
4695                let mut assigned = BTreeMap::new();
4696                for value in values {
4697                    let Some(field) = value
4698                        .pointer("/field/name")
4699                        .and_then(Value::as_str)
4700                        .and_then(BoardField::named)
4701                        .filter(|field| owned.contains(field))
4702                    else {
4703                        continue;
4704                    };
4705                    let held = assigned.insert(
4706                        field,
4707                        AssignedStatusOption {
4708                            id: StatusOptionId::try_from(
4709                                required_str(value, "optionId")?.to_owned(),
4710                            )
4711                            .map_err(|message| SourceError::Malformed { message })?,
4712                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
4713                                .map_err(|message| SourceError::Malformed {
4714                                    message: format!(
4715                                        "GitHub assigned {} name is invalid: {message}",
4716                                        field.name()
4717                                    ),
4718                                })?,
4719                        },
4720                    );
4721                    // An item holds one value of a field, so a second one leaves no way to
4722                    // tell which it holds — and a verification or recovery built on either
4723                    // could restore the wrong one.
4724                    if held.is_some() {
4725                        return Err(SourceError::Malformed {
4726                            message: format!(
4727                                "GitHub answered two {} values for board item {item_id}",
4728                                field.name()
4729                            ),
4730                        });
4731                    }
4732                }
4733                current.items.push((item_id.to_owned(), assigned));
4734            }
4735            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
4736                message: "GitHub project is missing items".into(),
4737            })?;
4738            let has_next = page
4739                .pointer("/pageInfo/hasNextPage")
4740                .and_then(Value::as_bool)
4741                .ok_or_else(|| SourceError::Malformed {
4742                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
4743                })?;
4744            if !has_next {
4745                break;
4746            }
4747            let next =
4748                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
4749            validate_cursor_progress(after.as_deref(), next)?;
4750            after = Some(next.to_owned());
4751        }
4752        snapshot.ok_or_else(|| SourceError::Malformed {
4753            message: "GitHub returned no board field snapshot".into(),
4754        })
4755    }
4756    // llmlint: ignore-end[changed_behavior_has_e2e]
4757
4758    /// Report every board field this source's configuration names and, with
4759    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
4760    /// the `Priority` field when the board has none.
4761    ///
4762    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
4763    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
4764    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
4765    /// color and description: the whole option list goes back with every existing id, because
4766    /// a re-minted id clears every item's value.
4767    ///
4768    /// # Errors
4769    ///
4770    /// Refuses a board without a single-select `Status` field. After an apply the board is
4771    /// read again, and a pre-existing option or any item's value of either field that moved is
4772    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
4773    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
4774    // unchanged apply, a created field, an added option to each field, drift refusal, a board
4775    // with no Status field and a non-github-projects source through the compiled CLI against
4776    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
4777    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
4778        let owned: Vec<BoardField> = if self.priorities.is_some() {
4779            vec![BoardField::Status, BoardField::Priority]
4780        } else {
4781            vec![BoardField::Status]
4782        };
4783        let before = self.board_snapshot(&owned).await?;
4784        let mut plans = vec![FieldPlan {
4785            field: BoardField::Status,
4786            wanted: self.statuses.wanted(),
4787        }];
4788        if !before.fields.contains_key(&BoardField::Status) {
4789            return Err(self.no_status_field());
4790        }
4791        if let Some(mapping) = &self.priorities {
4792            plans.push(FieldPlan {
4793                field: BoardField::Priority,
4794                wanted: mapping.names().map(str::to_owned).collect(),
4795            });
4796        }
4797        // The snapshot reads single-select fields alone, so a field it did not find may still
4798        // be on the board under the name, of another type: creating one beside it would fail
4799        // part way, or leave two fields of one name. Asked of the board's own field list, and
4800        // only when a field is missing.
4801        if plans
4802            .iter()
4803            .any(|plan| !before.fields.contains_key(&plan.field))
4804        {
4805            let board = self.board_fields().await?;
4806            for plan in plans
4807                .iter()
4808                .filter(|plan| !before.fields.contains_key(&plan.field))
4809            {
4810                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
4811                    return Err(SourceError::Refused {
4812                        message: format!(
4813                            "source {}'s board has a {} field that is not a single-select field \
4814                             (it is a {}), so it cannot hold this source's options; next: rename \
4815                             or remove that field, then run this again",
4816                            self.name,
4817                            plan.field.name(),
4818                            optional_str(field, "__typename")?.unwrap_or("field of another type")
4819                        ),
4820                    });
4821                }
4822            }
4823        }
4824        let mut reports = Vec::new();
4825        for plan in &plans {
4826            let held = before.fields.get(&plan.field);
4827            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
4828            let mut missing: Vec<String> = Vec::new();
4829            for wanted in &plan.wanted {
4830                let present = existing
4831                    .iter()
4832                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4833                    || missing
4834                        .iter()
4835                        .any(|named| named.eq_ignore_ascii_case(wanted));
4836                if !present {
4837                    missing.push(wanted.clone());
4838                }
4839            }
4840            let kinds = match plan.field {
4841                BoardField::Status => self.statuses.missing_by_kind(&existing),
4842                BoardField::Priority => Vec::new(),
4843            };
4844            reports.push(FieldReport {
4845                field: plan.field,
4846                exists: held.is_some(),
4847                kinds,
4848                outcome: match (mode, held.is_some(), missing.is_empty()) {
4849                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
4850                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
4851                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
4852                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
4853                },
4854                missing,
4855                existing,
4856            });
4857        }
4858        // A projected field is a text field, which the single-select snapshot cannot see, so
4859        // it is asked of the board's own field list — and only when one is configured.
4860        let mut metadata_fields = Vec::new();
4861        if !self.metadata_fields.is_empty() {
4862            let board = self.board_fields().await?;
4863            for projection in &self.metadata_fields {
4864                let conflict = match Board::field(&board.fields, &projection.field)? {
4865                    None => None,
4866                    Some(field) => {
4867                        let typename = optional_str(field, "__typename")?;
4868                        let data_type = optional_str(field, "dataType")?;
4869                        if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
4870                            None
4871                        } else {
4872                            let named = data_type
4873                                .filter(|kind| *kind != "TEXT")
4874                                .or(typename)
4875                                .unwrap_or("field of another type");
4876                            Some(NonTextFieldType::try_from(named.to_owned()).map_err(
4877                                |message| SourceError::Malformed {
4878                                    message: format!(
4879                                        "GitHub answered the board's {} field with a type this \
4880                                         setup cannot read: {message}",
4881                                        &*projection.field
4882                                    ),
4883                                },
4884                            )?)
4885                        }
4886                    }
4887                };
4888                let exists =
4889                    conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
4890                metadata_fields.push(MetadataFieldReport {
4891                    field: projection.field.to_string(),
4892                    key: projection.key.to_string(),
4893                    path: projection.path.clone(),
4894                    exists,
4895                    outcome: match (mode, exists || conflict.is_some()) {
4896                        (SetupMode::Plan, _) => FieldOutcome::Planned,
4897                        (SetupMode::Apply, true) => FieldOutcome::Unchanged,
4898                        (SetupMode::Apply, false) => FieldOutcome::Created,
4899                    },
4900                    conflict,
4901                });
4902            }
4903        }
4904        let report = FieldsReport {
4905            source: self.name.clone(),
4906            fields: reports,
4907            metadata_fields,
4908        };
4909        let writes: Vec<&FieldReport> = report
4910            .fields
4911            .iter()
4912            .filter(|field| !field.missing.is_empty() || !field.exists)
4913            .collect();
4914        let creates: Vec<&MetadataFieldReport> = report
4915            .metadata_fields
4916            .iter()
4917            .filter(|field| field.outcome == FieldOutcome::Created)
4918            .collect();
4919        if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
4920            return Ok(report);
4921        }
4922        let mut landed: Vec<&str> = Vec::new();
4923        for field in &writes {
4924            let added = field
4925                .missing
4926                .iter()
4927                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
4928            let sent = match before.fields.get(&field.field) {
4929                Some(held) => {
4930                    let mut options = held
4931                        .options
4932                        .iter()
4933                        .map(|option| {
4934                            json!({
4935                                "id": option.id, "name": option.name, "color": option.color,
4936                                "description": option.description,
4937                            })
4938                        })
4939                        .collect::<Vec<_>>();
4940                    options.extend(added);
4941                    self.graphql(
4942                        graphql::STATUS_OPTIONS_UPDATE,
4943                        json!({"input": {
4944                            "fieldId": held.field_id,
4945                            "singleSelectOptions": options,
4946                        }}),
4947                    )
4948                    .await
4949                }
4950                None => {
4951                    self.graphql(
4952                        graphql::CREATE_FIELD,
4953                        json!({"input": {
4954                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
4955                            "name": field.field.name(),
4956                            "singleSelectOptions": added.collect::<Vec<_>>(),
4957                        }}),
4958                    )
4959                    .await
4960                }
4961            };
4962            // A mutation that failed does not establish that GitHub left its field as it was,
4963            // so every failure from here on carries the recovery data a drift refusal does.
4964            match sent {
4965                Ok(_) => landed.push(field.field.name()),
4966                Err(error) => {
4967                    let changed = if landed.is_empty() {
4968                        String::new()
4969                    } else {
4970                        format!("changed the {} field and then ", landed.join(" and "))
4971                    };
4972                    return Err(SourceError::Refused {
4973                        message: format!(
4974                            "the guarded field setup {changed}failed on the {} field, which it may \
4975                             have changed part way: {error}; the pre-write item assignments \
4976                             are:\n{}",
4977                            field.field.name(),
4978                            recovery(&report, &before)?
4979                        ),
4980                    });
4981                }
4982            }
4983        }
4984        for field in &creates {
4985            let sent = self
4986                .graphql(
4987                    graphql::CREATE_FIELD,
4988                    json!({"input": {
4989                        "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
4990                    }}),
4991                )
4992                .await;
4993            if let Err(error) = sent {
4994                let changed = if landed.is_empty() {
4995                    String::new()
4996                } else {
4997                    format!("changed the {} field and then ", landed.join(" and "))
4998                };
4999                return Err(SourceError::Refused {
5000                    message: format!(
5001                        "the guarded field setup {changed}failed creating the {} text field: \
5002                         {error}; run it again to create what is still missing",
5003                        field.field
5004                    ),
5005                });
5006            }
5007            landed.push(&field.field);
5008        }
5009        // The board has been written, so a verification read that fails leaves it unverified
5010        // rather than unchanged, and says what to put back.
5011        let after = match self.board_snapshot(&owned).await {
5012            Ok(after) => after,
5013            Err(error) => {
5014                return Err(SourceError::Refused {
5015                    message: format!(
5016                        "the guarded field setup changed the {} field and then could not read the \
5017                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
5018                        landed.join(" and "),
5019                        recovery(&report, &before)?
5020                    ),
5021                });
5022            }
5023        };
5024        let mut moved = Vec::new();
5025        for field in &report.fields {
5026            let name = field.field.name();
5027            let now = after
5028                .fields
5029                .get(&field.field)
5030                .map(|held| held.options.as_slice())
5031                .unwrap_or_default();
5032            if !field.existing.iter().all(|old| now.contains(old)) {
5033                moved.push(format!(
5034                    "a pre-existing {name} option id, name, color or description"
5035                ));
5036            }
5037            if !field.missing.iter().all(|wanted| {
5038                now.iter()
5039                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5040            }) {
5041                moved.push(format!("an added {name} option"));
5042            }
5043            if after.assignments(field.field) != before.assignments(field.field) {
5044                moved.push(format!("an item's {name} value"));
5045            }
5046        }
5047        if !creates.is_empty() {
5048            // Read afresh: the view held for this command is the one from before the creates.
5049            *self.fields_cache()? = None;
5050            let board = match self.board_fields().await {
5051                Ok(board) => board,
5052                Err(error) => {
5053                    return Err(SourceError::Refused {
5054                        message: format!(
5055                            "the guarded field setup changed the {} field and then could not \
5056                             read the board back to verify it: {error}; run it again to verify \
5057                             it, which creates nothing a second time",
5058                            landed.join(" and ")
5059                        ),
5060                    });
5061                }
5062            };
5063            for field in &creates {
5064                let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
5065                    held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
5066                        && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
5067                });
5068                if !text {
5069                    moved.push(format!("the created {} text field", field.field));
5070                }
5071            }
5072        }
5073        if !moved.is_empty() {
5074            return Err(SourceError::Refused {
5075                message: format!(
5076                    "GitHub changed {} after the guarded field setup; the pre-write item \
5077                     assignments are:\n{}",
5078                    moved.join(", "),
5079                    recovery(&report, &before)?
5080                ),
5081            });
5082        }
5083        Ok(report)
5084    }
5085
5086    /// Validate configuration and capture the named credential without exposing it.
5087    ///
5088    /// # Errors
5089    ///
5090    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
5091    /// [`SourceError::Auth`] when the named credential is missing or empty.
5092    pub fn new(
5093        name: &SourceName,
5094        config: GitHubProjectsConfig,
5095        secrets: &dyn SecretResolver,
5096    ) -> Result<Self, SourceError> {
5097        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
5098    }
5099
5100    /// The same, recording every request it sends into an accounting the caller holds too.
5101    ///
5102    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
5103    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
5104    /// up — passes the one it records those into, so the session total accounts for the
5105    /// whole session rather than for this source's share of it.
5106    ///
5107    /// # Errors
5108    ///
5109    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
5110    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
5111    pub fn recording_into(
5112        name: &SourceName,
5113        config: GitHubProjectsConfig,
5114        secrets: &dyn SecretResolver,
5115        ledger: Arc<Accounting>,
5116    ) -> Result<Self, SourceError> {
5117        if !valid_github_owner(&config.owner) {
5118            return Err(SourceError::Config {
5119                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
5120            });
5121        }
5122        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
5123            return Err(SourceError::Config {
5124                message: format!("project_number must be between 1 and {}", i32::MAX),
5125            });
5126        }
5127        if !valid_environment_name(&config.token_env) {
5128            return Err(SourceError::Config {
5129                message: "token_env must be a valid environment-variable name".into(),
5130            });
5131        }
5132        let repository = config
5133            .repository
5134            .as_deref()
5135            .map(RepositoryTarget::parse)
5136            .transpose()?;
5137        if let Some(entry) = config
5138            .creation_owners
5139            .iter()
5140            .find(|entry| !valid_github_owner(entry))
5141        {
5142            return Err(SourceError::Config {
5143                message: format!(
5144                    "creation_owners entry {entry:?} is not a GitHub login: a login is 1-39 ASCII \
5145                     letters, digits, or single hyphens, and cannot start or end with a hyphen"
5146                ),
5147            });
5148        }
5149        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
5150            message: format!("endpoint is not a valid URL: {e}"),
5151        })?;
5152        if endpoint.scheme() != "https"
5153            && !(endpoint.scheme() == "http"
5154                && endpoint
5155                    .host_str()
5156                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
5157        {
5158            return Err(SourceError::Config {
5159                message:
5160                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
5161                        .into(),
5162            });
5163        }
5164        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
5165            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),
5166        })?;
5167        Ok(Self {
5168            name: name.clone(),
5169            owner: config.owner,
5170            project_number: config.project_number,
5171            repository,
5172            creation_owners: config.creation_owners,
5173            asset_client: std::sync::OnceLock::new(),
5174            body_markup: std::sync::OnceLock::new(),
5175            endpoint,
5176            token,
5177            credential_name: config.token_env,
5178            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
5179            priorities: config
5180                .priority_mapping
5181                .map(|mapping| PriorityMapping::resolve(mapping, name))
5182                .transpose()?,
5183            metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
5184            client: std::sync::OnceLock::new(),
5185            created: Mutex::new(Vec::new()),
5186            updated: Mutex::new(Vec::new()),
5187            commented: Mutex::new(Vec::new()),
5188            pacing: Pacing::resolve(config.pacing, name)?,
5189            last_mutation: Mutex::new(None),
5190            windows: Mutex::new(pacing::Windows::default()),
5191            task_children: Mutex::new(BTreeMap::new()),
5192            written: Mutex::new(BTreeMap::new()),
5193            deferred: Mutex::new(None),
5194            held_updates: Mutex::new(Vec::new()),
5195            clock: system_clock(),
5196            declared_private: std::sync::atomic::AtomicBool::new(false),
5197            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
5198            board_cache: Mutex::new(None),
5199            search_cache: Mutex::new(None),
5200            narrowed_cache: Mutex::new(BTreeMap::new()),
5201            resolved_cache: Mutex::new(BTreeMap::new()),
5202            read_ahead: Mutex::new(BTreeMap::new()),
5203            pending_origins: Mutex::new(Vec::new()),
5204            member_classes: Mutex::new(BTreeMap::new()),
5205            carried: Mutex::new(BTreeMap::new()),
5206            held_cache: Mutex::new(BTreeMap::new()),
5207            children_cache: Mutex::new(BTreeMap::new()),
5208            search_next: Mutex::new(BTreeMap::new()),
5209            fields_cache: Mutex::new(None),
5210            repository_cache: Mutex::new(BTreeMap::new()),
5211            ledger,
5212            expected: expected::Held::default(),
5213            projection: projection::Held::default(),
5214        })
5215    }
5216
5217    /// The client every API request goes through, built on the first one.
5218    ///
5219    /// Not with the source: building a client loads the system's certificate store, which
5220    /// costs tens of milliseconds, and a command that configures this board but never sends to
5221    /// it — a read of another source — should not pay that in its process.
5222    fn client(&self) -> Result<&Client, SourceError> {
5223        built(&self.client, || {
5224            Client::builder()
5225                .user_agent("onetaskgraph")
5226                .build()
5227                .map_err(|e| SourceError::Config {
5228                    message: format!("cannot build HTTP client: {e}"),
5229                })
5230        })
5231    }
5232
5233    /// The client attachment requests go through, with its own redirect policy, built on the
5234    /// first one for the reason [`Self::client`] gives.
5235    fn asset_client(&self) -> Result<&Client, SourceError> {
5236        built(&self.asset_client, || assets::client(&self.endpoint))
5237    }
5238
5239    /// A snapshot of every request this source has sent, and what each cost.
5240    ///
5241    /// A value to hold and compare rather than a borrow of the accounting itself, so two
5242    /// of them can sit side by side. When this source was built with
5243    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
5244    /// point of building it that way.
5245    #[must_use]
5246    pub fn accounting(&self) -> accounting::Session {
5247        self.ledger.snapshot()
5248    }
5249
5250    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
5251    /// rate limit rather than handing it straight back as an error.
5252    ///
5253    /// Retrying is safe for every document here, including the mutations, and the reason
5254    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
5255    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
5256    /// this replays has already taken effect. An outcome this source cannot know — the
5257    /// send failed, or the body could not be read, so the mutation may well have landed —
5258    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
5259    /// attempt. A duplicate write would come from replaying one of those, and none is
5260    /// replayed.
5261    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5262        // Updates held back go out before any other request, so whatever it reads or writes
5263        // sees the board as though each had been sent when it was made.
5264        Box::pin(self.send_held_updates()).await?;
5265        // Origins held back for a read ahead that never came are sent before anything else.
5266        if query != graphql::ORIGIN_LOOKUPS && query != graphql::ORIGIN_LOOKUPS_WITH_MEMBERS {
5267            Box::pin(self.send_pending_origins(None)).await?;
5268        }
5269        if !self.expected.journaling() {
5270            return self.graphql_sent(query, variables).await;
5271        }
5272        // While a write the store's cache expects runs, its mutations select the whole issue
5273        // they leave behind, so the response vouches for what the cache keeps — and every
5274        // request is recorded, so the write's parts are reported from their own responses.
5275        let (query, variables) = match graphql::whole_twin(query) {
5276            Some(twin) => (twin, expected::whole_variables(variables)),
5277            None => (query, variables),
5278        };
5279        let sent = self.graphql_sent(query, variables.clone()).await;
5280        self.journal(query, &variables, &sent).await;
5281        sent
5282    }
5283
5284    /// One document sent, retried while GitHub asks for a wait, exactly as asked.
5285    async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5286        if is_mutation(query)
5287            && ![
5288                graphql::ADD_COMMENT,
5289                graphql::UPDATE_COMMENT,
5290                graphql::DELETE_COMMENT,
5291                graphql::ADD_COMMENT_WHOLE,
5292                graphql::UPDATE_COMMENT_WHOLE,
5293            ]
5294            .contains(&query)
5295        {
5296            let mut cache = self.resolved_cache()?;
5297            let mut ahead = self.read_ahead()?;
5298            let mut carried = self.carried()?;
5299            for argument in ["input", "second", "third", "clear"] {
5300                if let Some(input) = variables.get(argument) {
5301                    let names = |id: &NativeId, item_id: Option<&str>| {
5302                        ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
5303                            input
5304                                .get(key)
5305                                .and_then(Value::as_str)
5306                                .is_some_and(|value| value == id.0 || Some(value) == item_id)
5307                        })
5308                    };
5309                    cache.retain(|id, item| !names(id, Some(&item.item_id)));
5310                    ahead.retain(|id, item| {
5311                        !names(id, item.as_ref().map(|item| item.item_id.as_str()))
5312                    });
5313                    carried.retain(|id, item| !names(id, Some(&item.item_id)));
5314                }
5315            }
5316        }
5317        let doing = operation_description(query);
5318        let mutation = is_mutation(query);
5319        let charge = pacing::Charge::graphql(query, &variables);
5320        let mut waited = Duration::ZERO;
5321        let mut waits = 0_u32;
5322        let mut backoff = self.pacing.retry_backoff;
5323        loop {
5324            let ticket = self.pace(charge, mutation).await;
5325            // Every write but a create is asked for immediately before it is sent — a create's
5326            // fields, its relationships, its content and the delete that takes it back included
5327            // — so a copy that owns the create renews its hold first, and one that has lost it
5328            // sends nothing more. Asked again before a retry, which is sent later.
5329            if mutation
5330                && !query.contains("createIssue(")
5331                && let Err(refused) = <dyn onetaskgraph_plugin_api::WriteGuard>::check().await
5332            {
5333                self.paced(ticket, mutation);
5334                return Err(refused);
5335            }
5336            let attempt = self.send_once(query, &variables, waits + 1).await;
5337            self.paced(ticket, mutation);
5338            let remaining = self.pacing.retry_budget.saturating_sub(waited);
5339            let (wait, cause) = match attempt {
5340                Ok(data) => return Ok(data),
5341                Err(Attempt::Failed(error)) => return Err(error),
5342                Err(Attempt::Conflicted(said)) => {
5343                    // A create is never replayed: see `CONFLICT_WORDINGS`.
5344                    if query.contains("createIssue(") || backoff.is_zero() || backoff > remaining {
5345                        return Err(SourceError::Unavailable {
5346                            message: format!(
5347                                "GitHub refused this source while {doing} as a temporary \
5348                                 conflict ({said}); it retried {} over {} and the conflict \
5349                                 did not clear within the {} one call may spend waiting. \
5350                                 next: run the command again once nothing else is writing to \
5351                                 the same items",
5352                                plural(waits, "time"),
5353                                seconds(waited),
5354                                seconds(self.pacing.retry_budget),
5355                            ),
5356                        });
5357                    }
5358                    (backoff, pacing::cause::BACKOFF)
5359                }
5360                Err(Attempt::Limited(limited)) => {
5361                    let (wait, cause) = limited.wait(backoff);
5362                    // A wait of nothing spends none of the budget, so it is exhaustion rather
5363                    // than a retry. `Pacing::resolve` rules out every way of configuring one
5364                    // except a budget of zero, where reporting the first refusal is the ask.
5365                    if wait.is_zero() || wait > remaining {
5366                        return Err(limited.exhausted(
5367                            doing,
5368                            waits,
5369                            waited,
5370                            wait,
5371                            self.pacing.retry_budget,
5372                        ));
5373                    }
5374                    (wait, cause)
5375                }
5376            };
5377            self.clock.sleep_because(wait, cause).await;
5378            waited += wait;
5379            waits += 1;
5380            backoff = backoff.saturating_mul(2);
5381        }
5382    }
5383
5384    /// Hold one request charging `charge` until every secondary window it charges has room for
5385    /// the whole of it — and, for a mutation of an instance configured with
5386    /// `pacing.min_mutation_interval_ms`, until that interval has passed since the last one
5387    /// finished — then count it, answering the ticket [`Self::paced`] finishes it with.
5388    ///
5389    /// The moment is decided and counted under the lock and waited out outside it, so two
5390    /// callers take two places rather than the same one, and no lock is held across an await.
5391    /// A request with room goes out at once: nothing here waits that a window does not ask for.
5392    async fn pace(&self, charge: pacing::Charge, mutation: bool) -> pacing::Ticket {
5393        let (wait, cause, ticket) = {
5394            // A poisoned lock here costs pacing, not correctness, and refusing the write over
5395            // it would turn an earlier failure into a second one for no gain.
5396            let mut windows = self
5397                .windows
5398                .lock()
5399                .unwrap_or_else(std::sync::PoisonError::into_inner);
5400            let now = self.clock.now();
5401            let (mut at, mut cause) = if self.pacing.windows {
5402                windows.room(now, charge)
5403            } else {
5404                (now, None)
5405            };
5406            if mutation && !self.pacing.min_mutation_interval.is_zero() {
5407                let mut last = self
5408                    .last_mutation
5409                    .lock()
5410                    .unwrap_or_else(std::sync::PoisonError::into_inner);
5411                // `checked_add` rather than `+`: adding durations can panic on overflow, and
5412                // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
5413                if let Some(earliest) = last
5414                    .and_then(|previous| previous.checked_add(self.pacing.min_mutation_interval))
5415                    && earliest > at
5416                {
5417                    at = earliest;
5418                    cause = Some(pacing::cause::MIN_INTERVAL);
5419                }
5420                *last = Some(at);
5421            }
5422            let ticket = windows.charge(at, charge);
5423            (at.saturating_sub(now), cause, ticket)
5424        };
5425        if let (false, Some(cause)) = (wait.is_zero(), cause) {
5426            self.clock.sleep_because(wait, cause).await;
5427        }
5428        ticket
5429    }
5430
5431    /// Record that the request `ticket` was charged for has finished, so each window — and
5432    /// the configured spacing — counts it from here rather than from the moment it left.
5433    ///
5434    /// This source can only choose when a request *departs*; GitHub's limiter counts when it
5435    /// *arrives*, and the two differ by whatever the request spent in transit. Counting from
5436    /// departure would hand the limiter a window that looked roomier than it is, so a source
5437    /// pacing exactly at a limit could still be seen arriving over it — which is how a copy
5438    /// paced well inside a board's threshold was once refused by it on a slower machine while
5439    /// passing on a quick one. The previous request had arrived before its response came back,
5440    /// so counting from here is never earlier than the limiter does, whatever transit costs
5441    /// and on whatever platform: the safe side of a limit that punishes being wrong by refusing
5442    /// reads for the next fifty minutes.
5443    ///
5444    /// A failed attempt is finished too: a request refused by the limiter still arrived, and
5445    /// one that never left costs only room nobody needed.
5446    fn paced(&self, ticket: pacing::Ticket, mutation: bool) {
5447        let now = self.clock.now();
5448        self.windows
5449            .lock()
5450            .unwrap_or_else(std::sync::PoisonError::into_inner)
5451            .finish(ticket, now);
5452        if mutation && !self.pacing.min_mutation_interval.is_zero() {
5453            let mut last = self
5454                .last_mutation
5455                .lock()
5456                .unwrap_or_else(std::sync::PoisonError::into_inner);
5457            // `max` rather than an assignment: a concurrent caller may already have reserved a
5458            // slot further out, and completing this request must never pull that slot back in.
5459            *last = Some(last.map_or(now, |reserved| reserved.max(now)));
5460        }
5461    }
5462
5463    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
5464    /// failure that waiting cannot help — and recorded, whichever of the three it was.
5465    ///
5466    /// This is the one place a request leaves this crate, which is why the accounting is
5467    /// here rather than at each of the callers: a read path added later is counted without
5468    /// anybody remembering to count it, and
5469    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
5470    /// when one is not.
5471    async fn send_once(
5472        &self,
5473        query: &str,
5474        variables: &Value,
5475        number: u32,
5476    ) -> Result<Value, Attempt> {
5477        let executed = self.ledger.priced_document(query);
5478        let Attempted {
5479            result,
5480            limits,
5481            reported_cost,
5482            response_outcome,
5483        } = self.attempt(executed.as_ref(), variables).await;
5484        // No `otherwise` name: every document this source sends is one of its own, and the
5485        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
5486        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
5487        let outcome = match &result {
5488            Ok(_) => accounting::Outcome::Answered,
5489            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
5490            Err(Attempt::Failed(_) | Attempt::Conflicted(_)) => accounting::Outcome::Refused,
5491        };
5492        self.ledger
5493            .record(sending.finished(outcome, limits).with_execution(
5494                Some(executed.into_owned()),
5495                response_outcome,
5496                number,
5497            ));
5498        result
5499    }
5500
5501    /// The attempt itself, with what its response said about the rate limit alongside.
5502    ///
5503    /// The two are returned together rather than recorded here because every one of the
5504    /// early exits below is a different outcome, and a record written at each of them is a
5505    /// record one of them can be added without.
5506    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
5507        let mut limits = accounting::RateLimit::default();
5508        let mut reported_cost = None;
5509        let mut response_outcome = accounting::ResponseOutcome::TransportError;
5510        let result = self
5511            .attempted(
5512                query,
5513                variables,
5514                &mut limits,
5515                &mut reported_cost,
5516                &mut response_outcome,
5517            )
5518            .await;
5519        Attempted {
5520            result,
5521            limits,
5522            reported_cost,
5523            response_outcome,
5524        }
5525    }
5526
5527    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
5528    async fn attempted(
5529        &self,
5530        query: &str,
5531        variables: &Value,
5532        limits: &mut accounting::RateLimit,
5533        reported_cost: &mut Option<u64>,
5534        response_outcome: &mut accounting::ResponseOutcome,
5535    ) -> Result<Value, Attempt> {
5536        let response = self
5537            .client()
5538            .map_err(Attempt::Failed)?
5539            .post(self.endpoint.clone())
5540            .bearer_auth(self.token.expose_secret())
5541            .json(&json!({"query": query, "variables": variables}))
5542            .send()
5543            .await
5544            .map_err(|e| {
5545                Attempt::Failed(SourceError::Unavailable {
5546                    message: format!("GitHub GraphQL request failed: {}", transport_error(&e)),
5547                })
5548            })?;
5549        let status = response.status();
5550        if !status.is_success() {
5551            *response_outcome = accounting::ResponseOutcome::HttpError;
5552        }
5553        let header = |name: &str| whole_seconds(response.headers().get(name));
5554        let served = response
5555            .headers()
5556            .get(reqwest::header::DATE)
5557            .and_then(|value| value.to_str().ok())
5558            .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
5559            .map(|at| at.with_timezone(&Utc));
5560        *limits = accounting::RateLimit::read(|name| {
5561            response
5562                .headers()
5563                .get(name)
5564                .and_then(|value| value.to_str().ok())
5565                .map(str::to_owned)
5566        });
5567        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
5568        // that are not text at all — is "not known to be exhausted". This never makes a
5569        // response a refusal on its own: it says which limiter a refusal is attributed to
5570        // and where its hint comes from, so a value this cannot read costs a hint rather
5571        // than an answer.
5572        let exhausted = response
5573            .headers()
5574            .get("x-ratelimit-remaining")
5575            .and_then(|value| value.to_str().ok())
5576            == Some("0");
5577        // `retry-after` is what GitHub asks for when it asks; when it does not and the
5578        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
5579        // which is the same question answered as an absolute time. Nothing else here is a
5580        // hint, and a schedule is what answers a refusal that carries none.
5581        let retry_after = header("retry-after");
5582        let hinted_by = if retry_after.is_some() || !exhausted {
5583            Hint::RetryAfter
5584        } else {
5585            Hint::PrimaryReset
5586        };
5587        let hint = retry_after.or_else(|| {
5588            exhausted
5589                .then(|| header("x-ratelimit-reset"))
5590                .flatten()
5591                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
5592        });
5593        // Read before it is parsed, because the evidence which tells a secondary rate
5594        // limit from a rejected credential is in the body of a response whose status says
5595        // only "forbidden" — and a non-success response was never parsed at all.
5596        let body = response.text().await.map_err(|e| {
5597            Attempt::Failed(SourceError::Unavailable {
5598                message: format!(
5599                    "GitHub GraphQL response could not be read: {}",
5600                    transport_error(&e)
5601                ),
5602            })
5603        })?;
5604        *reported_cost = serde_json::from_str::<Value>(&body)
5605            .ok()
5606            .as_ref()
5607            .and_then(|body| body.pointer("/data/rateLimit/cost"))
5608            .and_then(Value::as_u64);
5609        let parsed = serde_json::from_str::<Value>(&body).ok();
5610        *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
5611        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
5612            return Err(Attempt::Limited(Limited {
5613                limiter,
5614                hint,
5615                hinted_by,
5616            }));
5617        }
5618        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
5619            return Err(Attempt::Failed(SourceError::Auth {
5620                message: format!(
5621                    "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"
5622                ),
5623            }));
5624        }
5625        if !status.is_success() {
5626            return Err(Attempt::Failed(SourceError::Unavailable {
5627                message: format!("GitHub GraphQL returned HTTP {status}"),
5628            }));
5629        }
5630        let said = refusal_wording(status, &body);
5631        let normalized = said.to_ascii_lowercase();
5632        if CONFLICT_WORDINGS
5633            .iter()
5634            .any(|wording| normalized.contains(wording))
5635        {
5636            return Err(Attempt::Conflicted(said));
5637        }
5638        let answered = if query == graphql::ISSUES_BY_REFERENCE {
5639            self.answer_unresolved(&body)
5640        } else if query == graphql::CREATE_ISSUES {
5641            answer_with_errors(&body).map_or_else(|| self.answer(&body), Ok)
5642        } else {
5643            self.answer(&body)
5644        };
5645        let mut data = answered.map_err(Attempt::Failed)?;
5646        if let Some(served) = served {
5647            stamp_served(&mut data, &served.to_rfc3339());
5648        }
5649        Ok(data)
5650    }
5651
5652    /// What one successful HTTP response says, once its GraphQL errors are read.
5653    /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
5654    /// for this credential — a repository it cannot see, an issue number it does not hold —
5655    /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
5656    /// that reference rather than a failure of the others. Any other error refuses the read,
5657    /// as there.
5658    fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
5659        let parsed: Option<Value> = serde_json::from_str(body).ok();
5660        // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
5661        // `issue`, and the answer holds `null` at exactly that path: anything else — another
5662        // kind of error, one naming no slot, or one beside a value it says is not there — is a
5663        // refusal of the whole read.
5664        let unresolved = |data: &Value, error: &Value| {
5665            error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
5666                && error
5667                    .get("path")
5668                    .and_then(Value::as_array)
5669                    .is_some_and(|path| {
5670                        let alias = path.first().and_then(Value::as_str).and_then(|alias| {
5671                            alias
5672                                .strip_prefix('r')
5673                                .and_then(|slot| slot.parse::<usize>().ok())
5674                                .filter(|slot| *slot < REFERENCE_BATCH)
5675                                .map(|_| alias)
5676                        });
5677                        match (alias, path.get(1).and_then(Value::as_str), path.len()) {
5678                            (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
5679                            (Some(alias), Some("issue"), 2) => data
5680                                .get(alias)
5681                                .and_then(|repository| repository.get("issue"))
5682                                .is_some_and(Value::is_null),
5683                            _ => false,
5684                        }
5685                    })
5686        };
5687        let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
5688            parsed.get("data").is_some_and(|data| {
5689                data.is_object()
5690                    && parsed
5691                        .get("errors")
5692                        .and_then(Value::as_array)
5693                        .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
5694            })
5695        });
5696        match parsed {
5697            Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
5698            _ => self.answer(body),
5699        }
5700    }
5701
5702    fn answer(&self, body: &str) -> Result<Value, SourceError> {
5703        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
5704            message: format!("GitHub returned invalid JSON: {e}"),
5705        })?;
5706        let errors = body
5707            .get("errors")
5708            .map(|value| {
5709                value.as_array().ok_or_else(|| SourceError::Malformed {
5710                    message: "GitHub response errors is not an array".into(),
5711                })
5712            })
5713            .transpose()?;
5714        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
5715            let messages = errors
5716                .iter()
5717                .filter_map(|e| e.get("message").and_then(Value::as_str))
5718                .collect::<Vec<_>>()
5719                .join("; ");
5720            let message = if messages.is_empty() {
5721                "GitHub returned GraphQL errors".into()
5722            } else {
5723                messages
5724            };
5725            let normalized = message.to_ascii_lowercase();
5726            if normalized.contains("resource not accessible") || normalized.contains("scope") {
5727                return Err(SourceError::Auth {
5728                    message: format!(
5729                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
5730                        self.credential_name
5731                    ),
5732                });
5733            }
5734            return Err(SourceError::Refused { message });
5735        }
5736        body.get("data")
5737            .filter(|data| data.is_object())
5738            .cloned()
5739            .ok_or_else(|| SourceError::Malformed {
5740                message: "GitHub response has no data object".into(),
5741            })
5742    }
5743
5744    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
5745    // GraphQL cannot independently page them inside the outer item page. This source page is
5746    // deliberately bounded at that published maximum; the live drift journey exercises it.
5747    async fn board_page(
5748        &self,
5749        items_after: Option<&str>,
5750        items_first: u32,
5751    ) -> Result<Value, SourceError> {
5752        let data = self
5753            .graphql(
5754                graphql::BOARD,
5755                json!({"owner":self.owner,"number":self.project_number,
5756                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
5757                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
5758            )
5759            .await?;
5760        data.pointer("/owner/projectV2")
5761            .filter(|v| !v.is_null())
5762            .cloned()
5763            .ok_or_else(|| SourceError::Refused {
5764                message: format!(
5765                    "GitHub project {}/{} was not found or is not visible to the token",
5766                    self.owner, self.project_number
5767                ),
5768            })
5769    }
5770
5771    /// The search that finds the issues of this board, narrowed by `also` when it is
5772    /// given.
5773    ///
5774    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
5775    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
5776    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
5777    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
5778    /// from a task by the `parent` field each issue carries rather than by the search.
5779    fn board_search(&self, also: Option<&str>) -> String {
5780        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
5781        match also {
5782            Some(also) => format!("{scope} {also}"),
5783            None => scope,
5784        }
5785    }
5786
5787    /// One issue this source reached directly, as the board item a read of the board would
5788    /// have produced — or `None` when this board does not hold it.
5789    ///
5790    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
5791    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
5792    /// item's own id, that item's field values, and the issue as its content. One resolver
5793    /// for both routes is what makes an issue read through a search, through its own node
5794    /// id, or through its project's sub-issues report the same title, the same status, the
5795    /// same labels and the same qualified id.
5796    ///
5797    /// An issue with no entry for *this* board is not this source's to report, which is
5798    /// what keeps an id naming some other repository's issue from being answered as an item
5799    /// of this board. That answer is given about an **exhausted** connection and never
5800    /// about an unread page: the entry is looked for on the page in hand, and only if that
5801    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
5802    /// rest of it.
5803    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
5804        self.resolve_issue_as(issue, true).await
5805    }
5806
5807    /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
5808    /// and writes only when `remember` says to.
5809    async fn resolve_issue_as(
5810        &self,
5811        issue: &Value,
5812        remember: bool,
5813    ) -> Result<Option<Resolved>, SourceError> {
5814        if optional_str(issue, "__typename")? != Some("Issue") {
5815            return Ok(None);
5816        }
5817        let memberships = issue
5818            .get("projectItems")
5819            .ok_or_else(|| SourceError::Malformed {
5820                message: "GitHub issue is missing projectItems".into(),
5821            })?;
5822        let nodes = memberships
5823            .get("nodes")
5824            .and_then(Value::as_array)
5825            .ok_or_else(|| SourceError::Malformed {
5826                message: "GitHub issue projectItems.nodes is not an array".into(),
5827            })?;
5828        let held = match self.board_entry(nodes) {
5829            Some(held) => held.clone(),
5830            None => {
5831                let info = memberships
5832                    .get("pageInfo")
5833                    .ok_or_else(|| SourceError::Malformed {
5834                        message: "GitHub issue projectItems has no pageInfo".into(),
5835                    })?;
5836                // The page held no entry for this board. Whether that means the issue is
5837                // not on it is a question about the rest of the connection, and only a
5838                // connection with no rest answers it here.
5839                if !required_bool(info, "hasNextPage")? {
5840                    return Ok(None);
5841                }
5842                let cursor = required_str(info, "endCursor")?;
5843                validate_cursor_progress(None, cursor)?;
5844                let issue_id = required_str(issue, "id")?;
5845                match self.board_membership(issue_id, cursor).await? {
5846                    Some(held) => held,
5847                    None => return Ok(None),
5848                }
5849            }
5850        };
5851        // The membership's own `updatedAt` rides along: it is the board half of the item's change
5852        // token, which a field change moves where the issue's own time does not.
5853        let item = json!({
5854            "id": required_str(&held, "id")?,
5855            "updatedAt": held.get("updatedAt"),
5856            "project": held.get("project"),
5857            "fieldValues": held.get("fieldValues"),
5858            "content": issue,
5859        });
5860        if remember {
5861            self.resolve(&item)
5862        } else {
5863            self.resolve_unremembered(&item)
5864        }
5865    }
5866
5867    /// This board's own entry among one page of an issue's `Issue.projectItems`.
5868    ///
5869    /// One spelling of *which membership is this board's*, so the page a read carries and
5870    /// the pages [`Self::board_membership`] walks are searched by the same rule.
5871    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
5872        nodes.iter().find(|node| {
5873            node.pointer("/project/number").and_then(Value::as_u64)
5874                == Some(u64::from(self.project_number))
5875        })
5876    }
5877
5878    /// The rest of one issue's board memberships, from `after`, for this board's entry.
5879    ///
5880    /// The recovery read: a page of memberships that holds no entry for this board says
5881    /// nothing about the memberships past it, so the connection is walked to exhaustion
5882    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
5883    /// positive answer — the whole connection was read and no entry named this board —
5884    /// rather than a failure, and the walk is held to
5885    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
5886    /// with a cursor that does not advance is refused instead of spun on.
5887    async fn board_membership(
5888        &self,
5889        issue: &str,
5890        after: &str,
5891    ) -> Result<Option<Value>, SourceError> {
5892        let mut after = after.to_owned();
5893        loop {
5894            let data = self
5895                .graphql(
5896                    graphql::ISSUE_BOARD_ITEMS,
5897                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
5898                           "nestedFirst":NESTED_PAGE_SIZE}),
5899                )
5900                .await?;
5901            let Some(connection) = data
5902                .pointer("/node/projectItems")
5903                .filter(|value| !value.is_null())
5904            else {
5905                // The id resolved to nothing, or to something with no memberships to walk —
5906                // which is the same answer as a connection holding no entry for this board.
5907                return Ok(None);
5908            };
5909            let nodes = connection
5910                .get("nodes")
5911                .and_then(Value::as_array)
5912                .ok_or_else(|| SourceError::Malformed {
5913                    message: "GitHub issue projectItems.nodes is not an array".into(),
5914                })?;
5915            if let Some(held) = self.board_entry(nodes) {
5916                return Ok(Some(held.clone()));
5917            }
5918            let info = connection
5919                .get("pageInfo")
5920                .ok_or_else(|| SourceError::Malformed {
5921                    message: "GitHub issue projectItems has no pageInfo".into(),
5922                })?;
5923            let next = required_bool(info, "hasNextPage")?
5924                .then(|| required_str(info, "endCursor"))
5925                .transpose()?;
5926            match next {
5927                Some(next) => {
5928                    validate_cursor_progress(Some(&after), next)?;
5929                    after = next.to_owned();
5930                }
5931                None => return Ok(None),
5932            }
5933        }
5934    }
5935
5936    /// One page of a board-scoped issue search, and where the next page resumes.
5937    async fn search_page(
5938        &self,
5939        search: &str,
5940        first: u32,
5941        after: Option<&str>,
5942    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
5943        let data = self
5944            .graphql(
5945                graphql::SEARCH_ISSUES,
5946                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
5947                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
5948                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5949            )
5950            .await?;
5951        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
5952            message: "GitHub search response has no search connection".into(),
5953        })?;
5954        let mut found = Vec::new();
5955        for node in connection
5956            .get("nodes")
5957            .and_then(Value::as_array)
5958            .ok_or_else(|| SourceError::Malformed {
5959                message: "GitHub search nodes is not an array".into(),
5960            })?
5961        {
5962            if let Some(resolved) = self.resolve_issue(node).await? {
5963                found.push(resolved);
5964            }
5965        }
5966        let info = connection
5967            .get("pageInfo")
5968            .ok_or_else(|| SourceError::Malformed {
5969                message: "GitHub search connection has no pageInfo".into(),
5970            })?;
5971        let next = required_bool(info, "hasNextPage")?
5972            .then(|| required_str(info, "endCursor"))
5973            .transpose()?
5974            .map(str::to_owned);
5975        if let Some(next) = &next {
5976            validate_cursor_progress(after, next)?;
5977        }
5978        Ok((found, next))
5979    }
5980
5981    /// Every issue this board holds, completed with what this run wrote.
5982    ///
5983    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
5984    /// is an index and is eventually consistent, so an issue this run created seconds ago
5985    /// can be absent from it, and a project listed straight after being written would
5986    /// otherwise be missing from its own board. What is added back is only what this
5987    /// process itself wrote, out of [`Self::created`], which lives and dies with the
5988    /// process.
5989    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
5990        let found = self.searched_issues().await?;
5991        self.completed_with_written(found, |_| true)
5992    }
5993
5994    /// Every issue this board's own search reports, walked to exhaustion, read once per
5995    /// source.
5996    ///
5997    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
5998    /// needs it too and the two would otherwise walk the same search twice in one command.
5999    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
6000    /// is.
6001    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6002        let cached = self.search_cache()?.clone();
6003        if let Some(held) = cached {
6004            return Ok(held);
6005        }
6006        let mut after: Option<String> = None;
6007        let mut found = Vec::new();
6008        let search = self.board_search(None);
6009        loop {
6010            let (page, next) = self
6011                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6012                .await?;
6013            found.extend(page);
6014            match next {
6015                Some(next) => after = Some(next),
6016                None => break,
6017            }
6018        }
6019        *self.search_cache()? = Some(found.clone());
6020        Ok(found)
6021    }
6022
6023    /// Every issue this board's own search reports with `also` beside its scope, walked to
6024    /// exhaustion — held for no later read, which may not be narrowed alike.
6025    async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6026        let mut after: Option<String> = None;
6027        let mut found = Vec::new();
6028        let search = self.board_search(Some(also));
6029        loop {
6030            let (page, next) = self
6031                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6032                .await?;
6033            found.extend(page);
6034            match next {
6035                Some(next) => after = Some(next),
6036                None => return Ok(found),
6037            }
6038        }
6039    }
6040
6041    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
6042    fn search_cache(
6043        &self,
6044    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
6045        self.search_cache
6046            .lock()
6047            .map_err(|_| SourceError::Unavailable {
6048                message: "this source's view of the board's issues was left inconsistent by an \
6049                      earlier failure; next: run the command again"
6050                    .into(),
6051            })
6052    }
6053
6054    /// `found`, with everything this run wrote that `keep` accepts and the read did not
6055    /// report.
6056    ///
6057    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
6058    /// at all: the search index is behind, and a node read of an item filed moments ago can
6059    /// be too.
6060    fn completed_with_written(
6061        &self,
6062        mut found: Vec<Resolved>,
6063        keep: impl Fn(&Resolved) -> bool,
6064    ) -> Result<Vec<Resolved>, SourceError> {
6065        for own in self.created()?.iter().filter(|own| keep(own)) {
6066            if !found.iter().any(|item| item.id == own.id) {
6067                found.push(own.clone());
6068            }
6069        }
6070        Ok(found)
6071    }
6072
6073    /// What resolving one node id reached.
6074    ///
6075    /// Three answers rather than an `Option`, because a board *draft* is none of the other
6076    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
6077    /// is completed by a read of the draft itself rather than reported as nothing.
6078    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
6079        let asked = self
6080            .graphql(
6081                graphql::ISSUE,
6082                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
6083                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6084            )
6085            .await;
6086        let data = match asked {
6087            Ok(data) => data,
6088            // A string that is not a node id at all is not a failure to report: it is an id
6089            // this board does not hold, which is what every read of one already answers.
6090            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
6091            Err(error) => return Err(error),
6092        };
6093        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6094            return Ok(Reached::Nothing);
6095        };
6096        if optional_str(node, "__typename")? == Some("DraftIssue") {
6097            return Ok(Reached::Draft);
6098        }
6099        Ok(match self.resolve_issue(node).await? {
6100            Some(item) => Reached::Held(Box::new(item)),
6101            None => Reached::Nothing,
6102        })
6103    }
6104
6105    /// One item of this board by its own id, whatever kind it is.
6106    ///
6107    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
6108    /// run wrote is read first, because a node read of an item created moments ago can
6109    /// still be behind the board field values written onto it — see [`Self::created`].
6110    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6111        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
6112            return Ok(Some(own.clone()));
6113        }
6114        if let Some(held) = self.read_ahead()?.remove(id) {
6115            return Ok(held);
6116        }
6117        match self.reach(id).await? {
6118            Reached::Held(item) => Ok(Some(*item)),
6119            Reached::Nothing => Ok(None),
6120            Reached::Draft => self.draft_by_id(id).await,
6121        }
6122    }
6123
6124    /// [`Self::item_by_id`] for a document: one [`graphql::DOCUMENT_IN_PROJECT`] read, which
6125    /// answers the project it is filed under and that project's members beside it.
6126    ///
6127    /// Each is held for this command as a read of it would have been: the project for the
6128    /// next read of it by its id, and — when its page of members is the whole of them — the
6129    /// members for the next listing of that project. So a write held to who may read the
6130    /// document, which reads all three, is answered by this one request.
6131    async fn document_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6132        if self.created()?.iter().any(|own| own.id == *id) || self.read_ahead()?.contains_key(id) {
6133            return self.item_by_id(id).await;
6134        }
6135        let asked = self
6136            .graphql(
6137                graphql::DOCUMENT_IN_PROJECT,
6138                json!({"id":id.0,"first":MAX_PAGE_SIZE,"memberFirst":MEMBER_PAGE_SIZE,
6139                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6140                       "duplicates":true}),
6141            )
6142            .await;
6143        let data = match asked {
6144            Ok(data) => data,
6145            Err(error) if unresolvable_node(&error) => return Ok(None),
6146            Err(error) => return Err(error),
6147        };
6148        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6149            return Ok(None);
6150        };
6151        if optional_str(node, "__typename")? == Some("DraftIssue") {
6152            return self.draft_by_id(id).await;
6153        }
6154        let Some(item) = self.resolve_issue(node).await? else {
6155            return Ok(None);
6156        };
6157        // An answer for another issue than the one asked about says nothing of that one.
6158        if item.id != *id {
6159            return Err(SourceError::Malformed {
6160                message: format!(
6161                    "GitHub answered issue {} when asked for document {}",
6162                    item.id.0, id.0
6163                ),
6164            });
6165        }
6166        if let Some(project) = node.get("project").filter(|value| !value.is_null())
6167            && let Some(parent) = self
6168                .resolve_issue(project)
6169                .await?
6170                .filter(|parent| Some(&parent.id) == item.parent.as_ref())
6171        {
6172            let members = project.get("subIssues").filter(|value| !value.is_null());
6173            let whole = members
6174                .and_then(|members| members.pointer("/pageInfo/hasNextPage"))
6175                .and_then(Value::as_bool)
6176                == Some(false);
6177            if whole {
6178                let nodes = members
6179                    .and_then(|members| members.get("nodes"))
6180                    .and_then(Value::as_array)
6181                    .ok_or_else(|| SourceError::Malformed {
6182                        message: "GitHub subIssues.nodes is not an array".into(),
6183                    })?;
6184                let mut children = Vec::new();
6185                for member in nodes {
6186                    if let Some(resolved) = self.resolve_issue(member).await? {
6187                        children.push(resolved);
6188                    }
6189                }
6190                // Held as the project's members only when every one resolved and says it is
6191                // filed under it: a page with any other answers nothing of all the members.
6192                if children.len() == nodes.len()
6193                    && children
6194                        .iter()
6195                        .all(|child| child.parent.as_ref() == Some(&parent.id))
6196                {
6197                    self.children_cache()?
6198                        .insert(parent.id.clone(), (parent.id.clone(), children));
6199                }
6200            }
6201            self.read_ahead()?.insert(parent.id.clone(), Some(parent));
6202        }
6203        Ok(Some(item))
6204    }
6205
6206    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
6207    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
6208    /// than one request per id.
6209    ///
6210    /// What this run wrote answers first, as it does there, and only the rest is read. One id
6211    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
6212    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
6213    /// id at a time, so that id is answered as not held and the others as themselves; a draft
6214    /// is completed by a read of the draft, exactly as there.
6215    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
6216        let mut found: Vec<Option<Option<Resolved>>> = {
6217            let created = self.created()?;
6218            ids.iter()
6219                .map(|id| {
6220                    created
6221                        .iter()
6222                        .find(|own| own.id == *id)
6223                        .map(|own| Some(own.clone()))
6224                })
6225                .collect()
6226        };
6227        let unread: Vec<NativeId> = ids
6228            .iter()
6229            .zip(&found)
6230            .filter(|(_, found)| found.is_none())
6231            .map(|(id, _)| id.clone())
6232            .collect();
6233        let mut read = Vec::with_capacity(unread.len());
6234        if let [one] = unread.as_slice() {
6235            read.push(self.item_by_id(one).await?);
6236        } else {
6237            for batch in unread.chunks(DETAIL_BATCH) {
6238                let data = match self
6239                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
6240                    .await
6241                {
6242                    Ok(data) => data,
6243                    Err(error) if unresolvable_node(&error) => {
6244                        for id in batch {
6245                            read.push(self.item_by_id(id).await?);
6246                        }
6247                        continue;
6248                    }
6249                    Err(error) => return Err(error),
6250                };
6251                for (slot, id) in batch.iter().enumerate() {
6252                    let node =
6253                        data.get(format!("i{slot}"))
6254                            .ok_or_else(|| SourceError::Malformed {
6255                                message: format!(
6256                                    "GitHub answered a batch read with no item for {}",
6257                                    id.0
6258                                ),
6259                            })?;
6260                    read.push(if node.is_null() {
6261                        None
6262                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
6263                        self.draft_by_id(id).await?
6264                    } else {
6265                        if optional_str(node, "__typename")? == Some("Issue")
6266                            && required_str(node, "id")? != id.0
6267                        {
6268                            return Err(SourceError::Malformed {
6269                                message: format!(
6270                                    "GitHub answered the read of {} with issue {}",
6271                                    id.0,
6272                                    required_str(node, "id")?
6273                                ),
6274                            });
6275                        }
6276                        self.resolve_issue(node).await?
6277                    });
6278                }
6279            }
6280        }
6281        let mut read = read.into_iter();
6282        Ok(found
6283            .iter_mut()
6284            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
6285            .collect())
6286    }
6287
6288    fn read_ahead(
6289        &self,
6290    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
6291        self.read_ahead
6292            .lock()
6293            .map_err(|_| SourceError::Unavailable {
6294                message:
6295                    "items read ahead for a copy were left inconsistent; run the command again"
6296                        .into(),
6297            })
6298    }
6299
6300    fn resolved_cache(
6301        &self,
6302    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
6303        self.resolved_cache
6304            .lock()
6305            .map_err(|_| SourceError::Unavailable {
6306                message: "resolved item records were left inconsistent; run the command again"
6307                    .into(),
6308            })
6309    }
6310
6311    /// Reuse a record this invocation already resolved. The mutation sender invalidates
6312    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
6313    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6314        let cached = self.resolved_cache()?.get(id).cloned();
6315        if let Some(item) = cached {
6316            return Ok(Some(item));
6317        }
6318        self.item_by_id(id).await
6319    }
6320
6321    /// Take out the item an issue read by reference reached for `id`, when one is held: the
6322    /// status write it was read for is the one caller, so no other read or write is ever
6323    /// answered from a read that selected only what that write needs.
6324    fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6325        Ok(self
6326            .held_cache
6327            .lock()
6328            .map_err(|_| SourceError::Unavailable {
6329                message: "items read by issue reference were left inconsistent; run the \
6330                          command again"
6331                    .into(),
6332            })?
6333            .remove(id))
6334    }
6335
6336    /// One board draft by its own id, with the board item it sits in — or `None` when no
6337    /// item of this board is that draft's.
6338    ///
6339    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
6340    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
6341    /// links a draft to one board item, so the page this read carries is the whole of that
6342    /// connection, and a page that reports more than it holds is refused rather than read
6343    /// as an answer about memberships nobody read.
6344    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6345        let data = self
6346            .graphql(
6347                graphql::DRAFT,
6348                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
6349                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
6350            )
6351            .await?;
6352        // Gone between the two reads is an answer — the draft is no longer there. Anything
6353        // else than the draft [`Self::reach`] was just told this id is, is not one.
6354        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
6355            return Ok(None);
6356        };
6357        if optional_str(draft, "__typename")? != Some("DraftIssue") {
6358            return Err(SourceError::Malformed {
6359                message: format!(
6360                    "GitHub answered {} as a draft and then as something else",
6361                    id.0
6362                ),
6363            });
6364        }
6365        if required_str(draft, "id")? != id.0 {
6366            return Err(SourceError::Malformed {
6367                message: format!("GitHub answered a different draft for {}", id.0),
6368            });
6369        }
6370        let memberships = draft
6371            .get("projectV2Items")
6372            .ok_or_else(|| SourceError::Malformed {
6373                message: format!("GitHub draft {} is missing projectV2Items", id.0),
6374            })?;
6375        let nodes = memberships
6376            .get("nodes")
6377            .and_then(Value::as_array)
6378            .ok_or_else(|| SourceError::Malformed {
6379                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
6380            })?;
6381        let info = memberships
6382            .get("pageInfo")
6383            .ok_or_else(|| SourceError::Malformed {
6384                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
6385            })?;
6386        // Read whether or not this board's entry is on the page: a page claiming more than
6387        // the one item GitHub links a draft to is a malformed answer either way.
6388        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
6389            return Err(SourceError::Malformed {
6390                message: format!(
6391                    "GitHub draft {} reports more board items than the one GitHub links a draft \
6392                     to",
6393                    id.0
6394                ),
6395            });
6396        }
6397        if let Some(node) = nodes.first()
6398            && node
6399                .pointer("/project/number")
6400                .and_then(Value::as_u64)
6401                .is_none()
6402        {
6403            return Err(SourceError::Malformed {
6404                message: format!(
6405                    "GitHub draft {} board item has no numeric project number",
6406                    id.0
6407                ),
6408            });
6409        }
6410        let Some(held) = self.board_entry(nodes) else {
6411            return Ok(None);
6412        };
6413        if required_str(
6414            held.get("project").ok_or_else(|| SourceError::Malformed {
6415                message: format!("GitHub draft {} board item has no project", id.0),
6416            })?,
6417            "id",
6418        )? != self.board_fields().await?.id.as_str()
6419        {
6420            return Ok(None);
6421        }
6422        let item = json!({
6423            "id": required_str(held, "id")?,
6424            "project": held.get("project"),
6425            "fieldValues": held.get("fieldValues"),
6426            "content": draft,
6427        });
6428        self.resolve(&item)
6429    }
6430
6431    /// The board's own id and field definitions, for a write whose item does not carry
6432    /// them — never its items.
6433    ///
6434    /// A board this command has already listed supplies them, since it read them beside its
6435    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
6436    /// is consulted about which items the board holds: see the module documentation for
6437    /// why a question about one known item is answered by reading that item.
6438    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
6439        if let Some(board) = self.board_cache()?.as_ref() {
6440            return Ok(BoardFields {
6441                id: BoardId::parse(&board.id)?,
6442                fields: board.fields.clone(),
6443            });
6444        }
6445        if let Some(held) = self.fields_cache()?.clone() {
6446            return Ok(held);
6447        }
6448        let data = self
6449            .graphql(
6450                graphql::BOARD_FIELDS,
6451                json!({"owner":self.owner,"number":self.project_number,
6452                       "nestedFirst":NESTED_PAGE_SIZE}),
6453            )
6454            .await?;
6455        self.fields_read(&data)
6456    }
6457
6458    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
6459    /// the rest of this command.
6460    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
6461        let board = data
6462            .pointer("/boardFields/projectV2")
6463            .filter(|value| !value.is_null())
6464            .ok_or_else(|| SourceError::Refused {
6465                message: format!(
6466                    "GitHub project {}/{} was not found or is not visible to the token",
6467                    self.owner, self.project_number
6468                ),
6469            })?;
6470        let read = BoardFields {
6471            id: BoardId::parse(required_str(board, "id")?)?,
6472            fields: board.get("fields").cloned().unwrap_or(Value::Null),
6473        };
6474        *self.fields_cache()? = Some(read.clone());
6475        Ok(read)
6476    }
6477
6478    /// Read what creating an issue in `repository` needs and this command has not read yet —
6479    /// the board's fields and the repository's node id — in one request when it needs both.
6480    ///
6481    /// When either is already known this sends nothing, and the other is read by its own
6482    /// document where it is asked for, so no create reads anything twice.
6483    async fn creation_context(
6484        &self,
6485        repository: &RepositoryTarget,
6486        incoming: &Incoming<'_>,
6487    ) -> Result<(), SourceError> {
6488        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
6489        if fields_known || self.repository_cache()?.contains_key(repository) {
6490            return Ok(());
6491        }
6492        // A placement an earlier process kept is taken only while the project issue this create
6493        // has just read shows its setup-context token unchanged; see `projection`.
6494        if self.placed_by_kept(repository, incoming.parent) {
6495            return Ok(());
6496        }
6497        let data = self
6498            .graphql(
6499                graphql::CREATION_CONTEXT,
6500                json!({"owner":self.owner,"number":self.project_number,
6501                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
6502                       "repositoryName":repository.name}),
6503            )
6504            .await?;
6505        self.fields_read(&data)?;
6506        self.repository_read(&data, repository, incoming)?;
6507        Ok(())
6508    }
6509
6510    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
6511    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
6512        self.fields_cache
6513            .lock()
6514            .map_err(|_| SourceError::Unavailable {
6515                message: "this source's view of the board's fields was left inconsistent by an \
6516                      earlier failure; next: run the command again"
6517                    .into(),
6518            })
6519    }
6520
6521    /// What a write to `item` needs of the board, read off that item when it says enough and
6522    /// off [`Self::board_fields`] when it does not.
6523    ///
6524    /// A node read of an item names its board and carries the definition of every field it
6525    /// holds a value of — so an item naming its board, holding a value of the origin field
6526    /// when the write carries an origin, and, when the write carries a status, holding a
6527    /// `Status` value, needs no read of the board at all. **Nothing the item does not say is
6528    /// guessed:** a field it holds no value of may still be on the board, and a view reading it
6529    /// as absent would refuse a write the board can take or skip a field write the board
6530    /// needs, so such an item — and a create, which has no item yet — takes the board's fields
6531    /// from their own read instead. A write carrying no origin to an item holding none — every
6532    /// write out of a source declared private — writes no origin whatever the board defines,
6533    /// so it needs no definition of that field.
6534    async fn fields_for(
6535        &self,
6536        item: Option<&Resolved>,
6537        writes_origin: bool,
6538        writes_status: bool,
6539        selects_priority: bool,
6540    ) -> Result<BoardFields, SourceError> {
6541        if let Some(board) = item.and_then(Resolved::carried_board) {
6542            return Ok(board);
6543        }
6544        if let Some(item) = item
6545            && let Some(board_id) = item.named_board()
6546            && (!writes_origin || item.defines(ORIGIN_FIELD))
6547            && (!writes_status || item.defines("Status"))
6548            && (!selects_priority || item.defines(PRIORITY_FIELD))
6549            && self
6550                .metadata_fields
6551                .iter()
6552                .all(|projection| item.defines(&projection.field))
6553        {
6554            return Ok(BoardFields {
6555                id: board_id,
6556                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6557            });
6558        }
6559        self.board_fields().await
6560    }
6561
6562    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
6563    /// when that id names nothing here with a sub-issue relationship to walk.
6564    ///
6565    /// `None` and an empty answer are different: `None` is *this is not an issue of this
6566    /// GitHub*, which is what sends a project selector on to be read as a name, and an
6567    /// empty vector is a project that holds nothing.
6568    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
6569        let mut after: Option<String> = None;
6570        let mut children = Vec::new();
6571        // The project issue as the first page carried it, held once the walk has ended.
6572        let mut parent: Option<Resolved> = None;
6573        loop {
6574            let asked = self
6575                .graphql(
6576                    graphql::SUB_ISSUES,
6577                    json!({"id":id.0,"memberFirst":MEMBER_PAGE_SIZE,"after":after,
6578                           "nestedFirst":NESTED_PAGE_SIZE,
6579                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6580                )
6581                .await;
6582            let data = match asked {
6583                Ok(data) => data,
6584                // A string that is not a node id at all is not a failure to report: it is
6585                // the ordinary answer to a selector naming a project by its name.
6586                Err(error) if unresolvable_node(&error) => return Ok(None),
6587                Err(error) => return Err(error),
6588            };
6589            let Some(connection) = data
6590                .pointer("/node/subIssues")
6591                .filter(|value| !value.is_null())
6592            else {
6593                // No such node, or one with no sub-issue relationship — a board draft is
6594                // the one this board can really hold.
6595                return Ok(None);
6596            };
6597            if after.is_none()
6598                && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
6599            {
6600                parent = self
6601                    .resolve_issue(node)
6602                    .await?
6603                    .filter(|resolved| resolved.id == *id);
6604            }
6605            for node in connection
6606                .get("nodes")
6607                .and_then(Value::as_array)
6608                .ok_or_else(|| SourceError::Malformed {
6609                    message: "GitHub subIssues.nodes is not an array".into(),
6610                })?
6611            {
6612                if let Some(resolved) = self.resolve_issue(node).await? {
6613                    children.push(resolved);
6614                }
6615            }
6616            let info = connection
6617                .get("pageInfo")
6618                .ok_or_else(|| SourceError::Malformed {
6619                    message: "GitHub subIssues connection has no pageInfo".into(),
6620                })?;
6621            let next = required_bool(info, "hasNextPage")?
6622                .then(|| required_str(info, "endCursor"))
6623                .transpose()?;
6624            match next {
6625                Some(next) => {
6626                    validate_cursor_progress(after.as_deref(), next)?;
6627                    after = Some(next.to_owned());
6628                }
6629                None => {
6630                    // Held only once the walk has ended, so one failing part way holds
6631                    // nothing and every read it would have answered is made.
6632                    // The project issue the first page carried answers the next read of the
6633                    // project by its id in this command, as a read of it made now would: what
6634                    // classifying a project for a write reads straight after its members.
6635                    if let Some(parent) = &parent {
6636                        self.read_ahead()?
6637                            .entry(parent.id.clone())
6638                            .or_insert_with(|| Some(parent.clone()));
6639                    }
6640                    let mut carried = self.carried()?;
6641                    for item in parent.into_iter().chain(children.iter().cloned()) {
6642                        carried.insert(item.id.clone(), item);
6643                    }
6644                    return Ok(Some(children));
6645                }
6646            }
6647        }
6648    }
6649
6650    fn carried(
6651        &self,
6652    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
6653        self.carried.lock().map_err(|_| SourceError::Unavailable {
6654            message: "items a project's listing carried were left inconsistent; run the \
6655                      command again"
6656                .into(),
6657        })
6658    }
6659
6660    /// Which issue of this board a project *name* is, or `None` when none is.
6661    ///
6662    /// One bounded query which filters on that name at the server, rather than a walk of
6663    /// every issue the board holds. The name is compared again here: the qualifier narrows
6664    /// what GitHub sends, and this source decides what it names.
6665    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
6666        let search = self.board_search(Some(&title_qualifier(name)));
6667        let mut after = None;
6668        loop {
6669            let (candidates, next) = self
6670                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
6671                .await?;
6672            if let Some(item) = candidates.into_iter().find(|item| {
6673                item.kind == BoardKind::Work(ItemKind::Project)
6674                    && item.title.eq_ignore_ascii_case(name)
6675            }) {
6676                return Ok(Some(item.id));
6677            }
6678            match next {
6679                Some(next) => after = Some(next),
6680                None => return Ok(None),
6681            }
6682        }
6683    }
6684
6685    /// Everything filed under one project of this board: the sub-issues of the issue that
6686    /// project is.
6687    ///
6688    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
6689    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
6690    /// gains projects, or as another project gains tasks.
6691    ///
6692    /// A qualified id names the issue and is asked for its sub-issues directly: one
6693    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
6694    /// read as a project *name*, which costs the one bounded search
6695    /// [`Self::project_by_name`] makes.
6696    ///
6697    /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
6698    /// — and each answer is still cut to the items whose parent is this project, so one this
6699    /// process has since filed elsewhere is not reported here, and completed with what this
6700    /// process filed under it.
6701    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
6702        let held = self.children_cache()?.get(selector).cloned();
6703        let (project, children) = match held {
6704            Some(held) => held,
6705            None => {
6706                let answered = match self.sub_issues(selector).await? {
6707                    Some(children) => (selector.clone(), children),
6708                    None => match self.project_by_name(&selector.0).await? {
6709                        Some(project) => {
6710                            let children = self.sub_issues(&project).await?.unwrap_or_default();
6711                            (project, children)
6712                        }
6713                        None => return Ok(Vec::new()),
6714                    },
6715                };
6716                self.children_cache()?
6717                    .insert(selector.clone(), answered.clone());
6718                answered
6719            }
6720        };
6721        let mut children: Vec<Resolved> = children
6722            .into_iter()
6723            .filter(|child| child.parent.as_ref() == Some(&project))
6724            .collect();
6725        for own in self.updated()?.iter() {
6726            if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
6727                children.push(own.clone());
6728            }
6729        }
6730        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
6731    }
6732
6733    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
6734    /// completed with what this run wrote — the candidates a comment-activity read confirms.
6735    ///
6736    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
6737    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
6738    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
6739    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
6740    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
6741    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
6742    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
6743    /// rather than silently narrowing a caller's answer.
6744    ///
6745    /// The instant is written to the second, rounded down, which can only widen what the
6746    /// search returns; confirmation against each candidate's own comments is what makes the
6747    /// answer exact. The search is an index that lags a write by a second or two — the module
6748    /// documentation records it — so a caller that asks again from its last instant should
6749    /// overlap the two by more than that.
6750    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
6751        let found = self.searched(&updated_qualifier(since)).await?;
6752        self.completed_with_written(found, |_| true)
6753    }
6754
6755    /// Every issue of this board GitHub's issue search reports for the board-scoped search
6756    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
6757    ///
6758    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
6759    /// own record is the fresher of the two.
6760    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6761        let search = self.board_search(Some(also));
6762        let mut after: Option<String> = None;
6763        let mut found = Vec::new();
6764        loop {
6765            let (page, next) = self
6766                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
6767                .await?;
6768            found.extend(page);
6769            match next {
6770                Some(next) => after = Some(next),
6771                None => return Ok(found),
6772            }
6773        }
6774    }
6775
6776    /// A bounded task answer; the versioned cursor carries the connection position, how
6777    /// many rows of the page starting there were already handed out, and the own-write ids
6778    /// already observed, including across a new source instance.
6779    ///
6780    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
6781    /// sliced from the pages it needs; why is the module documentation's paging contract.
6782    async fn search_tasks(
6783        &self,
6784        query: &TaskQuery,
6785        page: &PageRequest,
6786        also: &str,
6787    ) -> Result<Page<Task>, SourceError> {
6788        let mut position = match &page.cursor {
6789            None => SearchPosition::default(),
6790            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
6791                .ok()
6792                .filter(|position| {
6793                    position.version == SEARCH_CURSOR_VERSION
6794                        && position.connection.valid_resume(position.offset)
6795                })
6796                .ok_or_else(|| SourceError::Config {
6797                    message: "page cursor is invalid".into(),
6798                })?,
6799        };
6800        let search = self.board_search(Some(also));
6801        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6802        let own = self.with_own_writes(Vec::new())?;
6803        // An issue this process commented on is a candidate of a comment-activity read
6804        // whether or not the search has caught up with the comment; see `Self::commented`.
6805        let commented = match query.commented_since {
6806            Some(_) => self.commented()?.clone(),
6807            None => Vec::new(),
6808        };
6809        for id in own.iter().map(|item| &item.id).chain(&commented) {
6810            if !position.own.contains(id) {
6811                position.own.push(id.clone());
6812            }
6813        }
6814        let mut tasks = Vec::new();
6815        while !position.connection.exhausted() && tasks.len() < limit {
6816            let first = SEARCH_PAGE_SIZE;
6817            // Page size is part of the key: a short cached answer cannot answer a wider ask.
6818            let key =
6819                serde_json::to_string(&("page", &search, &position.connection.after(), first))
6820                    .expect("search page key is serializable");
6821            let cached = if query.commented_since.is_none() {
6822                self.narrowed_cache()?.get(&key).cloned()
6823            } else {
6824                None
6825            };
6826            let (found, next) = match cached {
6827                Some(found) => {
6828                    let next = self
6829                        .search_next
6830                        .lock()
6831                        .map_err(|_| SourceError::Unavailable {
6832                            message:
6833                                "search pagination was left inconsistent; run the command again"
6834                                    .into(),
6835                        })?
6836                        .get(&key)
6837                        .cloned()
6838                        .flatten();
6839                    (found, next)
6840                }
6841                None => {
6842                    let (found, next) = self
6843                        .search_page(&search, first, position.connection.after())
6844                        .await?;
6845                    if query.commented_since.is_none() {
6846                        self.search_next
6847                            .lock()
6848                            .map_err(|_| SourceError::Unavailable {
6849                                message:
6850                                    "search pagination was left inconsistent; run the command again"
6851                                        .into(),
6852                            })?
6853                            .insert(key.clone(), next.clone());
6854                        self.narrowed_cache()?.insert(key, found.clone());
6855                    }
6856                    (found, next)
6857                }
6858            };
6859            let rows = found.len();
6860            for mut item in found.into_iter().skip(position.offset) {
6861                if tasks.len() == limit {
6862                    break;
6863                }
6864                position.offset += 1;
6865                if position.own.contains(&item.id) {
6866                    if position.seen.contains(&item.id) {
6867                        continue;
6868                    }
6869                    position.seen.push(item.id.clone());
6870                    // The search's own copy of an issue this process only commented on is as
6871                    // good as a node read of it, since its comments are read either way.
6872                    let only_commented = commented.contains(&item.id)
6873                        && !own.iter().any(|written| written.id == item.id);
6874                    if !only_commented {
6875                        let updated_at = item.updated_at;
6876                        let Some(written) = self.search_written(&own, &item.id).await? else {
6877                            continue;
6878                        };
6879                        item = written;
6880                        item.updated_at = item.updated_at.max(updated_at);
6881                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
6882                    }
6883                }
6884                if item.kind == BoardKind::Work(ItemKind::Task) {
6885                    let task = item.task()?;
6886                    if task_matches(&task, query, &query.project)
6887                        && self.commented_since(&item, query.commented_since).await?
6888                    {
6889                        tasks.push(task);
6890                    }
6891                }
6892            }
6893            if position.offset < rows {
6894                continue;
6895            }
6896            position.offset = 0;
6897            position.connection = match next {
6898                Some(after) => SearchConnection::Continuing {
6899                    after: Cursor(after),
6900                },
6901                None => SearchConnection::Exhausted {},
6902            };
6903        }
6904        if position.connection.exhausted() {
6905            for id in position.own.clone() {
6906                if position.seen.contains(&id) {
6907                    continue;
6908                }
6909                if tasks.len() == limit {
6910                    break;
6911                }
6912                position.seen.push(id.clone());
6913                let Some(item) = self.search_written(&own, &id).await? else {
6914                    continue;
6915                };
6916                if item.kind == BoardKind::Work(ItemKind::Task) {
6917                    let task = item.task()?;
6918                    if task_matches(&task, query, &query.project)
6919                        && self.commented_since(&item, query.commented_since).await?
6920                    {
6921                        tasks.push(task);
6922                    }
6923                }
6924            }
6925        }
6926        let more = !position.connection.exhausted()
6927            || position.own.iter().any(|id| !position.seen.contains(id));
6928        Ok(Page {
6929            items: tasks,
6930            next: more.then(|| {
6931                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
6932            }),
6933        })
6934    }
6935
6936    /// A resumed process has the ids but no write records; resolve only a record the
6937    /// current page needs, by its uncached node read rather than the lagging search index.
6938    async fn search_written(
6939        &self,
6940        own: &[Resolved],
6941        id: &NativeId,
6942    ) -> Result<Option<Resolved>, SourceError> {
6943        match own.iter().find(|item| item.id == *id) {
6944            Some(item) => Ok(Some(item.clone())),
6945            None => self.item_by_id(id).await,
6946        }
6947    }
6948
6949    /// The candidates for a task query carrying a text, metadata or origin predicate, read
6950    /// without enumerating the board — or `None` for a query carrying none of the three, which
6951    /// keeps the reads it always had.
6952    ///
6953    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
6954    /// because it names at most a handful of items. Text and metadata are answered by one
6955    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
6956    /// further by `updated:>=` when the query also asks for comment activity, since both
6957    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
6958    /// process afterwards by the same predicates [`task_matches`] applies to every read.
6959    ///
6960    /// Completed with what this process wrote, its own record winning over the index's copy
6961    /// of the same item: see [`Self::with_own_writes`].
6962    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
6963        self.send_pending_origins(None).await?;
6964        let asked = match (&query.origin, narrowing_qualifiers(query)) {
6965            (Some(origin), _) => Narrowing::Origin(origin.clone()),
6966            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
6967                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
6968                None => qualifiers,
6969            }),
6970            (None, None) => return Ok(None),
6971        };
6972        // A question about comment activity is asked afresh every time, as it always was: it
6973        // is the one a caller polls from one source while waiting for the index, and an
6974        // answer held from the first poll would be the answer to every later one.
6975        let key = query.commented_since.is_none().then(|| asked.key());
6976        let cached = match &key {
6977            Some(key) => self.narrowed_cache()?.get(key).cloned(),
6978            None => None,
6979        };
6980        let found = match cached {
6981            Some(found) => found,
6982            None => {
6983                let found = match &asked {
6984                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
6985                    Narrowing::Search(also) => self.searched(also).await?,
6986                };
6987                if let Some(key) = key {
6988                    self.narrowed_cache()?.insert(key, found.clone());
6989                }
6990                found
6991            }
6992        };
6993        self.with_own_writes(found).map(Some)
6994    }
6995
6996    /// The candidates for a project or unscoped document query carrying a searchable text,
6997    /// read without enumerating the board — or `None` for a query with no text or a blank one,
6998    /// which keeps the read it always had.
6999    ///
7000    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
7001    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
7002    /// costs is the issues that match and never the board. Its answer is held for the command
7003    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
7004    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
7005    /// substring rule, exactly as an item of the wider read was, and is completed with what this
7006    /// process wrote: see [`Self::with_own_writes`].
7007    async fn text_searched(
7008        &self,
7009        text: Option<&TextQuery>,
7010    ) -> Result<Option<Vec<Resolved>>, SourceError> {
7011        let Some(also) = text_qualifiers(text) else {
7012            return Ok(None);
7013        };
7014        let key = Narrowing::Search(also.clone()).key();
7015        let cached = self.narrowed_cache()?.get(&key).cloned();
7016        let found = match cached {
7017            Some(found) => found,
7018            None => {
7019                let found = self.searched(&also).await?;
7020                self.narrowed_cache()?.insert(key, found.clone());
7021                found
7022            }
7023        };
7024        self.with_own_writes(found).map(Some)
7025    }
7026
7027    /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
7028    /// [`Self::narrowed`] reads for a task query carrying that origin — one
7029    /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
7030    /// what this process wrote — each confirmed against its own origin entry.
7031    ///
7032    /// What answers a project or document listing by origin, so finding every copy of one plan
7033    /// costs the items that carry its origin and never a page of the board.
7034    async fn copied_from(
7035        &self,
7036        origin: &str,
7037        kind: BoardKind,
7038    ) -> Result<Vec<Resolved>, SourceError> {
7039        let wanted = TaskQuery {
7040            origin: Some(origin.to_owned()),
7041            ..TaskQuery::default()
7042        };
7043        Ok(self
7044            .narrowed(&wanted)
7045            .await?
7046            .unwrap_or_default()
7047            .into_iter()
7048            .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
7049            .collect())
7050    }
7051
7052    /// Every item of this board that may carry `origin` — a superset of those that do — found
7053    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
7054    ///
7055    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
7056    /// which reads the field every carrier holds, whichever release wrote it — and the
7057    /// board-scoped issue search for the same id as a phrase in the body, where this source
7058    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
7059    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
7060    /// query's, exactly.
7061    ///
7062    /// Both connections are walked to exhaustion, each from its own cursor. One that has
7063    /// already ended is sent its last cursor again, which answers an empty page, so the one
7064    /// document serves every page of either. What the two leave is stated in the module
7065    /// documentation: a carrier another process added within the last second or two, before
7066    /// either index has it.
7067    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
7068        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
7069        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
7070        let mut items_after: Option<String> = None;
7071        let mut search_after: Option<String> = None;
7072        let mut found: Vec<Resolved> = Vec::new();
7073        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
7074            if !found.iter().any(|held| held.id == resolved.id) {
7075                found.push(resolved);
7076            }
7077        };
7078        loop {
7079            let data = self
7080                .graphql(
7081                    graphql::ORIGIN_LOOKUP,
7082                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
7083                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
7084                           "itemsAfter":items_after,"searchAfter":search_after,
7085                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7086                           "duplicates":true}),
7087                )
7088                .await?;
7089            let items = data
7090                .pointer("/originItems/projectV2/items")
7091                .filter(|value| !value.is_null())
7092                .ok_or_else(|| SourceError::Refused {
7093                    message: format!(
7094                        "GitHub project {}/{} was not found or is not visible to the token",
7095                        self.owner, self.project_number
7096                    ),
7097                })?;
7098            for item in optional_nodes(Some(items), "project items")?
7099                .into_iter()
7100                .flatten()
7101            {
7102                // The board's own items list its drafts too, and a draft is not an issue: no
7103                // narrowed read answers with one, whatever its origin field holds.
7104                if let Some(resolved) = self.resolve(item)?
7105                    && resolved.content_kind == ContentKind::Issue
7106                {
7107                    keep(resolved, &mut found);
7108                }
7109            }
7110            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
7111                message: "GitHub search response has no search connection".into(),
7112            })?;
7113            for node in optional_nodes(Some(searched), "search")?
7114                .into_iter()
7115                .flatten()
7116            {
7117                if let Some(resolved) = self.resolve_issue(node).await? {
7118                    keep(resolved, &mut found);
7119                }
7120            }
7121            let items_next = resumed(items, items_after.as_deref())?;
7122            let search_next = resumed(searched, search_after.as_deref())?;
7123            if !items_next.has_more() && !search_next.has_more() {
7124                return Ok(found);
7125            }
7126            items_after = items_next.cursor();
7127            search_after = search_next.cursor();
7128        }
7129    }
7130
7131    /// `found`, with every item this process created or wrote in its place, and every one of
7132    /// them the read did not report added.
7133    ///
7134    /// This process's own record wins over the read's copy of the same item, because a read
7135    /// of an item written moments ago can still be behind what was written onto it — the
7136    /// origin field included, which is the one a narrowed read is confirmed against — and a
7137    /// read that still names an item under a predicate this process's write moved it out of
7138    /// must not return it. The one thing the read knows that the record cannot is when GitHub
7139    /// last saw the item change, which is what a comment-activity read rules a candidate out
7140    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
7141    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
7142    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
7143        // A board draft is not an issue, so no narrowed read returns one, and this process
7144        // having written one does not make it an answer either.
7145        let own: Vec<Resolved> = self
7146            .created()?
7147            .iter()
7148            .chain(self.updated()?.iter())
7149            .filter(|own| own.content_kind == ContentKind::Issue)
7150            .cloned()
7151            .collect();
7152        for mut own in own {
7153            self.resolved_cache()?.insert(own.id.clone(), own.clone());
7154            match found.iter_mut().find(|read| read.id == own.id) {
7155                Some(read) => {
7156                    own.updated_at = own.updated_at.max(read.updated_at);
7157                    *read = own;
7158                }
7159                None => found.push(own),
7160            }
7161        }
7162        Ok(found)
7163    }
7164
7165    /// Whether `item` has a comment created or last edited at or after `since` — always, when
7166    /// there is no instant to hold it to.
7167    ///
7168    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
7169    /// or after the instant moved it there: an issue not updated since holds no such comment,
7170    /// and its comments are never asked for — unless this process commented on it in this
7171    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
7172    /// only as far as the first that matches. A board draft is not an issue and has no
7173    /// comments, so it never matches.
7174    async fn commented_since(
7175        &self,
7176        item: &Resolved,
7177        since: Option<DateTime<Utc>>,
7178    ) -> Result<bool, SourceError> {
7179        let Some(since) = since else {
7180            return Ok(true);
7181        };
7182        if item.content_kind == ContentKind::DraftIssue {
7183            return Ok(false);
7184        }
7185        // An `updatedAt` this process's own record or a lagging index holds can predate a
7186        // comment this process wrote since, so only an issue it did not comment on is ruled
7187        // out by one.
7188        if item.updated_at.is_some_and(|updated| updated < since)
7189            && !self.commented()?.contains(&item.id)
7190        {
7191            return Ok(false);
7192        }
7193        let query = TaskQuery {
7194            commented_since: Some(since),
7195            ..TaskQuery::default()
7196        };
7197        let mut after: Option<String> = None;
7198        loop {
7199            let data = self
7200                .graphql(
7201                    graphql::ISSUE_COMMENTS,
7202                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
7203                )
7204                .await?;
7205            let Some(connection) = data
7206                .get("node")
7207                .filter(|value| !value.is_null())
7208                .and_then(|node| node.get("comments"))
7209                .filter(|value| !value.is_null())
7210            else {
7211                // Removed since the search reported it: no longer an issue with comments.
7212                return Ok(false);
7213            };
7214            let comments = optional_nodes(Some(connection), "issue comments")?
7215                .into_iter()
7216                .flatten()
7217                .map(comment_from)
7218                .collect::<Result<Vec<_>, _>>()?;
7219            if query.comments_match(&comments) {
7220                return Ok(true);
7221            }
7222            match next_cursor(connection)? {
7223                Some(next) => {
7224                    validate_cursor_progress(after.as_deref(), &next.0)?;
7225                    after = Some(next.0);
7226                }
7227                None => return Ok(false),
7228            }
7229        }
7230    }
7231
7232    /// Every item on the board: the union of both enumerations GitHub offers of one.
7233    ///
7234    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
7235    /// board **draft** and reads the board's own fields beside its items, and only the search
7236    /// reports an item that connection is behind on. The module documentation is where the lag and the
7237    /// measurements behind it are written down.
7238    ///
7239    /// A search result is admitted on the same terms as any other issue this source reaches
7240    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
7241    /// names *this* board — so an issue the index still believes is here after it was taken
7242    /// off is refused rather than reported.
7243    ///
7244    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
7245    /// which is what the cache could otherwise have broken.
7246    async fn board(&self) -> Result<Board, SourceError> {
7247        self.board_completed_by(None).await
7248    }
7249
7250    /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
7251    /// that half could add is then answered only if it holds.
7252    async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
7253        let cached = self.board_cache()?.clone();
7254        let mut board = match cached {
7255            Some(board) => board,
7256            None => {
7257                let read = self.read_board().await?;
7258                *self.board_cache()? = Some(read.clone());
7259                read
7260            }
7261        };
7262        let searched = match also {
7263            None => self.searched_issues().await?,
7264            Some(also) => self.searched_narrowly(also).await?,
7265        };
7266        for held in searched {
7267            if !board.items.iter().any(|item| item.id == held.id) {
7268                board.items.push(held);
7269            }
7270        }
7271        for own in self.created()?.iter() {
7272            if !board.items.iter().any(|item| item.id == own.id) {
7273                board.items.push(own.clone());
7274            }
7275        }
7276        Ok(board)
7277    }
7278
7279    /// This process's own view of the board, or the refusal a poisoned lock is.
7280    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
7281        self.board_cache
7282            .lock()
7283            .map_err(|_| SourceError::Unavailable {
7284                message: "this source's view of the board was left inconsistent by an earlier \
7285                      failure; next: run the command again"
7286                    .into(),
7287            })
7288    }
7289
7290    /// Bring this process's own view of the board up to an item it has just written.
7291    ///
7292    /// A created item goes to `created`, which is what completes a board read GitHub's own
7293    /// eventual consistency has left behind. An item that was already there is replaced
7294    /// where it sits, so a second write of it in the same command reads its real parent
7295    /// rather than the one it had before the first write.
7296    ///
7297    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
7298    /// that wins: an item this same run created is held in `created` and not in the cached
7299    /// board, and `board` completes the cached board *from* `created`, so replacing only
7300    /// the cached copy of such an item replaces nothing and the read still reports the
7301    /// title it was created with. The search is the third, and it is the one an item the
7302    /// board's own projection is behind on sits in *alone* — which is exactly the item this
7303    /// source is least able to re-read, so leaving it out would put the stale title back on
7304    /// the only items the completion in [`Self::board`] exists for.
7305    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
7306        self.resolved_cache()?.insert(item.id.clone(), item.clone());
7307        if created {
7308            self.created()?.push(item);
7309            return Ok(());
7310        }
7311        {
7312            let mut own = self.created()?;
7313            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
7314                *held = item;
7315                return Ok(());
7316            }
7317        }
7318        {
7319            let mut own = self.updated()?;
7320            match own.iter_mut().find(|held| held.id == item.id) {
7321                Some(held) => *held = item.clone(),
7322                None => own.push(item.clone()),
7323            }
7324        }
7325        if let Some(board) = self.board_cache()?.as_mut()
7326            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
7327        {
7328            *held = item.clone();
7329        }
7330        if let Some(found) = self.search_cache()?.as_mut()
7331            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
7332        {
7333            *held = item.clone();
7334        }
7335        for found in self.narrowed_cache()?.values_mut() {
7336            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
7337                *held = item.clone();
7338            }
7339        }
7340        for (_, found) in self.children_cache()?.values_mut() {
7341            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
7342                *held = item.clone();
7343            }
7344        }
7345        Ok(())
7346    }
7347
7348    /// Forget one item this process has just deleted, from every half of its own view.
7349    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
7350        if let Some(Deferred::Held { created, .. }) = self.deferred()?.as_mut() {
7351            created.retain(|held| held.content != *id);
7352        }
7353        self.resolved_cache()?.remove(id);
7354        self.read_ahead()?.remove(id);
7355        self.carried()?.remove(id);
7356        self.created()?.retain(|own| own.id != *id);
7357        self.updated()?.retain(|own| own.id != *id);
7358        self.commented()?.retain(|own| own != id);
7359        if let Some(board) = self.board_cache()?.as_mut() {
7360            board.items.retain(|item| item.id != *id);
7361        }
7362        if let Some(found) = self.search_cache()?.as_mut() {
7363            found.retain(|item| item.id != *id);
7364        }
7365        for found in self.narrowed_cache()?.values_mut() {
7366            found.retain(|item| item.id != *id);
7367        }
7368        for (_, found) in self.children_cache()?.values_mut() {
7369            found.retain(|item| item.id != *id);
7370        }
7371        Ok(())
7372    }
7373
7374    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
7375    fn narrowed_cache(
7376        &self,
7377    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
7378        self.narrowed_cache
7379            .lock()
7380            .map_err(|_| SourceError::Unavailable {
7381                message: "this source's view of a narrowed read was left inconsistent by an \
7382                      earlier failure; next: run the command again"
7383                    .into(),
7384            })
7385    }
7386
7387    /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
7388    /// is.
7389    /// The tasks `project` holds, as a copy's batched read found them this command, when it did.
7390    fn held_task_children(&self, project: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7391        Ok(self.task_children()?.get(project).cloned())
7392    }
7393
7394    fn task_children(
7395        &self,
7396    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Vec<Resolved>>>, SourceError> {
7397        self.task_children
7398            .lock()
7399            .map_err(|_| SourceError::Unavailable {
7400                message: "this source's record of a project's tasks was left inconsistent by an \
7401                          earlier failure; next: run the command again"
7402                    .into(),
7403            })
7404    }
7405
7406    fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
7407        self.children_cache
7408            .lock()
7409            .map_err(|_| SourceError::Unavailable {
7410                message: "this source's view of a project's tasks was left inconsistent by an \
7411                      earlier failure; next: run the command again"
7412                    .into(),
7413            })
7414    }
7415
7416    /// Every page of the board, read from GitHub.
7417    async fn read_board(&self) -> Result<Board, SourceError> {
7418        let mut after: Option<String> = None;
7419        let mut items = Vec::new();
7420        let mut board;
7421        loop {
7422            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
7423            for item in page
7424                .pointer("/items/nodes")
7425                .and_then(Value::as_array)
7426                .ok_or_else(|| SourceError::Malformed {
7427                    message: "GitHub project items.nodes is not an array".into(),
7428                })?
7429            {
7430                if let Some(resolved) = self.resolve(item)? {
7431                    items.push(resolved);
7432                }
7433            }
7434            let info = page
7435                .pointer("/items/pageInfo")
7436                .ok_or_else(|| SourceError::Malformed {
7437                    message: "GitHub project items have no pageInfo".into(),
7438                })?;
7439            let has_next = required_bool(info, "hasNextPage")?;
7440            let next = has_next
7441                .then(|| required_str(info, "endCursor"))
7442                .transpose()?;
7443            board = page.clone();
7444            match next {
7445                Some(next) => {
7446                    validate_cursor_progress(after.as_deref(), next)?;
7447                    after = Some(next.to_owned());
7448                }
7449                None => break,
7450            }
7451        }
7452        Ok(Board {
7453            id: required_str(&board, "id")?.to_owned(),
7454            fields: board.get("fields").cloned().unwrap_or(Value::Null),
7455            items,
7456        })
7457    }
7458
7459    /// The existing items this source has written, for completing a narrowed read that is
7460    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
7461    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
7462        self.updated.lock().map_err(|_| SourceError::Unavailable {
7463            message: "this source's record of what it wrote in this run was left inconsistent \
7464                      by an earlier failure; next: run the command again"
7465                .into(),
7466        })
7467    }
7468
7469    /// The issues this source has commented on in this command; see
7470    /// [`Self::commented`](GitHubProjectsSource::commented).
7471    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
7472        self.commented.lock().map_err(|_| SourceError::Unavailable {
7473            message: "this source's record of what it commented on in this run was left \
7474                      inconsistent by an earlier failure; next: run the command again"
7475                .into(),
7476        })
7477    }
7478
7479    /// Called only once GitHub has answered the comment write, so an issue whose comment
7480    /// failed is never made a candidate a later read would pay a node read for.
7481    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
7482        let mut commented = self.commented()?;
7483        if !commented.contains(issue) {
7484            commented.push(issue.clone());
7485        }
7486        Ok(())
7487    }
7488
7489    /// The items this source has created, for completing a board read that is behind.
7490    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
7491        self.created.lock().map_err(|_| SourceError::Unavailable {
7492            message: "this source's record of what it created in this run was left \
7493                      inconsistent by an earlier failure; next: run the command again"
7494                .into(),
7495        })
7496    }
7497
7498    /// One board item as this source reports it, or `None` for content it ignores.
7499    ///
7500    /// A pull request is neither a project nor a task — it is somebody's change, not a
7501    /// unit of plan — and an item whose content the token cannot see has nothing to
7502    /// report at all.
7503    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
7504        let resolved = self.resolve_unremembered(item)?;
7505        if let Some(resolved) = &resolved {
7506            self.resolved_cache()?
7507                .insert(resolved.id.clone(), resolved.clone());
7508        }
7509        Ok(resolved)
7510    }
7511
7512    /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
7513    /// write's own response needs, which says nothing this process should answer a later read
7514    /// with.
7515    fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
7516        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
7517            message: "GitHub project item is missing content".into(),
7518        })?;
7519        if content.is_null() {
7520            return Ok(None);
7521        }
7522        let content_kind = match required_str(content, "__typename")? {
7523            "Issue" => ContentKind::Issue,
7524            "DraftIssue" => ContentKind::DraftIssue,
7525            _ => return Ok(None),
7526        };
7527        let field_values = item
7528            .get("fieldValues")
7529            .ok_or_else(|| SourceError::Malformed {
7530                message: "GitHub project item is missing fieldValues".into(),
7531            })?;
7532        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
7533        let nodes = field_values
7534            .get("nodes")
7535            .and_then(Value::as_array)
7536            .ok_or_else(|| SourceError::Malformed {
7537                message: "GitHub project item fieldValues.nodes is not an array".into(),
7538            })?;
7539        if let Some(labels) = content.get("labels") {
7540            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
7541        }
7542        let raw_body = optional_str(content, "body")?.map(str::to_owned);
7543        let (body, slot) = self.visible(raw_body.clone())?;
7544        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
7545            .map(|id| NativeId(id.to_owned()));
7546        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
7547        // to read one from; it is a task, and never a project.
7548        let sub_issues = match content_kind {
7549            ContentKind::Issue => sub_issue_total(content)?,
7550            ContentKind::DraftIssue => 0,
7551        };
7552        let content_id = required_str(content, "id")?;
7553        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
7554            message: format!("GitHub issue {content_id}: {message}"),
7555        })?;
7556        let raw_title = required_str(content, "title")?;
7557        let kind = BoardKind::of(raw_title, parent.is_some(), sub_issues, marked);
7558        // The title a person wrote, which for a document is the one without the prefix —
7559        // the same way `content` above is the body without this source's metadata slot.
7560        let title = match kind {
7561            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
7562            BoardKind::Work(_) => raw_title.to_owned(),
7563        };
7564        let own_repository = content
7565            .pointer("/repository/nameWithOwner")
7566            .and_then(Value::as_str)
7567            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
7568            .transpose()
7569            .map_err(|message| SourceError::Malformed { message })?;
7570        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
7571            Repository::from_metadata(&slot)
7572                .map_err(|message| SourceError::Malformed { message })?
7573        } else {
7574            own_repository.clone().into_iter().collect()
7575        };
7576        let classification = Classification::from_metadata(&slot)
7577            .map_err(|message| SourceError::Malformed { message })?;
7578        let id = NativeId(content_id.to_owned());
7579        // Read only for a task, because only a task has either list: a project or a
7580        // document holding one of these keys holds nothing this source reports, and the
7581        // keys are left out of its caller-visible metadata all the same.
7582        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
7583            let listed = |key: &str| {
7584                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
7585                    .map_err(|message| SourceError::Malformed { message })
7586            };
7587            (
7588                listed(TaskRef::DELIVERS_KEY)?,
7589                listed(TaskRef::DELIVERED_BY_KEY)?,
7590            )
7591        } else {
7592            (Vec::new(), Vec::new())
7593        };
7594        let (option, closed, reason) = Self::status_parts(nodes, content)?;
7595        let priority = self.held_priority(nodes)?;
7596        let item_labels = labels(content)?;
7597        // Present when the item was reached through its own issue, whose board entry
7598        // names the board; a read of the board's own items has the board already. An
7599        // empty id names nothing a field write could address, so it is read as absent and
7600        // the write goes back to reading the board.
7601        let board_id = item
7602            .pointer("/project/id")
7603            .and_then(Value::as_str)
7604            .filter(|id| !id.is_empty());
7605        let resolved = Resolved {
7606            item_id: required_str(item, "id")?.to_owned(),
7607            id,
7608            content_kind,
7609            kind,
7610            title,
7611            body: body.filter(|value| !value.is_empty()),
7612            raw_body,
7613            status: self
7614                .statuses
7615                .status(kind.status_kind(), option, closed, reason),
7616            option: option.map(str::to_owned),
7617            priority,
7618            closed,
7619            reason: closed.then(|| reason.map(str::to_owned)).flatten(),
7620            delivers,
7621            delivered_by,
7622            labels: item_labels.clone(),
7623            parent,
7624            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
7625            projected: self.held_projections(nodes)?,
7626            number: match content_kind {
7627                ContentKind::Issue => Some(issue_number(content)?),
7628                // A draft is filed in no repository, so nothing ever numbered it:
7629                // `DraftIssue` declares no `number` at all, exactly as it declares no
7630                // `subIssuesSummary` the branch above reads.
7631                ContentKind::DraftIssue => None,
7632            },
7633            url: optional_str(content, "url")?.map(str::to_owned),
7634            created_at: optional_time(content, "createdAt")?,
7635            updated_at: optional_time(content, "updatedAt")?,
7636            item_updated_at: optional_time(item, "updatedAt")?,
7637            comment_count: content
7638                .pointer("/commentCount/totalCount")
7639                .and_then(Value::as_u64),
7640            relationship_events: content
7641                .pointer("/relationshipEvents/totalCount")
7642                .and_then(Value::as_u64),
7643            // The issue node is stamped wherever it was read; a board item a read of the issue
7644            // put together from its memberships is not.
7645            served_at: match optional_time(content, SERVED_AT)? {
7646                Some(served) => Some(served),
7647                None => optional_time(item, SERVED_AT)?,
7648            },
7649            // Both connections were read to their end above — one reporting a further page is
7650            // refused — so these are every field value and every label the item holds. A
7651            // response that did not select the labels at all says nothing of them, and gives
7652            // no facets.
7653            facets: content.get("labels").is_some().then(|| {
7654                change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
7655            }),
7656            own_repository,
7657            repositories,
7658            classification,
7659            slot,
7660            board_id: board_id.map(str::to_owned),
7661            fields: field_definitions(nodes),
7662            board_fields: Self::carried_board_fields(content, board_id)?,
7663            blocked_by: carried_blocked_by(content)?,
7664        };
7665        Ok(Some(resolved))
7666    }
7667
7668    /// The field definitions of the board `board_id` names — the project this issue's own
7669    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
7670    /// `None` when the read carried none, carried no entry for that board, or the board item
7671    /// named no board, which a write then answers by reading the board's fields itself.
7672    ///
7673    /// Matched by the board's node id and never by its number alone: a project number is
7674    /// unique only within its owner, so another owner's board numbered alike can sit on the
7675    /// same page, and its field and option ids address nothing on this one.
7676    fn carried_board_fields(
7677        content: &Value,
7678        board_id: Option<&str>,
7679    ) -> Result<Option<Value>, SourceError> {
7680        let (Some(nodes), Some(board_id)) = (
7681            content.pointer("/boards/nodes").and_then(Value::as_array),
7682            board_id,
7683        ) else {
7684            return Ok(None);
7685        };
7686        let Some(board) = nodes.iter().find_map(|node| {
7687            let project = node.get("project")?;
7688            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
7689        }) else {
7690            return Ok(None);
7691        };
7692        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
7693            return Ok(None);
7694        };
7695        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
7696        Ok(Some(fields.clone()))
7697    }
7698
7699    /// What one board item's `Priority` field says, through this instance's mapping.
7700    ///
7701    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
7702    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
7703    /// option the mapping does not name is kept as itself — never read as a level or as
7704    /// `none` — for a read of the task to report by name.
7705    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
7706        let Some(mapping) = &self.priorities else {
7707            return Ok(HeldPriority::Read(Priority::None));
7708        };
7709        // A value of the field that names no option — a text field someone called `Priority` —
7710        // is malformed rather than `none`: reading it as no priority would let the next copy
7711        // clear one a person set.
7712        let Some(option) = field_values
7713            .iter()
7714            .find(|value| {
7715                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
7716            })
7717            .map(|value| required_str(value, "name"))
7718            .transpose()?
7719        else {
7720            return Ok(HeldPriority::Read(Priority::None));
7721        };
7722        Ok(mapping.priority_of(option).map_or_else(
7723            || HeldPriority::Unmapped(option.to_owned()),
7724            HeldPriority::Read,
7725        ))
7726    }
7727
7728    /// What each projected metadata field holds for one board item, by field name — every text
7729    /// value GitHub answers, an empty one included.
7730    fn held_projections(
7731        &self,
7732        field_values: &[Value],
7733    ) -> Result<BTreeMap<String, String>, SourceError> {
7734        let mut held = BTreeMap::new();
7735        for projection in &self.metadata_fields {
7736            if let Some(text) = text_field(field_values, &projection.field)? {
7737                held.insert(projection.field.to_string(), text);
7738            }
7739        }
7740        Ok(held)
7741    }
7742
7743    /// What one board item's status is read from: its `Status` option, whether its issue
7744    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
7745    /// three into the status it reports.
7746    fn status_parts<'a>(
7747        field_values: &'a [Value],
7748        content: &'a Value,
7749    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
7750        let option = field_values
7751            .iter()
7752            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
7753            .map(|value| required_str(value, "name"))
7754            .transpose()?;
7755        let closed = optional_str(content, "state")? == Some("CLOSED");
7756        Ok((option, closed, optional_str(content, "stateReason")?))
7757    }
7758
7759    /// The board Status option this write selects, or the refusal that says why not.
7760    ///
7761    /// The mapped option is required for both open and terminal targets. A terminal write
7762    /// validates it before changing either representation, so it can never fall back to
7763    /// closing an issue whose board cannot display the matching status.
7764    ///
7765    /// Answers the field's id, the option's id, and the option's name as the board spells
7766    /// it — which is the name a read of the item reports once it sits there.
7767    fn column_for(
7768        &self,
7769        fields: &Value,
7770        kind: ItemKind,
7771        category: StatusCategory,
7772        target: &StatusTarget,
7773    ) -> Result<Option<(String, String, String)>, SourceError> {
7774        let Some(wanted) = target.option() else {
7775            return Ok(None);
7776        };
7777        let missing = |detail: &str| SourceError::Refused {
7778            message: format!(
7779                "{} status {} of source {} needs the board Status option {wanted:?}, and \
7780                 {detail}; next: add that option to the board, which `onetaskgraph sources \
7781                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
7782                 it has",
7783                kind.marker(),
7784                category_name(category),
7785                self.name,
7786                self.name,
7787                category_name(category),
7788                kind.marker()
7789            ),
7790        };
7791        let Some(field) = Board::field(fields, "Status")? else {
7792            return Err(missing("this board has no Status field"));
7793        };
7794        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
7795            return Err(missing(
7796                "this board's Status field is not a single-select field",
7797            ));
7798        }
7799        let option = field
7800            .get("options")
7801            .and_then(Value::as_array)
7802            .and_then(|options| {
7803                options.iter().find(|option| {
7804                    option
7805                        .get("name")
7806                        .and_then(Value::as_str)
7807                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
7808                })
7809            });
7810        match option {
7811            None => Err(missing("this board does not have it")),
7812            Some(option) => Ok(Some((
7813                required_str(field, "id")?.to_owned(),
7814                required_str(option, "id")?.to_owned(),
7815                required_str(option, "name")?.to_owned(),
7816            ))),
7817        }
7818    }
7819
7820    /// The refusal a status that closes an issue is answered with over a board draft.
7821    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
7822        SourceError::Refused {
7823            message: format!(
7824                "status {} of source {} closes the item's issue, and GitHub draft items have \
7825                 no open or closed state",
7826                category_name(category),
7827                self.name
7828            ),
7829        }
7830    }
7831
7832    /// What a status write to one item needs of the board: the board's id and the
7833    /// definition of its `Status` field, read off the item when the item says both.
7834    ///
7835    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
7836    /// and its `Status` value carries that field's definition, options and all. An item that
7837    /// does not say — no board id, or no `Status` value to read the field off — takes them
7838    /// from [`Self::board_fields`], which reads no item.
7839    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
7840        if let Some(board) = item.carried_board() {
7841            return Ok(board);
7842        }
7843        if item.defines("Status")
7844            && let Some(board_id) = item.named_board()
7845        {
7846            return Ok(BoardFields {
7847                id: board_id,
7848                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7849            });
7850        }
7851        self.board_fields().await
7852    }
7853
7854    /// Set one task's or one project's status and nothing else; see
7855    /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
7856    async fn set_status(
7857        &self,
7858        id: &NativeId,
7859        kind: ItemKind,
7860        category: StatusCategory,
7861    ) -> Result<Option<Status>, SourceError> {
7862        // Refused before anything is read, in the words a write of the same status is.
7863        let target = self.resolved_target(kind, category)?;
7864        // An item read by issue reference carries only what this write needs, so what it
7865        // becomes is never remembered for a later read to be answered from.
7866        let held = self.take_held(id)?;
7867        let by_reference = held.is_some();
7868        let bound = match held {
7869            Some(item) => Some(item),
7870            None => self.bound_item(id).await?,
7871        };
7872        let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
7873            return Ok(None);
7874        };
7875        let board = self.status_board(&item).await?;
7876        let (field, option, name) = self
7877            .column_for(&board.fields, kind, category, &target)?
7878            .ok_or_else(|| SourceError::Malformed {
7879                message: format!(
7880                    "status {} of source {} names no board Status option",
7881                    category_name(category),
7882                    self.name
7883                ),
7884            })?;
7885        if item.status.category == category && item.option.as_deref() == Some(&name) {
7886            return Ok(Some(item.status));
7887        }
7888        match &target {
7889            StatusTarget::Terminal(_, reason) => {
7890                if item.content_kind == ContentKind::DraftIssue {
7891                    return Err(self.closes_a_draft(category));
7892                }
7893                // The option first, so a refused selection leaves an open issue open rather
7894                // than closed behind a card that still reads open.
7895                self.set_item_field(
7896                    board.id.as_str(),
7897                    &item.item_id,
7898                    &field,
7899                    json!({"singleSelectOptionId": option}),
7900                )
7901                .await?;
7902                // An issue already closed in a way that reads as this category keeps its state
7903                // and its reason: only its card was out of line, and closing it again would
7904                // overwrite a reason — a duplicate's — that says more than this write does.
7905                let already = item.closed && item.status.category == category;
7906                if !already {
7907                    self.update_content(
7908                        ContentKind::Issue,
7909                        &item.id,
7910                        json!({"stateInput": state_input(Some(&target))}),
7911                    )
7912                    .await?;
7913                }
7914                let reason = if already {
7915                    item.reason.clone()
7916                } else {
7917                    Some(reason.reason().to_owned())
7918                };
7919                item.closed = true;
7920                item.status = self
7921                    .statuses
7922                    .status(kind, Some(&name), true, reason.as_deref());
7923                item.option = Some(name);
7924                item.reason = reason;
7925            }
7926            StatusTarget::Column(_) => {
7927                // An option is what an open item's status is, so a closed issue is reopened
7928                // first — sitting closed in the column, it would read back as closed. A draft has
7929                // no state to reopen.
7930                if item.content_kind == ContentKind::Issue && item.closed {
7931                    self.update_content(
7932                        ContentKind::Issue,
7933                        &item.id,
7934                        json!({"stateInput": state_input(Some(&target))}),
7935                    )
7936                    .await?;
7937                    item.closed = false;
7938                }
7939                self.set_item_field(
7940                    board.id.as_str(),
7941                    &item.item_id,
7942                    &field,
7943                    json!({"singleSelectOptionId": option}),
7944                )
7945                .await?;
7946                item.status = self.statuses.status(kind, Some(&name), false, None);
7947                item.option = Some(name);
7948            }
7949            StatusTarget::Disabled(_) => {
7950                unreachable!("resolved_target refused a disabled status")
7951            }
7952        }
7953        let status = item.status.clone();
7954        if !by_reference {
7955            self.remember_written(item, false)?;
7956        }
7957        Ok(Some(status))
7958    }
7959
7960    /// Replace one task's `delivered_by` and nothing else; see
7961    /// [`TaskSource::set_delivered_by`].
7962    ///
7963    /// One update of the body, which differs from the body GitHub holds only inside the
7964    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
7965    async fn replace_delivered_by(
7966        &self,
7967        id: &NativeId,
7968        delivered_by: &[TaskRef],
7969    ) -> Result<Option<()>, SourceError> {
7970        let entries = TaskRef::listed(
7971            TaskRef::DELIVERED_BY_KEY,
7972            id,
7973            Some(&self.name),
7974            delivered_by.to_vec(),
7975        )
7976        .map_err(|message| SourceError::Refused { message })?;
7977        let Some(mut item) = self
7978            .bound_item(id)
7979            .await?
7980            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7981        else {
7982            return Ok(None);
7983        };
7984        let mut slot = item.slot.clone();
7985        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
7986        self.write_slot(&mut item, &slot).await?;
7987        item.delivered_by = entries;
7988        self.remember_written(item, false)?;
7989        Ok(Some(()))
7990    }
7991
7992    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
7993    /// see [`TaskSource::set_task_metadata`].
7994    ///
7995    /// `None` when this board holds no item by that id, or holds one of another kind. The
7996    /// answer is the item as this source now reads it, so what a caller is told the key
7997    /// holds is what the slot holds.
7998    ///
7999    /// A key already holding the value is answered without a write, compared as JSON rather
8000    /// than as the body's bytes: a slot a person spelled with other whitespace would
8001    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
8002    async fn set_slot_key(
8003        &self,
8004        id: &NativeId,
8005        kind: BoardKind,
8006        key: &MetadataKey,
8007        value: &Value,
8008    ) -> Result<Option<Resolved>, SourceError> {
8009        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8010            return Ok(None);
8011        };
8012        if item.slot.get(key.as_str()) == Some(value) {
8013            return Ok(Some(item));
8014        }
8015        let mut slot = item.slot.clone();
8016        slot.insert(key.as_str().to_owned(), value.clone());
8017        // A key a board text field projects moves that field first, so the body — the value's
8018        // one authoritative home — is written last, as every write of an existing item is.
8019        if self
8020            .metadata_fields
8021            .iter()
8022            .any(|projection| &*projection.key == key.as_str())
8023        {
8024            let board = self.projection_board(&item).await?;
8025            let (writes, projected) = self.projection_writes(
8026                &board.fields,
8027                Some(&item.projected),
8028                &slot,
8029                Some(&[key.as_str()]),
8030            )?;
8031            let (mut values, mut clears) = (Vec::new(), Vec::new());
8032            for write in &writes {
8033                write.join(&mut values, &mut clears);
8034            }
8035            self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
8036                .await?;
8037            item.projected = projected;
8038        }
8039        self.write_slot(&mut item, &slot).await?;
8040        self.remember_written(item.clone(), false)?;
8041        Ok(Some(item))
8042    }
8043
8044    /// What a projection write to `item` needs of the board, read off the item when it says
8045    /// enough, as [`Self::fields_for`] reads it.
8046    async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8047        if let Some(board) = item.carried_board() {
8048            return Ok(board);
8049        }
8050        if let Some(board_id) = item.named_board()
8051            && self
8052                .metadata_fields
8053                .iter()
8054                .all(|projection| item.defines(&projection.field))
8055        {
8056            return Ok(BoardFields {
8057                id: board_id,
8058                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8059            });
8060        }
8061        self.board_fields().await
8062    }
8063
8064    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
8065    /// `item` up to what that write left.
8066    ///
8067    /// The body sent differs from the body GitHub holds only inside the slot — see
8068    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
8069    /// the mutation the item's content takes, so a board draft's body is written with
8070    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
8071    async fn write_slot(
8072        &self,
8073        item: &mut Resolved,
8074        slot: &BTreeMap<String, Value>,
8075    ) -> Result<(), SourceError> {
8076        let held = item.raw_body.clone().unwrap_or_default();
8077        let body = with_slot(&held, slot)?;
8078        if body != held {
8079            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8080                .await?;
8081        }
8082        let (visible, slot) = self.visible(Some(body.clone()))?;
8083        item.body = visible.filter(|value| !value.is_empty());
8084        item.raw_body = Some(body);
8085        item.slot = slot;
8086        Ok(())
8087    }
8088
8089    /// This instance's target for a category written to an item of `kind`, refusing one
8090    /// that kind has no option for — before anything is read or written.
8091    ///
8092    /// Nothing here mutates the board's option set to make room for a status. GitHub
8093    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
8094    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
8095    /// field and every item's status.
8096    fn resolved_target(
8097        &self,
8098        kind: ItemKind,
8099        category: StatusCategory,
8100    ) -> Result<StatusTarget, SourceError> {
8101        let target = self.statuses.target(kind, category).clone();
8102        let StatusTarget::Disabled(why) = target else {
8103            return Ok(target);
8104        };
8105        let refusal = why.refusal(&self.name, category, kind);
8106        // Why there is no shipped default, which is the question a person meeting this
8107        // refusal on a source that never mentioned the category asks.
8108        let shipped_none = match category {
8109            StatusCategory::Draft => Some(
8110                "draft has no shipped default because GitHub draft issues cannot have \
8111                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
8112            ),
8113            StatusCategory::Unknown => Some(
8114                "unknown has no shipped default because this board keeps no open-ended status \
8115                 word: every word classified unknown is written to the one board Status option \
8116                 status_mapping.unknown names",
8117            ),
8118            _ => None,
8119        };
8120        Err(match (refusal, shipped_none, why) {
8121            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
8122                SourceError::Refused {
8123                    message: format!("{message}; {note}"),
8124                }
8125            }
8126            (refusal, _, _) => refusal,
8127        })
8128    }
8129
8130    /// What writing `priority` does to one item's `Priority` field on this board, or the
8131    /// refusal naming what the board lacks.
8132    ///
8133    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
8134    /// none already, or of an item not created yet. Every other priority selects the option
8135    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
8136    /// without that option, is refused rather than given one: reads and writes never create
8137    /// a field or an option.
8138    fn priority_write(
8139        &self,
8140        fields: &Value,
8141        existing: Option<&Resolved>,
8142        priority: Priority,
8143    ) -> Result<Option<PriorityWrite>, SourceError> {
8144        let Some(mapping) = &self.priorities else {
8145            return Err(self.holds_no_priority());
8146        };
8147        let Some(wanted) = mapping.option(priority) else {
8148            if !existing.is_some_and(Resolved::holds_priority) {
8149                return Ok(None);
8150            }
8151            let field =
8152                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
8153                    message: format!(
8154                        "an item holding a {PRIORITY_FIELD} value was read without that field"
8155                    ),
8156                })?;
8157            return Ok(Some(PriorityWrite::Clear {
8158                field: required_str(field, "id")?.to_owned(),
8159            }));
8160        };
8161        let missing = |detail: &str| SourceError::Refused {
8162            message: format!(
8163                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
8164                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
8165                 it, or point priority_mapping.{priority} of this source at an option the board \
8166                 has",
8167                self.name, self.name
8168            ),
8169        };
8170        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
8171            return Err(missing(&format!(
8172                "this board has no {PRIORITY_FIELD} field"
8173            )));
8174        };
8175        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8176            return Err(missing(&format!(
8177                "this board's {PRIORITY_FIELD} field is not a single-select field"
8178            )));
8179        }
8180        // An options list that is absent or not a list is an answer this source cannot read,
8181        // not a board lacking the option: `sources fields --apply` is no remedy for it.
8182        let option = field
8183            .get("options")
8184            .and_then(Value::as_array)
8185            .ok_or_else(|| SourceError::Malformed {
8186                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
8187            })?
8188            .iter()
8189            .find(|option| {
8190                option
8191                    .get("name")
8192                    .and_then(Value::as_str)
8193                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8194            })
8195            .ok_or_else(|| missing("this board does not have it"))?;
8196        Ok(Some(PriorityWrite::Select {
8197            field: required_str(field, "id")?.to_owned(),
8198            option: required_str(option, "id")?.to_owned(),
8199        }))
8200    }
8201
8202    /// What writing `metadata` does to each projected text field of one item, and what those
8203    /// fields hold after it — or the refusal, before anything is sent.
8204    ///
8205    /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
8206    /// narrows the work to the entries projecting one of those metadata keys, for a write that
8207    /// touches only them; `None` is every entry. A string the field already holds, and nothing
8208    /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
8209    /// and so is a board without the field, or with a field of that name that is not a text
8210    /// field: reads and writes never create a field.
8211    fn projection_writes(
8212        &self,
8213        fields: &Value,
8214        held: Option<&BTreeMap<String, String>>,
8215        metadata: &BTreeMap<String, Value>,
8216        keys: Option<&[&str]>,
8217    ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
8218        let mut projected = held.cloned().unwrap_or_default();
8219        let mut writes = Vec::new();
8220        for projection in self
8221            .metadata_fields
8222            .iter()
8223            .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
8224        {
8225            let wanted = projection.projected(metadata, &self.name)?;
8226            let field = self.projection_field(fields, projection)?;
8227            let holding = projected.get(&*projection.field);
8228            match wanted {
8229                Projected::Text(text) if holding != Some(&text) => {
8230                    writes.push(ProjectionWrite::Set {
8231                        field,
8232                        text: text.clone(),
8233                    });
8234                    projected.insert(projection.field.to_string(), text);
8235                }
8236                Projected::Nothing if holding.is_some() => {
8237                    writes.push(ProjectionWrite::Clear { field });
8238                    projected.remove(&*projection.field);
8239                }
8240                Projected::Text(_) | Projected::Nothing => {}
8241            }
8242        }
8243        Ok((writes, projected))
8244    }
8245
8246    /// The id of the board text field one projection writes, or the refusal naming what the
8247    /// board has instead.
8248    fn projection_field(
8249        &self,
8250        fields: &Value,
8251        projection: &MetadataField,
8252    ) -> Result<String, SourceError> {
8253        let refuse = |detail: String| SourceError::Refused {
8254            message: format!(
8255                "source {} projects metadata key {:?} onto the board text field {:?}, and \
8256                 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
8257                 text field, or name another field in metadata_fields",
8258                self.name, &*projection.key, &*projection.field, self.name
8259            ),
8260        };
8261        let Some(field) = Board::field(fields, &projection.field)? else {
8262            return Err(refuse("this board has no field of that name".to_owned()));
8263        };
8264        let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
8265        let data_type = optional_str(field, "dataType")?;
8266        if typename != "ProjectV2Field" || data_type != Some("TEXT") {
8267            return Err(refuse(format!(
8268                "this board's field of that name is not a text field (it is a {}), so it is \
8269                 left as it is; rename or remove that field first",
8270                data_type.unwrap_or(typename)
8271            )));
8272        }
8273        Ok(required_str(field, "id")?.to_owned())
8274    }
8275
8276    /// Apply one priority write to one board item.
8277    async fn write_priority(
8278        &self,
8279        board_id: &str,
8280        item_id: &str,
8281        write: &PriorityWrite,
8282    ) -> Result<(), SourceError> {
8283        match write {
8284            PriorityWrite::Select { field, option } => {
8285                self.set_item_field(
8286                    board_id,
8287                    item_id,
8288                    field,
8289                    json!({"singleSelectOptionId": option}),
8290                )
8291                .await
8292            }
8293            PriorityWrite::Clear { field } => {
8294                let data = self
8295                    .graphql(
8296                        graphql::CLEAR_FIELD,
8297                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
8298                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
8299                    )
8300                    .await?;
8301                let returned = data
8302                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
8303                    .ok_or_else(|| SourceError::Malformed {
8304                        message: "GitHub field clear returned no project item".into(),
8305                    })?;
8306                if required_str(returned, "id")? != item_id {
8307                    return Err(SourceError::Malformed {
8308                        message: "GitHub field clear returned the wrong project item".into(),
8309                    });
8310                }
8311                Ok(())
8312            }
8313        }
8314    }
8315
8316    /// The refusal a priority is answered with by an instance configured with no
8317    /// `priority_mapping`, which holds none.
8318    fn holds_no_priority(&self) -> SourceError {
8319        SourceError::Refused {
8320            message: format!(
8321                "source {} holds no task priority: its configuration sets no priority_mapping; \
8322                 next: set priority_mapping on this source, then run `onetaskgraph sources \
8323                 fields {} --apply` to set its board up",
8324                self.name, self.name
8325            ),
8326        }
8327    }
8328
8329    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
8330    ///
8331    /// One field write — a select, or a clear for `none` — and no title, body, label, state
8332    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
8333    async fn set_priority(
8334        &self,
8335        id: &NativeId,
8336        priority: Priority,
8337    ) -> Result<Option<Priority>, SourceError> {
8338        if self.priorities.is_none() {
8339            return Err(self.holds_no_priority());
8340        }
8341        let Some(mut item) = self
8342            .bound_item(id)
8343            .await?
8344            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8345        else {
8346            return Ok(None);
8347        };
8348        if priority == Priority::None && !item.holds_priority() {
8349            return Ok(Some(priority));
8350        }
8351        // The item's own read carries the field's definition whenever it holds a value of
8352        // it, which a clear always does; a select onto an item holding none reads the board.
8353        let board = match (item.carried_board(), item.named_board()) {
8354            (Some(board), _) => board,
8355            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
8356                id,
8357                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
8358            },
8359            _ => self.board_fields().await?,
8360        };
8361        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
8362            return Ok(Some(priority));
8363        };
8364        let (document, root, input) = match write {
8365            PriorityWrite::Select { field, option } => (
8366                graphql::UPDATE_FIELD,
8367                "updateProjectV2ItemFieldValue",
8368                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
8369            ),
8370            PriorityWrite::Clear { field } => (
8371                graphql::CLEAR_FIELD,
8372                "clearProjectV2ItemFieldValue",
8373                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
8374            ),
8375        };
8376        let data = self
8377            .graphql(
8378                document,
8379                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
8380            )
8381            .await?;
8382        let returned = data
8383            .get(root)
8384            .and_then(|value| value.get("projectV2Item"))
8385            .ok_or_else(|| SourceError::Malformed {
8386                message: "GitHub priority write returned no project item".into(),
8387            })?;
8388        if required_str(returned, "id")? != item.item_id {
8389            return Err(SourceError::Malformed {
8390                message: "GitHub priority write returned the wrong project item".into(),
8391            });
8392        }
8393        let value = returned
8394            .get("fieldValueByName")
8395            .ok_or_else(|| SourceError::Malformed {
8396                message: "GitHub priority write returned no priority read-back".into(),
8397            })?;
8398        if !value.is_null()
8399            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
8400        {
8401            return Err(SourceError::Malformed {
8402                message: "GitHub priority read-back is not a Priority field value".into(),
8403            });
8404        }
8405        let values = if value.is_null() {
8406            Vec::new()
8407        } else {
8408            vec![value.clone()]
8409        };
8410        item.priority = self.held_priority(&values)?;
8411        let answer = item.task()?.priority;
8412        self.remember_written(item, false)?;
8413        Ok(Some(answer))
8414    }
8415
8416    /// Replace one task's visible body and nothing else; see
8417    /// [`TaskSource::set_task_content`].
8418    ///
8419    /// One update of the body, which differs from the body GitHub holds only outside the
8420    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
8421    /// this source keeps there reads back as it was. A body that would not change is not
8422    /// sent at all.
8423    async fn replace_content(
8424        &self,
8425        id: &NativeId,
8426        content: &str,
8427    ) -> Result<Option<()>, SourceError> {
8428        let Some(mut item) = self
8429            .bound_item(id)
8430            .await?
8431            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8432        else {
8433            return Ok(None);
8434        };
8435        let held = item.raw_body.clone().unwrap_or_default();
8436        let body = with_content(&held, &self.marked(content))?;
8437        // Checked before anything is sent: content ending in what this source reads as its own
8438        // metadata slot would read back as metadata rather than as the content it was.
8439        let (visible, slot) = self.visible(Some(body.clone()))?;
8440        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
8441            return Err(SourceError::Refused {
8442                message: format!(
8443                    "this content ends in what source {} reads as its own metadata slot \
8444                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
8445                     as content; next: remove that trailing block from the content",
8446                    self.name
8447                ),
8448            });
8449        }
8450        if body != held {
8451            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8452                .await?;
8453        }
8454        item.body = visible.filter(|value| !value.is_empty());
8455        item.raw_body = Some(body);
8456        item.slot = slot;
8457        self.remember_written(item, false)?;
8458        Ok(Some(()))
8459    }
8460
8461    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
8462    ///
8463    /// One read of the item — which carries the board's field definitions and the issue's
8464    /// `blockedBy`, so neither is read again — and then only what differs from it: the
8465    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
8466    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
8467    /// the title, the body — visible content and metadata slot together — and a state change.
8468    /// So an update naming any of title, body, metadata, status and priority is one read and
8469    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
8470    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
8471    /// as a whole write does; an open one selects its option and then reopens. The origin
8472    /// field is never written: an update is of an item that already exists, whose origin is
8473    /// what it is.
8474    ///
8475    /// The task answered is the item as those writes left it, built from the read and what was
8476    /// sent rather than read again — the same record a later read in this run answers from.
8477    async fn targeted_update(
8478        &self,
8479        id: &NativeId,
8480        update: &TaskUpdate,
8481    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8482        // Everything this source can refuse without reading the item is refused first, in the
8483        // words a whole write of the same fields is refused with.
8484        update.consistent()?;
8485        if update
8486            .title
8487            .as_deref()
8488            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
8489        {
8490            return Err(SourceError::Refused {
8491                message: format!(
8492                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
8493                     spells a document, so it would read back as one rather than as a task; \
8494                     retitle it",
8495                    self.name
8496                ),
8497            });
8498        }
8499        if let Some(delivers) = &update.delivers {
8500            TaskRef::listed(
8501                TaskRef::DELIVERS_KEY,
8502                id,
8503                Some(&self.name),
8504                delivers.clone(),
8505            )
8506            .map_err(|message| SourceError::Refused { message })?;
8507        }
8508        if self.priorities.is_none()
8509            && update
8510                .priority
8511                .is_some_and(|priority| priority != Priority::None)
8512        {
8513            return Err(self.holds_no_priority());
8514        }
8515        let target = update
8516            .status
8517            .as_ref()
8518            .map(|status| self.resolved_target(ItemKind::Task, status.category))
8519            .transpose()?;
8520        let Some(mut item) = self
8521            .bound_item(id)
8522            .await?
8523            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8524        else {
8525            return Ok(None);
8526        };
8527        let before = item.task()?;
8528
8529        let mut status_move = None;
8530        if let (Some(status), Some(target)) = (&update.status, target) {
8531            let board = self.status_board(&item).await?;
8532            let (field, option, name) = self
8533                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
8534                .ok_or_else(|| SourceError::Malformed {
8535                    message: format!(
8536                        "status {} of source {} names no board Status option",
8537                        category_name(status.category),
8538                        self.name
8539                    ),
8540                })?;
8541            let terminal = matches!(target, StatusTarget::Terminal(_, _));
8542            if terminal && item.content_kind == ContentKind::DraftIssue {
8543                return Err(self.closes_a_draft(status.category));
8544            }
8545            let landed = match &target {
8546                StatusTarget::Terminal(_, reason) => {
8547                    self.statuses
8548                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
8549                }
8550                _ => self
8551                    .statuses
8552                    .status(ItemKind::Task, Some(&name), false, None),
8553            };
8554            let option_moves = item
8555                .option
8556                .as_deref()
8557                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
8558            let state_moves = item.content_kind == ContentKind::Issue
8559                && (item.closed != terminal || (terminal && item.status != landed));
8560            if let Some(moves) = Moves::of(option_moves, state_moves) {
8561                status_move = Some(StatusMove {
8562                    board: board.id,
8563                    field,
8564                    option,
8565                    name,
8566                    target,
8567                    landed,
8568                    moves,
8569                });
8570            }
8571        }
8572
8573        let mut priority_move = None;
8574        if let Some(priority) = update.priority
8575            && self.priorities.is_some()
8576            && item.priority != HeldPriority::Read(priority)
8577        {
8578            let board = match (item.carried_board(), item.named_board()) {
8579                (Some(board), _) => board,
8580                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
8581                    id: board,
8582                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8583                },
8584                _ => self.board_fields().await?,
8585            };
8586            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
8587                priority_move = Some((board.id, write, priority));
8588            }
8589        }
8590
8591        // The metadata keys this update names that a board text field projects, and what
8592        // that does to those fields — refused, as a whole write refuses it, before anything
8593        // is sent.
8594        let touched: Vec<&str> = update
8595            .metadata_set
8596            .keys()
8597            .chain(update.metadata_remove.iter())
8598            .map(MetadataKey::as_str)
8599            .collect();
8600        let mut projection_move = None;
8601        if self
8602            .metadata_fields
8603            .iter()
8604            .any(|projection| touched.contains(&&*projection.key))
8605        {
8606            let mut metadata = item.slot.clone();
8607            for (key, value) in &update.metadata_set {
8608                metadata.insert(key.as_str().to_owned(), value.clone());
8609            }
8610            for key in &update.metadata_remove {
8611                metadata.remove(key.as_str());
8612            }
8613            let board = self.projection_board(&item).await?;
8614            let (writes, projected) = self.projection_writes(
8615                &board.fields,
8616                Some(&item.projected),
8617                &metadata,
8618                Some(&touched),
8619            )?;
8620            projection_move = Some((board.id, writes, projected));
8621        }
8622
8623        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
8624        // recorded in the slot, and the slot travels in the one body update below.
8625        let edges = match &update.depends_on {
8626            Some(edges) => Some(
8627                self.partition_edges(
8628                    BoardKind::Work(ItemKind::Task),
8629                    item.content_kind,
8630                    item.blocked_by.as_deref(),
8631                    edges,
8632                )
8633                .await?,
8634            ),
8635            None => None,
8636        };
8637
8638        // A record primed from what a producer wrote holds no body anybody read, so an update
8639        // changing the body reads it first: by the `Status` write's own answer when the update
8640        // moves the option — the mutation answering with the issue whole — and otherwise by
8641        // reading the issue. The body is then composed against what GitHub holds now.
8642        let mut option_selected = false;
8643        // The mark stays until a read lands, so an update that writes no body, or one whose
8644        // read fails, leaves the next update of it to read the body too.
8645        if (update.content.is_some()
8646            || !update.metadata_set.is_empty()
8647            || !update.metadata_remove.is_empty()
8648            || update.delivers.is_some()
8649            || edges.is_some())
8650            && self.is_unread(id)
8651        {
8652            let live = match status_move.as_ref().filter(|moving| moving.moves.option()) {
8653                Some(moving) => {
8654                    option_selected = true;
8655                    Some(
8656                        self.select_reading_body(
8657                            moving.board.as_str(),
8658                            &item.item_id,
8659                            &moving.field,
8660                            &moving.option,
8661                        )
8662                        .await?,
8663                    )
8664                }
8665                None => {
8666                    self.forget(id)?;
8667                    let Some(read) = self.item_by_id(id).await? else {
8668                        return Ok(None);
8669                    };
8670                    read.raw_body
8671                }
8672            };
8673            let (visible, read_slot) = self.visible(live.clone())?;
8674            self.mark_read(id);
8675            item.raw_body = live;
8676            item.body = visible;
8677            item.slot = read_slot;
8678        }
8679
8680        let mut slot = item.slot.clone();
8681        for (key, value) in &update.metadata_set {
8682            slot.insert(key.as_str().to_owned(), value.clone());
8683        }
8684        for key in &update.metadata_remove {
8685            slot.remove(key.as_str());
8686        }
8687        if let Some(delivers) = &update.delivers {
8688            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
8689        }
8690        if let Some((_, recorded)) = &edges {
8691            record_edges(&mut slot, recorded);
8692        }
8693        let held = item.raw_body.clone().unwrap_or_default();
8694        let content = match &update.content {
8695            Some(content) => with_content(&held, &self.marked(content))?,
8696            None => held.clone(),
8697        };
8698        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
8699        // the body's bytes, as a metadata write compares it: a slot a person spelled with
8700        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
8701        let body = if slot == item.slot {
8702            content
8703        } else {
8704            with_slot(&content, &slot)?
8705        };
8706        // Checked before anything is sent, as a content write checks it: content ending in
8707        // what this source reads as its own slot would read back as metadata.
8708        let (visible, read) = self.visible(Some(body.clone()))?;
8709        let wanted = update.content.as_deref().or(item.body.as_deref());
8710        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
8711            return Err(SourceError::Refused {
8712                message: format!(
8713                    "this content ends in what source {} reads as its own metadata slot \
8714                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
8715                     as content; next: remove that trailing block from the content",
8716                    self.name
8717                ),
8718            });
8719        }
8720        let recorded_moves =
8721            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
8722
8723        // One `updateIssue` carries all three, because every mutation spends the secondary
8724        // limiter and the title, body and state are one mutation's inputs.
8725        let mut fields = serde_json::Map::new();
8726        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
8727            fields.insert("title".to_owned(), json!(title));
8728        }
8729        if body != held {
8730            fields.insert("body".to_owned(), json!(body));
8731        }
8732        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
8733            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
8734        }
8735        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
8736        // GitHub runs no two requests as one, and runs one document's mutation fields in order
8737        // without undoing an earlier field when a later one fails — so a body written before a
8738        // board field the board then refused would be left changed. Written after every other
8739        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
8740        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
8741        // first, together in one request — a terminal option selected before the issue
8742        // closes, as a whole write does — then the `blockedBy` difference, then the body.
8743        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
8744        let mut clears: Vec<(&BoardId, &str)> = Vec::new();
8745        if let Some(moving) = status_move
8746            .as_ref()
8747            .filter(|moving| moving.moves.option() && !option_selected)
8748        {
8749            board_writes.push((
8750                &moving.board,
8751                (
8752                    moving.field.clone(),
8753                    json!({"singleSelectOptionId": moving.option}),
8754                ),
8755            ));
8756        }
8757        match &priority_move {
8758            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
8759                board,
8760                (field.clone(), json!({"singleSelectOptionId": option})),
8761            )),
8762            Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
8763            None => {}
8764        }
8765        if let Some((board, writes, _)) = &projection_move {
8766            for write in writes {
8767                let (mut values, mut cleared) = (Vec::new(), Vec::new());
8768                write.join(&mut values, &mut cleared);
8769                board_writes.extend(values.into_iter().map(|value| (board, value)));
8770                clears.extend(cleared.into_iter().map(|field| (board, field)));
8771            }
8772        }
8773        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
8774        boards.extend(clears.iter().map(|(board, _)| *board));
8775        boards.dedup_by(|one, other| one.as_str() == other.as_str());
8776        for board in boards {
8777            let writes = board_writes
8778                .iter()
8779                .filter(|(on, _)| on.as_str() == board.as_str())
8780                .map(|(_, write)| write.clone())
8781                .collect::<Vec<_>>();
8782            let cleared = clears
8783                .iter()
8784                .filter(|(on, _)| on.as_str() == board.as_str())
8785                .map(|(_, field)| *field)
8786                .collect::<Vec<_>>();
8787            self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
8788                .await?;
8789        }
8790        let mut blocked_by_moved = false;
8791        if let Some((native, _)) = &edges
8792            && item.content_kind == ContentKind::Issue
8793        {
8794            blocked_by_moved = self
8795                .reconcile_blocked_by(
8796                    &item.id,
8797                    native,
8798                    Issue::Existing(item.blocked_by.as_deref()),
8799                )
8800                .await?;
8801        }
8802        if !fields.is_empty() {
8803            self.update_content(item.content_kind, &item.id, Value::Object(fields))
8804                .await?;
8805        }
8806
8807        // The `blockedBy` this item was read with is what the write just changed, so it is no
8808        // answer any more: a later write in this command would reconcile against it — re-adding
8809        // what it kept and never removing what it dropped — and a forward-edge read would
8810        // answer with it. Left unknown, each asks GitHub instead.
8811        if blocked_by_moved {
8812            item.blocked_by = None;
8813        }
8814        if let Some(title) = &update.title {
8815            item.title.clone_from(title);
8816        }
8817        item.body = visible.filter(|value| !value.is_empty());
8818        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
8819        item.slot = slot;
8820        if let Some(delivers) = &update.delivers {
8821            item.delivers.clone_from(delivers);
8822        }
8823        if let Some(moving) = status_move {
8824            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
8825                && item.content_kind == ContentKind::Issue;
8826            item.status = moving.landed;
8827            item.option = Some(moving.name);
8828        }
8829        if let Some((_, _, priority)) = priority_move {
8830            item.priority = HeldPriority::Read(priority);
8831        }
8832        if let Some((_, _, projected)) = projection_move {
8833            item.projected = projected;
8834        }
8835        let task = item.task()?;
8836        let mut written = update.changed(&before, &task);
8837        if blocked_by_moved || recorded_moves {
8838            written.insert(UpdatedField::DependsOn);
8839        }
8840        self.remember_written(item, false)?;
8841        Ok(Some(TaskUpdateOutcome {
8842            task,
8843            written,
8844            delivers_before: before.delivers,
8845        }))
8846    }
8847
8848    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
8849    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
8850    ///
8851    /// One update of the body: the content outside the slot, and inside it that one entry,
8852    /// every other entry kept as it was. This source keeps no template answers — an issue has
8853    /// no room beside itself that is not its body, and answers written there would duplicate
8854    /// what the content already says and count against GitHub's body limit — so `answers`
8855    /// reaches nothing here. A body that would not change is not sent at all.
8856    async fn replace_rendering(
8857        &self,
8858        id: &NativeId,
8859        kind: BoardKind,
8860        content: &str,
8861        provenance: &Value,
8862        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
8863    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
8864        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8865            return Ok(None);
8866        };
8867        let held = item.raw_body.clone().unwrap_or_default();
8868        let mut slot = item.slot.clone();
8869        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
8870        let rewritten;
8871        let content = if let Some(assets) = assets {
8872            let uploads = self
8873                .upload_assets(item.own_repository.as_ref(), assets)
8874                .await?;
8875            rewritten =
8876                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
8877            rewritten.as_str()
8878        } else {
8879            content
8880        };
8881        let body = with_slot(&with_content(&held, &self.marked(content))?, &slot)?;
8882        // Checked before anything is sent, as a content write checks it.
8883        let (visible, read) = self.visible(Some(body.clone()))?;
8884        if visible.as_deref().unwrap_or_default() != content || read != slot {
8885            return Err(SourceError::Refused {
8886                message: format!(
8887                    "this content ends in what source {} reads as its own metadata slot \
8888                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
8889                     as content; next: remove that trailing block from the template",
8890                    self.name
8891                ),
8892            });
8893        }
8894        if body != held {
8895            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8896                .await?;
8897        }
8898        item.body = visible.filter(|value| !value.is_empty());
8899        item.raw_body = Some(body);
8900        item.slot = read;
8901        self.remember_written(item, false)?;
8902        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
8903            id: id.clone(),
8904            content: Some(content.to_owned()),
8905        }))
8906    }
8907
8908    async fn set_item_field(
8909        &self,
8910        board_id: &str,
8911        item_id: &str,
8912        field_id: &str,
8913        value: Value,
8914    ) -> Result<(), SourceError> {
8915        let data = self
8916            .graphql(
8917                graphql::UPDATE_FIELD,
8918                json!({"input":{
8919                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
8920                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
8921            )
8922            .await?;
8923        field_update_item(&data, item_id).map(|_| ())
8924    }
8925
8926    /// GitHub accepts one value per field mutation; aliases combine those mutations in
8927    /// one request — writes in the order given, then clears. A lone write is
8928    /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
8929    /// request's slots hold go in further requests of the same document, in order. Every
8930    /// returned item id is checked, including optional aliases.
8931    async fn set_item_fields(
8932        &self,
8933        board: &str,
8934        item: &str,
8935        fields: &[(String, Value)],
8936        clears: &[&str],
8937    ) -> Result<(), SourceError> {
8938        match (fields, clears) {
8939            ([], []) => return Ok(()),
8940            ([(field, value)], []) => {
8941                return self.set_item_field(board, item, field, value.clone()).await;
8942            }
8943            ([], [field]) => {
8944                return self
8945                    .write_priority(
8946                        board,
8947                        item,
8948                        &PriorityWrite::Clear {
8949                            field: (*field).to_owned(),
8950                        },
8951                    )
8952                    .await;
8953            }
8954            _ => {}
8955        }
8956        // Any field of the request stands in for an input a slot leaves out, which GitHub
8957        // still requires to be well formed although it never runs it.
8958        let filler = fields
8959            .first()
8960            .map_or_else(|| clears[0], |(field, _)| field.as_str());
8961        let mut writes = fields.iter();
8962        let mut cleared = clears.iter();
8963        loop {
8964            let batch: Vec<&(String, Value)> =
8965                writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
8966            let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
8967            if batch.is_empty() && batch_clears.is_empty() {
8968                return Ok(());
8969            }
8970            let mut variables = serde_json::Map::new();
8971            for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
8972                let input = match batch.get(index) {
8973                    Some((field, value)) => {
8974                        json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
8975                    }
8976                    None => json!({"projectId":board,"itemId":item,"fieldId":filler,
8977                                   "value":{"text":""}}),
8978                };
8979                variables.insert(slot.variable.to_owned(), input);
8980                variables.insert(slot.include.to_owned(), json!(index < batch.len()));
8981            }
8982            for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
8983                let field = batch_clears.get(index).map_or(filler, |field| **field);
8984                variables.insert(
8985                    slot.variable.to_owned(),
8986                    json!({"projectId":board,"itemId":item,"fieldId":field}),
8987                );
8988                variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
8989            }
8990            let data = self
8991                .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
8992                .await?;
8993            let sent = FIELD_WRITE_SLOTS[..batch.len()]
8994                .iter()
8995                .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
8996            for slot in sent {
8997                let alias = slot.alias;
8998                let returned = data
8999                    .get(alias)
9000                    .and_then(|value| value.get("projectV2Item"))
9001                    .ok_or_else(|| SourceError::Malformed {
9002                        message: format!("GitHub field update {alias} returned no project item"),
9003                    })?;
9004                if required_str(returned, "id")? != item {
9005                    return Err(SourceError::Malformed {
9006                        message: format!(
9007                            "GitHub field update {alias} returned the wrong project item"
9008                        ),
9009                    });
9010                }
9011            }
9012        }
9013    }
9014
9015    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
9016        let mut after: Option<String> = None;
9017        let mut ids = Vec::new();
9018        loop {
9019            let data = self
9020                .graphql(
9021                    graphql::ISSUE_DEPENDENCIES,
9022                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
9023                )
9024                .await?;
9025            let connection =
9026                data.pointer("/node/blockedBy")
9027                    .ok_or_else(|| SourceError::Malformed {
9028                        message: "GitHub dependency response has no blockedBy connection".into(),
9029                    })?;
9030            ids.extend(
9031                connection
9032                    .get("nodes")
9033                    .and_then(Value::as_array)
9034                    .ok_or_else(|| SourceError::Malformed {
9035                        message: "GitHub dependency response nodes is not an array".into(),
9036                    })?
9037                    .iter()
9038                    .map(|value| required_str(value, "id").map(str::to_owned))
9039                    .collect::<Result<Vec<_>, _>>()?,
9040            );
9041            let next = next_cursor(connection)?;
9042            if let Some(next) = &next {
9043                validate_cursor_progress(after.as_deref(), &next.0)?;
9044            }
9045            after = next.map(|cursor| cursor.0);
9046            if after.is_none() {
9047                return Ok(ids);
9048            }
9049        }
9050    }
9051
9052    async fn dependencies(
9053        &self,
9054        id: &NativeId,
9055        near_kind: ItemKind,
9056        direction: Direction,
9057        page: &PageRequest,
9058    ) -> Result<Page<DependencyEdge>, SourceError> {
9059        validate_page(page)?;
9060        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
9061        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
9062        let recorded = recorded_offset(cursor, direction)?;
9063        // What this issue is blocked by, when a read of it by its own id in this command
9064        // already carried the whole connection — a copy reads the item it writes before it
9065        // reads its edges — and the page asked for is the whole of it, or the recorded tail
9066        // after it. Answered from that read, in the shape the dependency read answers in;
9067        // anything else is asked of GitHub.
9068        let carried = match direction {
9069            Direction::DependsOn => self
9070                .resolved_cache()?
9071                .get(id)
9072                .filter(|item| item.content_kind == ContentKind::Issue)
9073                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
9074            Direction::DependedOnBy => None,
9075        }
9076        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
9077        // Asked for even in the recorded phase, whose page reads nothing from the
9078        // connection: `__typename` is what says whether this item has a native
9079        // relationship at all, and that is what decides which far ends the reserved key is
9080        // allowed to hold.
9081        let data = match carried {
9082            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
9083                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
9084            None => {
9085                let after = if recorded.is_some() { None } else { cursor };
9086                let first = page.limit.min(MAX_PAGE_SIZE);
9087                match direction {
9088                    Direction::DependsOn => {
9089                        self.graphql(
9090                            graphql::ISSUE_DEPENDS_ON,
9091                            json!({"id":id.0,"first":first,"after":after,"owner":self.owner,
9092                                   "number":self.project_number,
9093                                   "boardItems":BOARD_ITEMS_PAGE_SIZE}),
9094                        )
9095                        .await?
9096                    }
9097                    Direction::DependedOnBy => {
9098                        self.graphql(
9099                            graphql::ISSUE_DEPENDED_ON_BY,
9100                            json!({"id":id.0,"first":first,"after":after}),
9101                        )
9102                        .await?
9103                    }
9104                }
9105            }
9106        };
9107        let draft_slot = draft_slot(&data, id, self.project_number)?;
9108        let node =
9109            data.get("node")
9110                .filter(|v| !v.is_null())
9111                .ok_or_else(|| SourceError::Refused {
9112                    message: format!(
9113                        "GitHub item {} was not found or does not support dependencies",
9114                        id.0
9115                    ),
9116                })?;
9117        let connection_name = match direction {
9118            Direction::DependsOn => "blockedBy",
9119            Direction::DependedOnBy => "blocking",
9120        };
9121        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
9122        // named natively and the reserved key may hold any far end. An issue's connections
9123        // hold issues, and this source reads them at the near item's own level.
9124        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
9125        if let Some(offset) = recorded {
9126            return Ok(recorded_page(
9127                self.recorded_edges(
9128                    id,
9129                    near_kind,
9130                    direction,
9131                    natively_names,
9132                    node,
9133                    draft_slot.as_ref(),
9134                )
9135                .await?,
9136                offset,
9137                limit,
9138            ));
9139        }
9140        if natively_names.is_none() {
9141            return Ok(recorded_page(
9142                self.recorded_edges(
9143                    id,
9144                    near_kind,
9145                    direction,
9146                    natively_names,
9147                    node,
9148                    draft_slot.as_ref(),
9149                )
9150                .await?,
9151                0,
9152                limit,
9153            ));
9154        }
9155        let connection = node
9156            .get(connection_name)
9157            .ok_or_else(|| SourceError::Malformed {
9158                message: "GitHub dependency response is missing its connection".into(),
9159            })?;
9160        let nodes = connection
9161            .get("nodes")
9162            .and_then(Value::as_array)
9163            .ok_or_else(|| SourceError::Malformed {
9164                message: "GitHub dependency response nodes is not an array".into(),
9165            })?;
9166        // `from` depends on `to`, always. GitHub spells the same relationship from either
9167        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
9168        // it — so the near item is `from` in one direction and `to` in the other.
9169        let items = nodes
9170            .iter()
9171            .map(|value| {
9172                let related = NativeId(required_str(value, "id")?.into());
9173                let related_kind = related_kind(value)?;
9174                let (from, to) = match direction {
9175                    Direction::DependsOn => (
9176                        DependencyEndpoint::from_native(id.clone(), near_kind),
9177                        DependencyEndpoint::from_native(related, related_kind),
9178                    ),
9179                    Direction::DependedOnBy => (
9180                        DependencyEndpoint::from_native(related, related_kind),
9181                        DependencyEndpoint::from_native(id.clone(), near_kind),
9182                    ),
9183                };
9184                Ok(DependencyEdge {
9185                    from,
9186                    to,
9187                    kind: DependencyKind::Blocks,
9188                })
9189            })
9190            .collect::<Result<Vec<_>, SourceError>>()?;
9191        let mut next = next_cursor(connection)?;
9192        if let Some(next) = &next {
9193            validate_cursor_progress(cursor, &next.0)?;
9194        }
9195        // The recorded far ends ride on the last page of `blockedBy`, whose answer carried the
9196        // body they are recorded in, as far as the page has room — so they cost no request of
9197        // their own — and only what does not fit is resumed by a cursor.
9198        let mut items = items;
9199        if next.is_none() {
9200            let recorded = self
9201                .recorded_edges(
9202                    id,
9203                    near_kind,
9204                    direction,
9205                    natively_names,
9206                    node,
9207                    draft_slot.as_ref(),
9208                )
9209                .await?;
9210            let room = limit.saturating_sub(items.len());
9211            let total = recorded.len();
9212            items.extend(recorded.into_iter().take(room));
9213            if total > room {
9214                next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
9215            }
9216        }
9217        Ok(Page { items, next })
9218    }
9219
9220    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
9221    /// a far end in another source has to live: no GitHub issue relationship can name one.
9222    ///
9223    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
9224    /// source never writes one down.
9225    ///
9226    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
9227    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
9228    /// request beyond the read already made, and reading the board for them would be a
9229    /// walk of every item for one field of one. A draft has no body in that answer, because
9230    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
9231    /// listing of the board, which can be behind on the very item asked about.
9232    async fn recorded_edges(
9233        &self,
9234        id: &NativeId,
9235        near_kind: ItemKind,
9236        direction: Direction,
9237        natively_names: Option<ItemKind>,
9238        node: &Value,
9239        draft_slot: Option<&Option<BTreeMap<String, Value>>>,
9240    ) -> Result<Vec<DependencyEdge>, SourceError> {
9241        if direction != Direction::DependsOn {
9242            return Ok(Vec::new());
9243        }
9244        let slot = match (node.get("body"), draft_slot) {
9245            (Some(body), _) if natively_names.is_some() => {
9246                metadata_body(body.as_str().map(str::to_owned))?.1
9247            }
9248            // A draft the dependency read itself answered: this board's, or none of its.
9249            (_, Some(Some(slot))) => slot.clone(),
9250            (_, Some(None)) => return Ok(Vec::new()),
9251            _ => {
9252                let Some(item) = self.bound_item(id).await? else {
9253                    return Ok(Vec::new());
9254                };
9255                item.slot
9256            }
9257        };
9258        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
9259            .map_err(|message| SourceError::Malformed { message })
9260    }
9261
9262    /// Why `creation_owners` refuses a create filed in `target`, or `None` when it allows it —
9263    /// every create when the list is empty.
9264    fn owner_refusal(&self, target: &RepositoryTarget) -> Option<String> {
9265        if self.creation_owners.is_empty()
9266            || self
9267                .creation_owners
9268                .iter()
9269                .any(|owner| owner.eq_ignore_ascii_case(&target.owner))
9270        {
9271            return None;
9272        }
9273        Some(format!(
9274            "source {} would create the issue in repository {}, owned by {}, and its \
9275             creation_owners allows only repositories of {}",
9276            self.name,
9277            target.slug(),
9278            target.owner,
9279            self.creation_owners.join(", ")
9280        ))
9281    }
9282
9283    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
9284        self.repository
9285            .as_ref()
9286            .ok_or_else(|| SourceError::Refused {
9287                message: format!(
9288                    "source {} has no repository configured, and a GitHub Projects board has no \
9289                 repository of its own to create an issue in; set repository: owner/name on \
9290                 this source",
9291                    self.name
9292                ),
9293            })
9294    }
9295
9296    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
9297    /// states.
9298    ///
9299    /// The fallback is demanded first, whichever arm answers: a write without a configured
9300    /// repository is refused naming the field exactly as it was before the rule existed,
9301    /// so a source that could not write before cannot write now, rather than writing for
9302    /// the one item whose own field happens to decide it.
9303    ///
9304    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
9305    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
9306    /// entry owned by someone other than the owner of the parent issue's repository —
9307    /// GitHub accepts a sub-issue from another repository of the same owner and from no
9308    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
9309    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
9310    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
9311    /// and is visible to the token is checked where its node id is resolved, still before
9312    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
9313    /// looked up in a listing of the board, which can be minutes behind an issue its own
9314    /// `projectItems` already places on it — and that read answers first from this process's
9315    /// own record, so a project created moments ago in this command answers though GitHub
9316    /// has not caught up.
9317    async fn creation_target(
9318        &self,
9319        incoming: &Incoming<'_>,
9320    ) -> Result<RepositoryTarget, SourceError> {
9321        let what = format!(
9322            "{} {:?}",
9323            incoming.written.kind().describes(),
9324            incoming.title
9325        );
9326        let target = self
9327            .creation_rule(incoming.repositories, incoming.parent, &what)
9328            .await?;
9329        // The engine asks `creation_refusal` before anything of a copy is written; this holds
9330        // every other create to the same restriction, before `createIssue`.
9331        match self.owner_refusal(&target) {
9332            Some(message) => Err(SourceError::Refused { message }),
9333            None => Ok(target),
9334        }
9335    }
9336
9337    /// [`Self::creation_target`]'s rule over what it reads of the item — its `repositories`
9338    /// and the project issue it is filed under — with `what` naming the item in a refusal: the
9339    /// one place the rule is, so the create, [`TaskSource::creation_repository`] and the
9340    /// write-time read of who can read the issue ([`TaskSource::visibility`]) cannot answer
9341    /// differently.
9342    ///
9343    /// On a source declared private, an item's one named repository is read for its
9344    /// visibility first — once per engine hold, see the `visibility` module — and one that
9345    /// does not read private decides nothing: the issue is created in the parent's
9346    /// repository, else the configured one. A read that cannot be answered refuses.
9347    async fn creation_rule(
9348        &self,
9349        repositories: &[Repository],
9350        parent: Option<&NativeId>,
9351        what: &str,
9352    ) -> Result<RepositoryTarget, SourceError> {
9353        let parent = match parent {
9354            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
9355                SourceError::Refused {
9356                    message: format!(
9357                        "GitHub project issue {} was not found on the board of source {}, so {} \
9358                         cannot be filed under it",
9359                        parent.0, self.name, what
9360                    ),
9361                }
9362            })?),
9363            None => None,
9364        };
9365        let parents_repository = parent
9366            .as_ref()
9367            .map(|parent| {
9368                // A draft is on the board and so is found, but it has no repository to
9369                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
9370                // would refuse the task only once `createIssue` had made it.
9371                if parent.content_kind == ContentKind::DraftIssue {
9372                    return Err(SourceError::Refused {
9373                        message: format!(
9374                            "GitHub project item {} on the board of source {} is a draft, \
9375                             which cannot have sub-issues, so {} cannot be filed under it",
9376                            parent.id.0, self.name, what
9377                        ),
9378                    });
9379                }
9380                // An issue's repository is where a sub-issue is placed and whose owner it
9381                // is compared against, so a parent whose repository this source cannot
9382                // spell as `owner/name` — GitHub's login grammar is wider than this
9383                // source's floor — is one nothing can be filed under.
9384                parent
9385                    .own_repository
9386                    .as_ref()
9387                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
9388                    .ok_or_else(|| SourceError::Malformed {
9389                        message: format!(
9390                            "GitHub project issue {} on the board of source {} is in {}, which \
9391                             is not a {}/owner/name repository this source can place {} in",
9392                            parent.id.0,
9393                            self.name,
9394                            parent
9395                                .own_repository
9396                                .as_ref()
9397                                .map_or("no repository", Repository::as_str),
9398                            RepositoryTarget::HOST,
9399                            what
9400                        ),
9401                    })
9402            })
9403            .transpose()?;
9404        self.creation_rule_in_repository(repositories, parents_repository, what)
9405            .await
9406    }
9407
9408    async fn creation_rule_in_repository(
9409        &self,
9410        repositories: &[Repository],
9411        parents_repository: Option<RepositoryTarget>,
9412        what: &str,
9413    ) -> Result<RepositoryTarget, SourceError> {
9414        let fallback = self.configured_repository()?;
9415        match repositories {
9416            [named] => {
9417                let target =
9418                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
9419                        message: format!(
9420                            "{} names repository {}, which is not a {}/owner/name repository \
9421                             source {} can create an issue in; name one that is, or name none",
9422                            what,
9423                            named.as_str(),
9424                            RepositoryTarget::HOST,
9425                            self.name
9426                        ),
9427                    })?;
9428                // On a source declared private, an issue whose one named repository does not
9429                // read private is created where an item naming none is: the item still names
9430                // that repository in its metadata slot, and its issue stays private.
9431                if self.is_declared_private()
9432                    && self.repository_visibility(&target).await?
9433                        != onetaskgraph_plugin_api::Visibility::Private
9434                {
9435                    return Ok(parents_repository.unwrap_or_else(|| fallback.clone()));
9436                }
9437                if let Some(parents) = &parents_repository
9438                    && parents.owner != target.owner
9439                {
9440                    return Err(SourceError::Refused {
9441                        message: format!(
9442                            "{} names repository {}, owned by {}, but its project's issue is in \
9443                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
9444                             of the same owner as its parent issue; name a repository of {}, or \
9445                             name none",
9446                            what,
9447                            target.slug(),
9448                            target.owner,
9449                            parents.slug(),
9450                            parents.owner,
9451                            parents.owner
9452                        ),
9453                    });
9454                }
9455                Ok(target)
9456            }
9457            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
9458        }
9459    }
9460
9461    /// The node id of the repository `incoming` is being created in, or the refusal naming
9462    /// the item and the repository the token cannot see.
9463    ///
9464    /// Resolved once per command per repository; see [`Self::repository_cache`].
9465    async fn repository_id(
9466        &self,
9467        repository: &RepositoryTarget,
9468        incoming: &Incoming<'_>,
9469    ) -> Result<String, SourceError> {
9470        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
9471            return Ok(id);
9472        }
9473        let data = self
9474            .graphql(
9475                graphql::REPOSITORY,
9476                json!({"owner":repository.owner,"name":repository.name}),
9477            )
9478            .await?;
9479        self.repository_read(&data, repository, incoming)
9480    }
9481
9482    /// The repository's node id out of an answer carrying the `repository` root, held for
9483    /// the rest of this command, or the refusal naming the item that cannot be created in it.
9484    fn repository_read(
9485        &self,
9486        data: &Value,
9487        repository: &RepositoryTarget,
9488        incoming: &Incoming<'_>,
9489    ) -> Result<String, SourceError> {
9490        let node = data
9491            .get("repository")
9492            .filter(|value| !value.is_null())
9493            .ok_or_else(|| SourceError::Refused {
9494                message: format!(
9495                    "GitHub repository {} was not found or is not visible to the token, so {} \
9496                     {:?} cannot be created in it",
9497                    repository.slug(),
9498                    incoming.written.kind().describes(),
9499                    incoming.title
9500                ),
9501            })?;
9502        let id = required_str(node, "id")?.to_owned();
9503        self.repository_cache()?
9504            .insert(repository.clone(), id.clone());
9505        Ok(id)
9506    }
9507
9508    fn repository_cache(
9509        &self,
9510    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
9511        self.repository_cache
9512            .lock()
9513            .map_err(|_| SourceError::Unavailable {
9514                message: "this source's record of the destination repository was left \
9515                          inconsistent by an earlier failure; next: run the command again"
9516                    .into(),
9517            })
9518    }
9519
9520    /// Create or update one board item, whichever kind it is.
9521    async fn write_item(
9522        &self,
9523        incoming: &Incoming<'_>,
9524        target: Option<&NativeId>,
9525        depends_on: &[DependencyEdge],
9526    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9527        let prepared = self.prepare_write(incoming, target, depends_on).await?;
9528        let landed = match &prepared.destination {
9529            // Its content is written last, by `complete_write`, once everything else has landed.
9530            WriteDestination::Existing(item) => Landed {
9531                content_id: item.id.clone(),
9532                item_id: Some(item.item_id.clone()),
9533                url: item.url.clone(),
9534                number: item.number,
9535            },
9536            WriteDestination::New(target) => {
9537                if self.deferring()? {
9538                    self.create_filed_issue(
9539                        prepared.board.id.as_str(),
9540                        target,
9541                        incoming,
9542                        &prepared.body,
9543                    )
9544                    .await?
9545                } else {
9546                    self.create_and_file_issue(
9547                        prepared.board.id.as_str(),
9548                        target,
9549                        incoming,
9550                        &prepared.body,
9551                    )
9552                    .await?
9553                }
9554            }
9555        };
9556        self.complete_write(incoming, prepared, landed).await
9557    }
9558
9559    /// Everything one write of `incoming` reads and decides before it sends anything: what is
9560    /// there now, where a new item is created, the board's fields, and every value refused while
9561    /// nothing has been written — the item's body included.
9562    async fn prepare_write(
9563        &self,
9564        incoming: &Incoming<'_>,
9565        target: Option<&NativeId>,
9566        depends_on: &[DependencyEdge],
9567    ) -> Result<Prepared, SourceError> {
9568        // Refused before anything is read or written: a task or a project titled the way
9569        // this board spells a document would land as an issue this same source reads back
9570        // as a document, so the field this destination cannot carry is named rather than
9571        // written and silently reclassified.
9572        if let Written::Work(kind, _) = incoming.written
9573            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
9574        {
9575            return Err(SourceError::Refused {
9576                message: format!(
9577                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
9578                     spells a document, so it would read back as one rather than as a {}; \
9579                     retitle it, or copy it as a document",
9580                    kind.marker(),
9581                    self.name,
9582                    kind.marker()
9583                ),
9584            });
9585        }
9586        // The destination is read by its own id, and whether this board holds it is decided
9587        // by that read — its own `projectItems` — rather than by whether a listing of the
9588        // board happens to include it yet. See the module documentation.
9589        // An existing issue is never moved; a new one is created where the rule says — and
9590        // knowing where is what lets the board's fields and that repository's id be read
9591        // together, before anything below needs either.
9592        let destination = match target {
9593            Some(target) => {
9594                WriteDestination::Existing(Box::new(self.bound_item(target).await?.ok_or_else(
9595                    || SourceError::Refused {
9596                        message: format!("GitHub destination item {} was not found", target.0),
9597                    },
9598                )?))
9599            }
9600            None => {
9601                let target = self.creation_target(incoming).await?;
9602                self.creation_context(&target, incoming).await?;
9603                WriteDestination::New(target)
9604            }
9605        };
9606        let existing = destination.existing();
9607        let board = self
9608            .fields_for(
9609                existing,
9610                incoming.metadata.contains_key(ORIGIN_KEY),
9611                incoming.written.status().is_some(),
9612                incoming
9613                    .priority
9614                    .is_some_and(|priority| priority != Priority::None),
9615            )
9616            .await?;
9617        let status_target = incoming
9618            .written
9619            .work_status()
9620            .map(|(kind, status)| self.resolved_target(kind, status.category))
9621            .transpose()?;
9622        let column = match (incoming.written.work_status(), status_target.as_ref()) {
9623            (Some((kind, status)), Some(target)) => {
9624                self.column_for(&board.fields, kind, status.category, target)?
9625            }
9626            _ => None,
9627        };
9628        // Resolved before anything is created, for the reason the column above is: a
9629        // priority this board has no option for is refused while nothing has been written.
9630        let priority_write = match incoming.priority {
9631            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
9632            None => None,
9633        };
9634        // And for the reason the priority is: a value no projected field can hold, or a board
9635        // that cannot hold the field, is refused while nothing has been written.
9636        let (projections, projected) = self.projection_writes(
9637            &board.fields,
9638            existing.map(|item| &item.projected),
9639            incoming.metadata,
9640            None,
9641        )?;
9642        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
9643        if content_kind == ContentKind::DraftIssue {
9644            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
9645                (status_target.as_ref(), incoming.written.status())
9646            {
9647                return Err(self.closes_a_draft(status.category));
9648            }
9649            if incoming.parent.is_some() {
9650                return Err(SourceError::Refused {
9651                    message: "GitHub draft items cannot be a project's sub-issue".into(),
9652                });
9653            }
9654        }
9655        match existing {
9656            Some(item) if content_kind == ContentKind::Issue => {
9657                if item.labels != incoming.labels {
9658                    return Err(SourceError::Refused {
9659                        message: "GitHub issue labels differ from the labels being written".into(),
9660                    });
9661                }
9662            }
9663            _ => {
9664                if !incoming.labels.is_empty() {
9665                    return Err(SourceError::Refused {
9666                        message: "GitHub items created by this destination carry no labels".into(),
9667                    });
9668                }
9669            }
9670        }
9671
9672        // The repository the issue really lives in is what the slot below is written against,
9673        // so a single entry that is where the issue is created travels as no key at all, and
9674        // the read side derives it back from the issue.
9675        let own_repository = match &destination {
9676            WriteDestination::Existing(item) => item.own_repository.clone(),
9677            WriteDestination::New(target) => Some(
9678                Repository::try_from(target.origin())
9679                    .map_err(|message| SourceError::Config { message })?,
9680            ),
9681        };
9682        let (native, fallback) = self
9683            .partition_edges(
9684                incoming.written.kind(),
9685                content_kind,
9686                existing.and_then(|item| item.blocked_by.as_deref()),
9687                depends_on,
9688            )
9689            .await?;
9690        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
9691        let content = match incoming.assets {
9692            Some(assets) => {
9693                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
9694                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
9695                    incoming.content.unwrap_or_default(),
9696                    &mut slot,
9697                    &uploads,
9698                );
9699                incoming.content.map(|_| rewritten)
9700            }
9701            None => incoming.content.map(str::to_owned),
9702        };
9703        let marked = content.as_deref().map(|content| self.marked(content));
9704        let body = compose_body(marked.as_deref(), &slot)?;
9705        // Read before anything is created, for the reason the field below is: a value
9706        // this destination cannot store has to refuse, and refusing after `createIssue`
9707        // would leave an issue behind that nothing asked for. The engine writes a
9708        // qualified id here; a caller handing this key anything else is told so rather
9709        // than having it silently stored as no origin at all.
9710        // 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.
9711        let origin = match incoming.metadata.get(ORIGIN_KEY) {
9712            None => "",
9713            Some(Value::String(origin)) => origin.as_str(),
9714            Some(other) => {
9715                return Err(SourceError::Refused {
9716                    message: format!(
9717                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
9718                         is {other}"
9719                    ),
9720                });
9721            }
9722        };
9723        // Resolved before anything is created: a board that cannot carry the copy origin
9724        // has to refuse the write, and refusing it after `createIssue` would leave an
9725        // issue behind that nothing asked for.
9726        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
9727            Some(field) => {
9728                if required_str(field, "__typename")? != "ProjectV2Field" {
9729                    return Err(SourceError::Refused {
9730                        message: format!(
9731                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
9732                        ),
9733                    });
9734                }
9735                Some(required_str(field, "id")?.to_owned())
9736            }
9737            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
9738                return Err(SourceError::Refused {
9739                    message: format!(
9740                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
9741                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
9742                         the board"
9743                    ),
9744                });
9745            }
9746            None => None,
9747        };
9748
9749        Ok(Prepared {
9750            origin: origin.to_owned(),
9751            destination,
9752            board,
9753            status_target,
9754            column,
9755            priority_write,
9756            projections,
9757            projected,
9758            content_kind,
9759            own_repository,
9760            native,
9761            slot,
9762            content,
9763            body,
9764            origin_field,
9765        })
9766    }
9767
9768    /// Everything one write does once the item it writes exists: its board fields, its
9769    /// relationships and — for an item that was already there — its content last, then what this
9770    /// process remembers of it.
9771    ///
9772    /// While this source is holding writes back — see [`TaskSource::begin_deferred_writes`] — an
9773    /// item this command created has its fields, its closing and its edges held for
9774    /// [`TaskSource::finish_writes`] rather than sent here.
9775    async fn complete_write(
9776        &self,
9777        incoming: &Incoming<'_>,
9778        prepared: Prepared,
9779        landed: Landed,
9780    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9781        let Prepared {
9782            destination,
9783            board,
9784            status_target,
9785            column,
9786            priority_write,
9787            projections,
9788            projected,
9789            content_kind,
9790            own_repository,
9791            native,
9792            slot,
9793            content,
9794            body,
9795            origin,
9796            origin_field,
9797        } = prepared;
9798        let existing = destination.existing();
9799        let origin = origin.as_str();
9800        let Landed {
9801            content_id,
9802            item_id,
9803            url,
9804            number,
9805        } = landed;
9806
9807        // Only what differs from the item as this write read it is sent: a priority the item
9808        // already holds is not, so an unchanged re-copy writes no field and a person's edit of
9809        // it since that read is left alone.
9810        let priority_write = priority_write.filter(|_| {
9811            !existing.is_some_and(|item| {
9812                incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
9813            })
9814        });
9815        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
9816        let column = column
9817            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
9818            .map(|(field, option, _)| (field, option));
9819        // Creating an item here is several calls — `createIssue`, which files it on the
9820        // board, then its board fields, the parent and the dependencies — and GitHub can fail
9821        // at any of them. Everything this source can refuse *before* the first of those is
9822        // already checked above, so what is left is GitHub itself failing part way. When it
9823        // does over an item this call created, the issue is taken back: a write that
9824        // refused must not leave an item behind that nobody asked for, and one that does
9825        // makes the retry create a second.
9826        // Whether the board-field write carrying a moved origin was answered as landing whole.
9827        // When it was refused, GitHub does not say which of its fields ran before the one that
9828        // failed, so the origin may or may not have moved.
9829        let mut origin_landed = false;
9830        // An item this command created while writes are held back has every part that can land
9831        // later held for `finish_writes` — its own create filed it on the board and under its
9832        // project already — and so does a later write of it in the same command.
9833        let holding = (existing.is_none() && self.deferring()?) || self.holds_back(&content_id)?;
9834        let landed = if holding {
9835            self.hold_back(
9836                &board.id,
9837                &content_id,
9838                existing,
9839                origin_field.as_deref(),
9840                origin,
9841                column,
9842                status_target.as_ref(),
9843                priority_write.as_ref(),
9844                &projections,
9845                &native,
9846            )
9847        } else if let Some(item_id) = item_id.as_deref() {
9848            self.finish_write(
9849                board.id.as_str(),
9850                incoming,
9851                &content_id,
9852                item_id,
9853                content_kind,
9854                existing,
9855                origin_field.as_deref(),
9856                origin,
9857                column,
9858                status_target.as_ref(),
9859                priority_write.as_ref(),
9860                &projections,
9861                &native,
9862                &mut origin_landed,
9863            )
9864            .await
9865        } else {
9866            // An issue a create filed on the board itself has no board item this process
9867            // knows until `finish_writes` reads it, so nothing but a held-back write may land
9868            // on it before then.
9869            Err(SourceError::Malformed {
9870                message: format!(
9871                    "issue {} was written before the board item GitHub filed it as was read",
9872                    content_id.0
9873                ),
9874            })
9875        };
9876        // An existing item's title, body and state go last, in one `updateIssue`, once its board
9877        // fields and its relationships have landed: a refusal of any of those then leaves its
9878        // body — and the metadata slot inside it — exactly as it stood.
9879        let landed = match (landed, existing) {
9880            (Ok(()), Some(item)) => {
9881                self.update_existing(item, incoming, &body, status_target.as_ref())
9882                    .await
9883            }
9884            (landed, _) => landed,
9885        };
9886        if let Err(error) = landed {
9887            match existing {
9888                // Best effort, and the write's own failure is what the caller is told: a
9889                // refusal naming the tidy-up would hide why the write failed at all.
9890                None => {
9891                    let _ = self.delete_issue(&content_id).await;
9892                }
9893                // The origin field is the one piece of an existing item's metadata written
9894                // before its body, so a write refused after it puts it back as it was. When
9895                // that is refused too, the write's own failure is still what the caller is
9896                // told — with what it left behind added, because the item's metadata is then
9897                // not as it stood and a caller retrying has to know which key moved.
9898                Some(item) => {
9899                    let before = item.origin.as_deref().unwrap_or("");
9900                    if let Some(field) = origin_field.as_deref()
9901                        && before != origin
9902                        && let Err(restore) = self
9903                            .set_item_field(
9904                                board.id.as_str(),
9905                                &item.item_id,
9906                                field,
9907                                json!({"text": before}),
9908                            )
9909                            .await
9910                    {
9911                        let left = if origin_landed {
9912                            format!(
9913                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
9914                                 not be put back to {before:?} ({restore}), so item {} still \
9915                                 holds {origin:?} there",
9916                                item.id.0
9917                            )
9918                        } else {
9919                            format!(
9920                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
9921                                 {origin:?}, GitHub does not say whether that part of it ran, \
9922                                 and putting it back to {before:?} was refused ({restore}), so \
9923                                 item {} holds {origin:?} or {before:?} there",
9924                                item.id.0
9925                            )
9926                        };
9927                        return Err(noting(
9928                            error,
9929                            &format!(
9930                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
9931                                 run the write again"
9932                            ),
9933                        ));
9934                    }
9935                }
9936            }
9937            return Err(error);
9938        }
9939
9940        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
9941            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
9942                self.statuses
9943                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
9944            }
9945            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
9946                self.statuses
9947                    .status(kind, written_option.as_deref(), false, None)
9948            }
9949            (Some((_, status)), _) => status.clone(),
9950            (None, _) => Status {
9951                category: StatusCategory::Unknown,
9952                name: "Open".to_owned(),
9953            },
9954        };
9955
9956        // So the rest of this command reads what it just did rather than what the board
9957        // said before it. See `remember_written` for which half takes it.
9958        let remembered = Resolved {
9959            // Empty for an issue whose board item is not read yet: `finish_writes` reads it
9960            // and `learn_item` puts it here.
9961            // 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.
9962            item_id: item_id.unwrap_or_default(),
9963            id: content_id.clone(),
9964            content_kind,
9965            kind: incoming.written.kind(),
9966            title: incoming.title.to_owned(),
9967            // The visible half of the body this write composed, split back off it the
9968            // way a read splits it — so what this record reports is what a read of the
9969            // same issue reports, rather than the person's text with the metadata slot
9970            // still on the end of it.
9971            body: self.visible(body.clone())?.0,
9972            raw_body: body.clone(),
9973            // A document has no status of its own; what it reads back as is whatever
9974            // the issue's own state says, which is what a re-read reports.
9975            status: written_status,
9976            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
9977            priority: match incoming.priority {
9978                Some(priority) => HeldPriority::Read(priority),
9979                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
9980                    item.priority.clone()
9981                }),
9982            },
9983            // What `state_input` asked for: closed for a terminal target, open for any other
9984            // status, and the issue's own state left as it was by a document write.
9985            closed: content_kind == ContentKind::Issue
9986                && match status_target.as_ref() {
9987                    Some(StatusTarget::Terminal(_, _)) => true,
9988                    Some(_) => false,
9989                    None => existing.is_some_and(|item| item.closed),
9990                },
9991            reason: match status_target.as_ref() {
9992                Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
9993                    Some(reason.reason().to_owned())
9994                }
9995                Some(_) => None,
9996                None => existing.and_then(|item| item.reason.clone()),
9997            },
9998            delivers: incoming.delivers.to_vec(),
9999            delivered_by: incoming.delivered_by.to_vec(),
10000            labels: incoming.labels.to_vec(),
10001            parent: incoming.parent.cloned(),
10002            origin: (!origin.is_empty()).then(|| origin.to_owned()),
10003            projected,
10004            number,
10005            // In the update path this is the item's own url, read off `existing` where the
10006            // record above was bound, so one expression serves both halves.
10007            url,
10008            created_at: existing.and_then(|item| item.created_at),
10009            updated_at: existing.and_then(|item| item.updated_at),
10010            // What this write moved is GitHub's to say, so a record of it gives no token until
10011            // it is read again.
10012            item_updated_at: None,
10013            comment_count: None,
10014            relationship_events: None,
10015            served_at: None,
10016            facets: None,
10017            own_repository,
10018            repositories: incoming.repositories.to_vec(),
10019            classification: incoming.classification,
10020            slot,
10021            board_id: Some(board.id.as_str().to_owned()),
10022            fields: board
10023                .fields
10024                .get("nodes")
10025                .and_then(Value::as_array)
10026                .cloned()
10027                .unwrap_or_default(),
10028            board_fields: Some(board.fields.clone()),
10029            // What this write left the relationship holding is known by id alone, and a
10030            // later read of its edges needs each far end's kind, so it reads them again.
10031            blocked_by: None,
10032        };
10033        self.remember_written(remembered, existing.is_none())?;
10034        Ok(onetaskgraph_plugin_api::AssetsWritten {
10035            id: content_id,
10036            content,
10037        })
10038    }
10039
10040    /// Everything a write does after the item exists: its board fields, its parent, and
10041    /// its dependencies.
10042    ///
10043    /// Split out of `write_item` so there is one place a failure past the point of no
10044    /// return is caught, rather than a tidy-up repeated at each `?` above.
10045    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
10046    // so there is one place a failure past the point of no return is caught, and its
10047    // arguments are exactly the values that tail already had in scope. Bundling them into a
10048    // struct would describe no concept — it would be "the arguments of this function" — and
10049    // would put the whole of `write_item`'s locals behind one more indirection.
10050    #[allow(clippy::too_many_arguments)]
10051    async fn finish_write(
10052        &self,
10053        board_id: &str,
10054        incoming: &Incoming<'_>,
10055        content_id: &NativeId,
10056        item_id: &str,
10057        content_kind: ContentKind,
10058        existing: Option<&Resolved>,
10059        origin_field: Option<&str>,
10060        origin: &str,
10061        column: Option<(String, String)>,
10062        status_target: Option<&StatusTarget>,
10063        priority: Option<&PriorityWrite>,
10064        projections: &[ProjectionWrite],
10065        native: &[String],
10066        origin_landed: &mut bool,
10067    ) -> Result<(), SourceError> {
10068        let (fields, clears) = field_writes(
10069            existing,
10070            origin_field,
10071            origin,
10072            column,
10073            priority,
10074            projections,
10075        );
10076        self.set_item_fields(board_id, item_id, &fields, &clears)
10077            .await?;
10078        *origin_landed = true;
10079
10080        // An existing issue closes in the `updateIssue` its write ends with; one created just
10081        // now closes here, once its option is selected.
10082        if existing.is_none()
10083            && content_kind == ContentKind::Issue
10084            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
10085        {
10086            self.update_content(
10087                ContentKind::Issue,
10088                content_id,
10089                json!({"stateInput":state_input(status_target)}),
10090            )
10091            .await?;
10092        }
10093
10094        if content_kind == ContentKind::Issue {
10095            self.reparent(
10096                existing.and_then(|item| item.parent.clone()),
10097                content_id,
10098                incoming.parent,
10099            )
10100            .await?;
10101            // A document takes part in no dependency graph, so writing one neither reads
10102            // nor changes the issue's own `blockedBy` relationships. Reconciling them
10103            // against the empty list a document write carries would *delete* whatever
10104            // relationships a person had made on that issue, which is a write nobody
10105            // asked for.
10106            if incoming.written.kind() != BoardKind::Document {
10107                let issue = match existing {
10108                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
10109                    None => Issue::Created,
10110                };
10111                self.reconcile_blocked_by(content_id, native, issue).await?;
10112            }
10113        }
10114        Ok(())
10115    }
10116
10117    /// Whether this source is holding writes back — see [`TaskSource::begin_deferred_writes`].
10118    fn deferring(&self) -> Result<bool, SourceError> {
10119        Ok(self.deferred()?.is_some())
10120    }
10121
10122    /// Whether `content` is an item this command created whose parts are held back.
10123    fn holds_back(&self, content: &NativeId) -> Result<bool, SourceError> {
10124        Ok(self
10125            .deferred()?
10126            .as_ref()
10127            .is_some_and(|deferred| deferred.holds(content)))
10128    }
10129
10130    fn deferred(&self) -> Result<std::sync::MutexGuard<'_, Option<Deferred>>, SourceError> {
10131        self.deferred.lock().map_err(|_| SourceError::Unavailable {
10132            message: "this source's record of the writes it is holding back was left \
10133                      inconsistent by an earlier failure; next: run the command again"
10134                .into(),
10135        })
10136    }
10137
10138    /// Creates one issue filed on the board and under its project by the `createIssue` itself —
10139    /// `projectV2Ids` naming the board and `parentIssueId` the project — which is what a copy
10140    /// creates with: one request where adding it to the board and filing it under its project
10141    /// were two more. GitHub adds the board item after answering, so its id is not known here;
10142    /// [`TaskSource::finish_writes`] reads it.
10143    async fn create_filed_issue(
10144        &self,
10145        board_id: &str,
10146        repository: &RepositoryTarget,
10147        incoming: &Incoming<'_>,
10148        body: &Option<String>,
10149    ) -> Result<Landed, SourceError> {
10150        let repository_id = self.repository_id(repository, incoming).await?;
10151        let data = self
10152            .graphql(
10153                graphql::CREATE_ISSUE,
10154                json!({"input": filed_input(&repository_id, board_id, incoming, body)}),
10155            )
10156            .await?;
10157        let created = data
10158            .pointer("/createIssue/issue")
10159            .filter(|value| !value.is_null())
10160            .ok_or_else(|| SourceError::Malformed {
10161                message: "GitHub issue creation returned no issue".into(),
10162            })?;
10163        self.created_landed(created).await
10164    }
10165
10166    /// Keep the address `createIssue` answered for the issue `content`, for
10167    /// [`TaskSource::written_location`].
10168    fn note_written(&self, content: &NativeId, url: Option<&str>) {
10169        if let Some(url) = url {
10170            self.written
10171                .lock()
10172                .unwrap_or_else(std::sync::PoisonError::into_inner)
10173                .insert(content.clone(), url.to_owned());
10174        }
10175    }
10176
10177    /// What one `createIssue` answered, as the item it created — or, when its number cannot be
10178    /// read, the refusal, after taking the issue back.
10179    async fn created_landed(&self, created: &Value) -> Result<Landed, SourceError> {
10180        let content_id = NativeId(required_nonblank_str(created, "id")?.to_owned());
10181        let url = optional_str(created, "url")?.map(str::to_owned);
10182        let number = match created_issue_number(created) {
10183            Ok(number) => number,
10184            Err(error) => {
10185                let _ = self.delete_issue(&content_id).await;
10186                return Err(error);
10187            }
10188        };
10189        self.note_written(&content_id, url.as_deref());
10190        Ok(Landed {
10191            content_id,
10192            item_id: None,
10193            url,
10194            number,
10195        })
10196    }
10197
10198    /// Hold back everything a write of the created item `content` would send after creating it,
10199    /// for [`TaskSource::finish_writes`]: the same field values, closing and edges
10200    /// `finish_write` sends, the parent excepted, which its create already named. A later write
10201    /// of the same item replaces what it changes.
10202    // 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.
10203    #[allow(clippy::too_many_arguments)]
10204    fn hold_back(
10205        &self,
10206        board_id: &BoardId,
10207        content: &NativeId,
10208        existing: Option<&Resolved>,
10209        origin_field: Option<&str>,
10210        origin: &str,
10211        column: Option<(String, String)>,
10212        status_target: Option<&StatusTarget>,
10213        priority: Option<&PriorityWrite>,
10214        projections: &[ProjectionWrite],
10215        native: &[String],
10216    ) -> Result<(), SourceError> {
10217        let status = column.map(|(field, option)| {
10218            let name = self
10219                .fields_cache()
10220                .ok()
10221                .and_then(|fields| {
10222                    fields.as_ref().and_then(|board| {
10223                        option_name(&board.fields, &field, &option).map(str::to_owned)
10224                    })
10225                })
10226                .unwrap_or_default();
10227            (field, option, name)
10228        });
10229        let (fields, clears) =
10230            field_writes(existing, origin_field, origin, None, priority, projections);
10231        let close = match status_target {
10232            Some(StatusTarget::Terminal(_, reason)) => Some(*reason),
10233            _ => None,
10234        };
10235        let mut deferred = self.deferred()?;
10236        let deferred = deferred.get_or_insert_with(Deferred::default);
10237        if let Deferred::Nothing = deferred {
10238            *deferred = Deferred::Held {
10239                board: board_id.clone(),
10240                created: Vec::new(),
10241            };
10242        }
10243        let Deferred::Held { created, .. } = deferred else {
10244            unreachable!("held back on the line above");
10245        };
10246        let held = match created.iter().position(|held| held.content == *content) {
10247            Some(at) => &mut created[at],
10248            None => {
10249                created.push(HeldBack {
10250                    content: content.clone(),
10251                    fields: Vec::new(),
10252                    status: None,
10253                    clears: Vec::new(),
10254                    close: None,
10255                    edges: Vec::new(),
10256                });
10257                created.last_mut().expect("just held")
10258            }
10259        };
10260        for (field, value) in fields {
10261            held.fields.retain(|(held, _)| *held != field);
10262            held.clears.retain(|held| *held != field);
10263            held.fields.push((field, value));
10264        }
10265        for field in clears {
10266            held.fields.retain(|(held, _)| held != field);
10267            if !held.clears.iter().any(|held| held == field) {
10268                held.clears.push(field.to_owned());
10269            }
10270        }
10271        if status.is_some() {
10272            held.status = status;
10273        }
10274        if close.is_some() {
10275            held.close = close;
10276        }
10277        // Nothing of an item created in this command has been sent but its create, so the edges
10278        // it is to have are exactly the ones its latest write names.
10279        held.edges = native.iter().map(|far| NativeId(far.clone())).collect();
10280        Ok(())
10281    }
10282
10283    /// Create every task of `writes`, together, filed on the board and under each one's
10284    /// project: [`graphql::CREATE_ISSUES`], [`MUTATION_BATCH`] to a request.
10285    ///
10286    /// Each is prepared as [`Self::write_item`] prepares one — every refusal a write owes made
10287    /// before the first request — then created, then completed with its parts held back. A
10288    /// request refused part way leaves the issues it did create, which are taken back, with the
10289    /// rest of this call's, before the refusal is answered.
10290    async fn create_together(
10291        &self,
10292        writes: &[ItemWrite<Task>],
10293    ) -> Result<Vec<NativeId>, SourceError> {
10294        let incomings: Vec<Incoming<'_>> = writes
10295            .iter()
10296            .map(|write| self.task_incoming(write, None))
10297            .collect::<Result<_, _>>()?;
10298        let mut prepared = Vec::with_capacity(writes.len());
10299        for (incoming, write) in incomings.iter().zip(writes) {
10300            if write.target.is_some() {
10301                return Err(SourceError::Malformed {
10302                    message: "a write naming its target was handed to a batch of creates".into(),
10303                });
10304            }
10305            prepared.push(
10306                self.prepare_write(incoming, None, &write.depends_on)
10307                    .await?,
10308            );
10309        }
10310        let mut inputs = Vec::with_capacity(prepared.len());
10311        for (incoming, prepared) in incomings.iter().zip(&prepared) {
10312            let WriteDestination::New(target) = &prepared.destination else {
10313                return Err(SourceError::Malformed {
10314                    message: "an update was decided for a write handed to a batch of creates"
10315                        .into(),
10316                });
10317            };
10318            let repository_id = self.repository_id(target, incoming).await?;
10319            inputs.push(filed_input(
10320                &repository_id,
10321                prepared.board.id.as_str(),
10322                incoming,
10323                &prepared.body,
10324            ));
10325        }
10326        let mut landed: Vec<Landed> = Vec::with_capacity(inputs.len());
10327        let refused = 'create: {
10328            for chunk in inputs.chunks(MUTATION_BATCH) {
10329                let mut variables = serde_json::Map::new();
10330                for slot in 0..MUTATION_BATCH {
10331                    variables.insert(
10332                        format!("create{slot}"),
10333                        chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
10334                    );
10335                    variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
10336                }
10337                let data = match self
10338                    .graphql(graphql::CREATE_ISSUES, Value::Object(variables))
10339                    .await
10340                {
10341                    Ok(data) => data,
10342                    Err(error) => break 'create Some(error),
10343                };
10344                let mut refusal = data.get(ANSWER_ERRORS).map(|errors| {
10345                    let said: Vec<&str> = errors
10346                        .as_array()
10347                        .into_iter()
10348                        .flatten()
10349                        .filter_map(|error| error.get("message").and_then(Value::as_str))
10350                        .collect();
10351                    SourceError::Refused {
10352                        message: if said.is_empty() {
10353                            "GitHub returned GraphQL errors".to_owned()
10354                        } else {
10355                            said.join("; ")
10356                        },
10357                    }
10358                });
10359                for slot in 0..chunk.len() {
10360                    match data
10361                        .pointer(&format!("/create{slot}/issue"))
10362                        .filter(|value| !value.is_null())
10363                    {
10364                        Some(created) => match self.created_landed(created).await {
10365                            Ok(created) => landed.push(created),
10366                            Err(error) => {
10367                                refusal.get_or_insert(error);
10368                            }
10369                        },
10370                        // An alias GitHub refused answers nothing, and the refusal says why.
10371                        None => {
10372                            refusal.get_or_insert(SourceError::Malformed {
10373                                message: "GitHub issue creation returned no issue".into(),
10374                            });
10375                        }
10376                    }
10377                }
10378                if let Some(error) = refusal {
10379                    break 'create Some(error);
10380                }
10381            }
10382            None
10383        };
10384        if let Some(error) = refused {
10385            // None of these ids reaches the caller, so an issue GitHub will not take back is
10386            // named here or by nobody.
10387            let ids: Vec<&NativeId> = landed.iter().map(|created| &created.content_id).collect();
10388            let left = self.delete_issues(&ids).await;
10389            let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
10390            return Err(left_behind(error, &left));
10391        }
10392        let created: Vec<NativeId> = landed
10393            .iter()
10394            .map(|landed| landed.content_id.clone())
10395            .collect();
10396        let mut ids = Vec::with_capacity(landed.len());
10397        for ((incoming, prepared), landed) in incomings.iter().zip(prepared).zip(landed) {
10398            match self.complete_write(incoming, prepared, landed).await {
10399                Ok(written) => ids.push(written.id),
10400                // 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.
10401                Err(error) => {
10402                    let ids: Vec<&NativeId> = created.iter().collect();
10403                    let left = self.delete_issues(&ids).await;
10404                    let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
10405                    return Err(left_behind(error, &left));
10406                }
10407            }
10408        }
10409        Ok(ids)
10410    }
10411
10412    /// Hold back the update of the existing issue `issue` — `members`, what its own
10413    /// [`graphql::UPDATE_ISSUE`] would send beside its id — while writes are being held back — so the updates a copy makes one after
10414    /// another go to GitHub as one request. Whatever this source held of the issue is dropped
10415    /// as the mutation itself would drop it. Answers whether it was held: never while a write
10416    /// the store's cache expects runs, which records each mutation's own answer, and never for
10417    /// an issue an update is already held for, which that one is sent ahead of.
10418    fn hold_update(&self, issue: &NativeId, members: &Value) -> Result<bool, SourceError> {
10419        if self.expected.journaling() || self.deferred()?.is_none() {
10420            return Ok(false);
10421        }
10422        let mut held = self
10423            .held_updates
10424            .lock()
10425            .map_err(|_| SourceError::Unavailable {
10426                message: "issue updates held back were left inconsistent; run the command again"
10427                    .into(),
10428            })?;
10429        if held.iter().any(|earlier| earlier.issue == *issue) {
10430            return Ok(false);
10431        }
10432        let named = |value: &str| issue.0 == value;
10433        self.resolved_cache()?
10434            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
10435        self.read_ahead()?.retain(|id, item| {
10436            !named(&id.0) && !item.as_ref().is_some_and(|item| named(&item.item_id))
10437        });
10438        self.carried()?
10439            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
10440        held.push(HeldUpdate::new(issue, members));
10441        Ok(true)
10442    }
10443
10444    /// Send every update [`Self::hold_update`] held back: one alone as [`graphql::UPDATE_ISSUE`]
10445    /// sends it, several as aliased `updateIssue`s of [`graphql::UPDATE_ISSUES`],
10446    /// [`MUTATION_BATCH`] to a request. Each counts one write, as it would alone.
10447    async fn send_held_updates(&self) -> Result<(), SourceError> {
10448        let held = std::mem::take(&mut *self.held_updates.lock().map_err(|_| {
10449            SourceError::Unavailable {
10450                message: "issue updates held back were left inconsistent; run the command again"
10451                    .into(),
10452            }
10453        })?);
10454        if let [one] = held.as_slice() {
10455            return self
10456                .send_update(
10457                    graphql::UPDATE_ISSUE,
10458                    "/updateIssue/issue",
10459                    &one.issue,
10460                    one.input.clone(),
10461                )
10462                .await;
10463        }
10464        for chunk in held.chunks(MUTATION_BATCH) {
10465            let mut variables = serde_json::Map::new();
10466            for slot in 0..MUTATION_BATCH {
10467                variables.insert(
10468                    format!("update{slot}"),
10469                    chunk.get(slot).unwrap_or(&chunk[0]).input.clone(),
10470                );
10471                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
10472            }
10473            let data = self
10474                .graphql(graphql::UPDATE_ISSUES, Value::Object(variables))
10475                .await?;
10476            let inputs: Vec<&Value> = chunk.iter().map(|held| &held.input).collect();
10477            check_aliased_answers(
10478                graphql::UPDATE_ISSUES,
10479                &data,
10480                "update",
10481                &inputs,
10482                &[("issue", "id")],
10483            )?;
10484        }
10485        Ok(())
10486    }
10487
10488    /// Send everything held back since [`TaskSource::begin_deferred_writes`]: one read of the
10489    /// created items' board items, then their field values and edges together in requests of
10490    /// [`WRITE_BATCH`], then their closes in requests of [`MUTATION_BATCH`]. Anything refused
10491    /// takes back every issue held, [`DELETE_BATCH`] to a request, and answers the refusal.
10492    async fn send_held_back(&self) -> Result<(), SourceError> {
10493        let Some(Deferred::Held { board, created }) = self.deferred()?.take() else {
10494            return Ok(());
10495        };
10496        if created.is_empty() {
10497            return Ok(());
10498        }
10499        let sent = self.send_deferred(board.as_str(), &created).await;
10500        if let Err(error) = sent {
10501            // Best effort, and the failure is what the caller is told, as a write of one item
10502            // that fails part way takes back what it created: the copy that held these back
10503            // journalled every one, so one this cannot take back its undo tries again, and says
10504            // so when it cannot either.
10505            let ids: Vec<&NativeId> = created.iter().map(|held| &held.content).collect();
10506            let _ = self.delete_issues(&ids).await;
10507            return Err(error);
10508        }
10509        Ok(())
10510    }
10511
10512    /// The requests [`Self::send_held_back`] makes.
10513    async fn send_deferred(&self, board: &str, created: &[HeldBack]) -> Result<(), SourceError> {
10514        let items = self.created_board_items(board, created).await?;
10515        let mut fields: Vec<Value> = Vec::new();
10516        let mut closes: Vec<Value> = Vec::new();
10517        let mut edges: Vec<Value> = Vec::new();
10518        for held in created {
10519            let (item, status) = &items[&held.content];
10520            let mut values = held.fields.clone();
10521            // A status the board already shows — set by a workflow a person turned on to file
10522            // new items at it — is not written again.
10523            if let Some((field, option, name)) = &held.status
10524                && status.as_deref() != Some(name.as_str())
10525            {
10526                values.insert(0, (field.clone(), json!({"singleSelectOptionId": option})));
10527            }
10528            for (field, value) in values {
10529                fields.push(json!({"projectId":board,"itemId":item,"fieldId":field,"value":value}));
10530            }
10531            if let Some(reason) = held.close {
10532                closes.push(json!({"id": held.content.0, "stateInput": closed_input(reason)}));
10533            }
10534            for far in &held.edges {
10535                edges.push(json!({"issueId": held.content.0, "blockingIssueId": far.0}));
10536            }
10537            if !held.clears.is_empty() {
10538                let clears: Vec<&str> = held.clears.iter().map(String::as_str).collect();
10539                self.set_item_fields(board, item, &[], &clears).await?;
10540            }
10541        }
10542        // Each alias answers with what it wrote, which is what its input named: the board item a
10543        // field value is set on, the issue closed, the issue blocked and the one blocking it.
10544        let field_answer: &[(&str, &str)] = &[("projectV2Item", "itemId")];
10545        let close_answer: &[(&str, &str)] = &[("issue", "id")];
10546        let block_answer: &[(&str, &str)] =
10547            &[("issue", "issueId"), ("blockingIssue", "blockingIssueId")];
10548        // Every field value, then every edge, as one ordered list sent `WRITE_BATCH` aliases to a
10549        // request: a request holding both kinds is the one document carrying both, so a plan's
10550        // values and edges cost one request where they fit in one.
10551        let writes: Vec<HeldWrite<'_>> = fields
10552            .iter()
10553            .map(HeldWrite::Field)
10554            .chain(edges.iter().map(HeldWrite::Edge))
10555            .collect();
10556        for chunk in writes.chunks(WRITE_BATCH) {
10557            let values: Vec<&Value> = chunk
10558                .iter()
10559                .filter_map(|write| match write {
10560                    HeldWrite::Field(input) => Some(*input),
10561                    HeldWrite::Edge(_) => None,
10562                })
10563                .collect();
10564            let blocks: Vec<&Value> = chunk
10565                .iter()
10566                .filter_map(|write| match write {
10567                    HeldWrite::Edge(input) => Some(*input),
10568                    HeldWrite::Field(_) => None,
10569                })
10570                .collect();
10571            let (document, sends) = match (values.is_empty(), blocks.is_empty()) {
10572                (false, false) => (graphql::WRITE_FIELDS_AND_EDGES, ["sendField", "sendBlock"]),
10573                (false, true) => (graphql::WRITE_FIELD_VALUES, ["send", "send"]),
10574                _ => (graphql::ADD_BLOCKED_BYS, ["send", "send"]),
10575            };
10576            let mut variables = serde_json::Map::new();
10577            let kinds = [
10578                ("field", &values, sends[0], field_answer),
10579                ("block", &blocks, sends[1], block_answer),
10580            ];
10581            for (prefix, inputs, send, _) in &kinds {
10582                if inputs.is_empty() {
10583                    continue;
10584                }
10585                for slot in 0..WRITE_BATCH {
10586                    variables.insert(
10587                        format!("{prefix}{slot}"),
10588                        (*inputs.get(slot).unwrap_or(&inputs[0])).clone(),
10589                    );
10590                    variables.insert(format!("{send}{slot}"), json!(slot < inputs.len()));
10591                }
10592            }
10593            let data = self.graphql(document, Value::Object(variables)).await?;
10594            for (prefix, inputs, _, answers) in kinds {
10595                check_aliased_answers(document, &data, prefix, inputs, answers)?;
10596            }
10597        }
10598        for chunk in closes.chunks(MUTATION_BATCH) {
10599            let mut variables = serde_json::Map::new();
10600            for slot in 0..MUTATION_BATCH {
10601                variables.insert(
10602                    format!("close{slot}"),
10603                    chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
10604                );
10605                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
10606            }
10607            let data = self
10608                .graphql(graphql::CLOSE_ISSUES, Value::Object(variables))
10609                .await?;
10610            let inputs: Vec<&Value> = chunk.iter().collect();
10611            check_aliased_answers(graphql::CLOSE_ISSUES, &data, "close", &inputs, close_answer)?;
10612        }
10613        for held in created {
10614            self.learn_item(&held.content, &items[&held.content].0)?;
10615        }
10616        Ok(())
10617    }
10618
10619    /// The board item each of `created` is on this board, with its `Status` as the board set it:
10620    /// [`graphql::CREATED_BOARD_ITEMS`], read again — after a wait of a second, then two, four,
10621    /// eight and sixteen — for only those GitHub has not filed yet.
10622    ///
10623    /// **Membership is never assumed.** `createIssue`'s `projectV2Ids` files an issue some time
10624    /// after GitHub answered the create — the feasibility spike saw 0.45 to 1.84 seconds, and no
10625    /// create answer carrying the item — so only a read says it is there. One still absent once
10626    /// those reads are spent is filed with [`graphql::ADD_TO_BOARD`], one write each; GitHub
10627    /// filing it meanwhile refuses that "Content already exists in this project", which says it
10628    /// is placed, and one more read finds the item rather than a second one being made.
10629    async fn created_board_items(
10630        &self,
10631        board: &str,
10632        created: &[HeldBack],
10633    ) -> Result<BTreeMap<NativeId, (String, Option<String>)>, SourceError> {
10634        let mut found: BTreeMap<NativeId, (String, Option<String>)> = BTreeMap::new();
10635        let unfiled = |found: &BTreeMap<NativeId, (String, Option<String>)>| -> Vec<NativeId> {
10636            created
10637                .iter()
10638                .map(|held| held.content.clone())
10639                .filter(|id| !found.contains_key(id))
10640                .collect()
10641        };
10642        let mut wait = Duration::from_secs(1);
10643        for attempt in 0..6 {
10644            let absent = unfiled(&found);
10645            if absent.is_empty() {
10646                return Ok(found);
10647            }
10648            if attempt > 0 {
10649                self.clock
10650                    .sleep_because(wait, pacing::cause::BOARD_FILING)
10651                    .await;
10652                wait = wait.saturating_mul(2);
10653            }
10654            self.read_created_items(board, &absent, &mut found).await?;
10655        }
10656        let mut placed_meanwhile = Vec::new();
10657        for id in unfiled(&found) {
10658            match self
10659                .graphql(
10660                    graphql::ADD_TO_BOARD,
10661                    json!({"input":{"projectId":board,"contentId":id.0}}),
10662                )
10663                .await
10664            {
10665                Ok(added) => {
10666                    let item = added
10667                        .pointer("/addProjectV2ItemById/item")
10668                        .filter(|value| !value.is_null())
10669                        .ok_or_else(|| SourceError::Malformed {
10670                            message: "GitHub board addition returned no project item".into(),
10671                        })?;
10672                    found.insert(id, (required_nonblank_str(item, "id")?.to_owned(), None));
10673                }
10674                Err(error) if already_on_the_board(&error) => placed_meanwhile.push(id),
10675                Err(error) => return Err(error),
10676            }
10677        }
10678        if !placed_meanwhile.is_empty() {
10679            self.read_created_items(board, &placed_meanwhile, &mut found)
10680                .await?;
10681        }
10682        let absent = unfiled(&found);
10683        if absent.is_empty() {
10684            return Ok(found);
10685        }
10686        let absent: Vec<&str> = absent.iter().map(|id| id.0.as_str()).collect();
10687        Err(SourceError::Unavailable {
10688            message: format!(
10689                "GitHub created {} filed on board {board} and, after 31 seconds and adding {} \
10690                 to it, still answers {} on no item of it: {}; next: run the copy again once \
10691                 GitHub has caught up",
10692                if absent.len() == 1 {
10693                    "an issue"
10694                } else {
10695                    "issues"
10696                },
10697                if absent.len() == 1 { "it" } else { "them" },
10698                if absent.len() == 1 { "it" } else { "them" },
10699                absent.join(", ")
10700            ),
10701        })
10702    }
10703
10704    /// One [`graphql::CREATED_BOARD_ITEMS`] read of each of `ids`, [`COPY_DETAIL_BATCH`] to a
10705    /// request, adding to `found` the board item of every one GitHub has filed on `board`.
10706    async fn read_created_items(
10707        &self,
10708        board: &str,
10709        ids: &[NativeId],
10710        found: &mut BTreeMap<NativeId, (String, Option<String>)>,
10711    ) -> Result<(), SourceError> {
10712        for chunk in ids.chunks(COPY_DETAIL_BATCH) {
10713            let mut variables = serde_json::Map::new();
10714            for slot in 0..COPY_DETAIL_BATCH {
10715                variables.insert(
10716                    format!("id{slot}"),
10717                    json!(chunk.get(slot).unwrap_or(&chunk[0]).0),
10718                );
10719                variables.insert(format!("read{slot}"), json!(slot < chunk.len()));
10720            }
10721            variables.insert("boardItems".into(), json!(BOARD_ITEMS_PAGE_SIZE));
10722            variables.insert("statusName".into(), json!(STATUS_FIELD));
10723            let data = self
10724                .graphql(graphql::CREATED_BOARD_ITEMS, Value::Object(variables))
10725                .await?;
10726            for (slot, id) in chunk.iter().enumerate() {
10727                // An answer for another issue than the one asked about says nothing of
10728                // where that one is filed, and its board item is not this one's.
10729                let node = data.get(format!("i{slot}")).filter(|node| !node.is_null());
10730                let named = node.map(|node| node.get("id").and_then(Value::as_str));
10731                if let Some(named) = named
10732                    && named != Some(id.0.as_str())
10733                {
10734                    return Err(SourceError::Malformed {
10735                        message: format!(
10736                            "GitHub answered the board items of {} when asked for those of {}",
10737                            named.unwrap_or("an issue it did not name"),
10738                            id.0
10739                        ),
10740                    });
10741                }
10742                // An issue GitHub does not answer yet is not filed yet; one it answers is
10743                // read whole, its connection, each board item's board and its status, and
10744                // any of them unreadable is refused rather than read as not filed.
10745                let Some(node) = node else { continue };
10746                let malformed = |what: &str| SourceError::Malformed {
10747                    message: format!("GitHub answered the board items of {} with {what}", id.0),
10748                };
10749                let nodes = node
10750                    .pointer("/projectItems/nodes")
10751                    .and_then(Value::as_array)
10752                    .ok_or_else(|| malformed("no list of board items"))?;
10753                let mut item = None;
10754                for node in nodes {
10755                    let on = node
10756                        .pointer("/project/id")
10757                        .and_then(Value::as_str)
10758                        .ok_or_else(|| malformed("a board item naming no board"))?;
10759                    if on == board {
10760                        item = Some(node);
10761                    }
10762                }
10763                if let Some(item) = item {
10764                    let status = match item.get("status") {
10765                        None | Some(Value::Null) => None,
10766                        Some(status) => Some(
10767                            status
10768                                .get("name")
10769                                .and_then(Value::as_str)
10770                                .ok_or_else(|| malformed("a status that names no option"))?
10771                                .to_owned(),
10772                        ),
10773                    };
10774                    found.insert(
10775                        id.clone(),
10776                        (required_nonblank_str(item, "id")?.to_owned(), status),
10777                    );
10778                }
10779            }
10780        }
10781        Ok(())
10782    }
10783
10784    /// The board item id of `content`, which this process created and remembered without one,
10785    /// in every half of its own view.
10786    fn learn_item(&self, content: &NativeId, item: &str) -> Result<(), SourceError> {
10787        if let Some(held) = self.resolved_cache()?.get_mut(content) {
10788            held.item_id = item.to_owned();
10789        }
10790        if let Some(held) = self.created()?.iter_mut().find(|held| held.id == *content) {
10791            held.item_id = item.to_owned();
10792        }
10793        Ok(())
10794    }
10795
10796    /// Ask together which items each of `origins` names, and what creating an item reads; see
10797    /// [`TaskSource::read_ahead_origins_for_copy`]. An origin whose answer runs past its first
10798    /// page is left to its own lookup.
10799    async fn origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
10800        if self.repository.is_none() {
10801            return Ok(());
10802        }
10803        let unasked: Vec<&String> = {
10804            let held = self.narrowed_cache()?;
10805            let mut unasked: Vec<&String> = Vec::new();
10806            for origin in origins {
10807                if !held.contains_key(&Narrowing::Origin(origin.clone()).key())
10808                    && !unasked.contains(&origin)
10809                {
10810                    unasked.push(origin);
10811                }
10812            }
10813            unasked
10814        };
10815        if unasked.is_empty() {
10816            return Ok(());
10817        }
10818        // One batch is held back for the read ahead that follows, which may carry a project's
10819        // members beside it in the same request.
10820        if unasked.len() <= ORIGIN_BATCH {
10821            let mut pending =
10822                self.pending_origins
10823                    .lock()
10824                    .map_err(|_| SourceError::Unavailable {
10825                        message:
10826                            "origins held for a read ahead were left inconsistent; run the command \
10827                          again"
10828                                .into(),
10829                    })?;
10830            for origin in unasked {
10831                if !pending.contains(origin) {
10832                    pending.push(origin.clone());
10833                }
10834            }
10835            return Ok(());
10836        }
10837        let unasked: Vec<String> = unasked.into_iter().cloned().collect();
10838        self.send_origin_lookups(&unasked, None).await
10839    }
10840
10841    /// Hold, for [`TaskSource::member_classifications`] of `project`, each member's
10842    /// classification and repositories as the members page `data` carries them — when that page
10843    /// is every member, each one names `project` as its parent, and every record it holds is
10844    /// readable. Any other page holds nothing, so the members are read as they always were.
10845    fn hold_member_classes(&self, project: &NativeId, data: &Value) -> Result<(), SourceError> {
10846        let Some(page) = data
10847            .pointer("/members/subIssues")
10848            .filter(|page| !page.is_null())
10849        else {
10850            return Ok(());
10851        };
10852        if page
10853            .pointer("/pageInfo/hasNextPage")
10854            .and_then(Value::as_bool)
10855            != Some(false)
10856        {
10857            return Ok(());
10858        }
10859        let Some(nodes) = page.get("nodes").and_then(Value::as_array) else {
10860            return Ok(());
10861        };
10862        let mut members = Vec::with_capacity(nodes.len());
10863        for node in nodes {
10864            if node.pointer("/parent/id").and_then(Value::as_str) != Some(project.0.as_str()) {
10865                return Ok(());
10866            }
10867            let Some(body) = node.get("body").and_then(Value::as_str) else {
10868                return Ok(());
10869            };
10870            // Every node here is filed under `project`, which `BoardKind::of` reads as one of its
10871            // tasks whatever its marker says, so each is a member the full read would classify.
10872            let Ok((_, slot)) = metadata_body(Some(body.to_owned())) else {
10873                return Ok(());
10874            };
10875            let Ok(classification) = Classification::from_metadata(&slot) else {
10876                return Ok(());
10877            };
10878            let repositories = if slot.contains_key(Repository::METADATA_KEY) {
10879                let Ok(repositories) = Repository::from_metadata(&slot) else {
10880                    return Ok(());
10881                };
10882                repositories
10883            } else {
10884                let Some(origin) = node
10885                    .pointer("/repository/nameWithOwner")
10886                    .and_then(Value::as_str)
10887                else {
10888                    return Ok(());
10889                };
10890                let Ok(own) = Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST))
10891                else {
10892                    return Ok(());
10893                };
10894                vec![own]
10895            };
10896            members.push((classification, repositories));
10897        }
10898        self.member_classes
10899            .lock()
10900            .map_err(|_| SourceError::Unavailable {
10901                message: "a project's members held for its classification were left \
10902                          inconsistent; run the command again"
10903                    .into(),
10904            })?
10905            .insert(project.clone(), members);
10906        Ok(())
10907    }
10908
10909    /// Send every origin held back for the read ahead, beside the members of `members` when it
10910    /// names a project — or nothing, when none is held.
10911    async fn send_pending_origins(&self, members: Option<&NativeId>) -> Result<(), SourceError> {
10912        let pending = std::mem::take(&mut *self.pending_origins.lock().map_err(|_| {
10913            SourceError::Unavailable {
10914                message: "origins held for a read ahead were left inconsistent; run the command \
10915                          again"
10916                    .into(),
10917            }
10918        })?);
10919        if pending.is_empty() {
10920            return Ok(());
10921        }
10922        self.send_origin_lookups(&pending, members).await
10923    }
10924
10925    /// [`graphql::ORIGIN_LOOKUPS`] of `unasked`, [`ORIGIN_BATCH`] to a request — the first one
10926    /// [`graphql::ORIGIN_LOOKUPS_WITH_MEMBERS`], carrying the members of `members`, when it names
10927    /// a project — holding each origin's answer and, from a members page that is the whole of
10928    /// them, each member's classification.
10929    async fn send_origin_lookups(
10930        &self,
10931        unasked: &[String],
10932        members: Option<&NativeId>,
10933    ) -> Result<(), SourceError> {
10934        let Some(repository) = self.repository.clone() else {
10935            return Ok(());
10936        };
10937        let mut members = members;
10938        for chunk in unasked.chunks(ORIGIN_BATCH) {
10939            let mut variables = json!({"owner":self.owner,"number":self.project_number,
10940                "type":"ISSUE","lookupFirst":ORIGIN_LOOKUPS_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
10941                "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true,
10942                "repositoryOwner":repository.owner,"repositoryName":repository.name});
10943            for slot in 0..ORIGIN_BATCH {
10944                let origin = chunk.get(slot).unwrap_or(&chunk[0]);
10945                variables[format!("filter{slot}")] =
10946                    json!(format!("{ORIGIN_FIELD}:{}", quoted(origin)));
10947                variables[format!("search{slot}")] = json!(
10948                    self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))))
10949                );
10950                variables[format!("ask{slot}")] = json!(slot < chunk.len());
10951            }
10952            let project = members.take();
10953            let document = match project {
10954                Some(project) => {
10955                    variables["project"] = json!(project.0);
10956                    variables["memberFirst"] = json!(MEMBER_PAGE_SIZE);
10957                    graphql::ORIGIN_LOOKUPS_WITH_MEMBERS
10958                }
10959                None => graphql::ORIGIN_LOOKUPS,
10960            };
10961            let data = self.graphql(document, variables).await?;
10962            self.fields_read(&data)?;
10963            if let Some(project) = project {
10964                self.hold_member_classes(project, &data)?;
10965            }
10966            if let Some(node) = data.get("repository").filter(|value| !value.is_null()) {
10967                let id = required_nonblank_str(node, "id")?.to_owned();
10968                self.repository_cache()?.insert(repository.clone(), id);
10969                if let Some(numeric) = node
10970                    .get("databaseId")
10971                    .and_then(Value::as_u64)
10972                    .and_then(std::num::NonZeroU64::new)
10973                {
10974                    self.numeric_repositories
10975                        .lock()
10976                        .await
10977                        .insert(repository.clone(), numeric);
10978                }
10979            }
10980            for (slot, origin) in chunk.iter().enumerate() {
10981                let items = data
10982                    .pointer(&format!("/o{slot}/projectV2/items"))
10983                    .filter(|value| !value.is_null())
10984                    .ok_or_else(|| SourceError::Refused {
10985                        message: format!(
10986                            "GitHub project {}/{} was not found or is not visible to the token",
10987                            self.owner, self.project_number
10988                        ),
10989                    })?;
10990                let searched =
10991                    data.get(format!("s{slot}"))
10992                        .ok_or_else(|| SourceError::Malformed {
10993                            message: "GitHub search response has no search connection".into(),
10994                        })?;
10995                if resumed(items, None)?.has_more() || resumed(searched, None)?.has_more() {
10996                    continue;
10997                }
10998                let mut found: Vec<Resolved> = Vec::new();
10999                for item in optional_nodes(Some(items), "project items")?
11000                    .into_iter()
11001                    .flatten()
11002                {
11003                    if let Some(resolved) = self.resolve(item)?
11004                        && resolved.content_kind == ContentKind::Issue
11005                        && !found.iter().any(|held| held.id == resolved.id)
11006                    {
11007                        found.push(resolved);
11008                    }
11009                }
11010                for node in optional_nodes(Some(searched), "search")?
11011                    .into_iter()
11012                    .flatten()
11013                {
11014                    if let Some(resolved) = self.resolve_issue(node).await?
11015                        && !found.iter().any(|held| held.id == resolved.id)
11016                    {
11017                        found.push(resolved);
11018                    }
11019                }
11020                self.narrowed_cache()?
11021                    .insert(Narrowing::Origin((*origin).clone()).key(), found);
11022            }
11023        }
11024        Ok(())
11025    }
11026
11027    /// Delete every issue of `ids`, [`DELETE_BATCH`] to a request with
11028    /// [`graphql::DELETE_ISSUES`], and answer those still there: each a request's answer does not
11029    /// confirm, every one of a request refused whole included.
11030    async fn delete_issues(&self, ids: &[&NativeId]) -> Vec<NativeId> {
11031        let mut left = Vec::new();
11032        for chunk in ids.chunks(DELETE_BATCH) {
11033            let mut variables = serde_json::Map::new();
11034            for slot in 0..DELETE_BATCH {
11035                let id = chunk.get(slot).unwrap_or(&chunk[0]);
11036                variables.insert(format!("delete{slot}"), json!({"issueId": id.0}));
11037                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11038            }
11039            let data = self
11040                .graphql(graphql::DELETE_ISSUES, Value::Object(variables))
11041                .await
11042                .unwrap_or(Value::Null);
11043            for (slot, id) in chunk.iter().enumerate() {
11044                // Confirmed only by the repository the deleted issue was in, named by a
11045                // non-blank id: anything else says nothing of whether the issue is gone.
11046                let confirmed = data
11047                    .pointer(&format!("/delete{slot}/repository/id"))
11048                    .and_then(Value::as_str)
11049                    .is_some_and(|id| !id.trim().is_empty());
11050                if !confirmed {
11051                    left.push((*id).clone());
11052                }
11053            }
11054        }
11055        left
11056    }
11057
11058    /// Delete one issue, which takes its board item with it.
11059    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
11060        let data = self
11061            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11062            .await?;
11063        data.pointer("/deleteIssue/repository")
11064            .filter(|value| !value.is_null())
11065            .ok_or_else(|| SourceError::Malformed {
11066                message: "GitHub issue deletion returned no repository".into(),
11067            })?;
11068        self.forget(id)?;
11069        Ok(())
11070    }
11071
11072    /// Remove one item this copy created, so a copy that could not finish leaves the board
11073    /// as it found it.
11074    ///
11075    /// Deleting the issue takes its board item with it, so there is no second mutation to
11076    /// keep in step. An id the board does not hold is not an error: the item is already
11077    /// gone, which is the state this asks for. Which that is, is decided by reading the item
11078    /// by its own id — a listing of the board can still be missing an item it holds, and
11079    /// reading that as *already gone* would leave behind the very item this was asked to
11080    /// take back.
11081    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
11082        let Some(item) = self.bound_item(id).await? else {
11083            return Ok(());
11084        };
11085        if item.content_kind == ContentKind::DraftIssue {
11086            return Err(SourceError::Refused {
11087                message: format!(
11088                    "GitHub item {} is a draft, and this source removes an item by deleting \
11089                     its issue; next: remove it from the board by hand",
11090                    id.0
11091                ),
11092            });
11093        }
11094        let data = self
11095            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11096            .await?;
11097        data.pointer("/deleteIssue/repository")
11098            .filter(|value| !value.is_null())
11099            .ok_or_else(|| SourceError::Malformed {
11100                message: "GitHub issue deletion returned no repository".into(),
11101            })?;
11102        self.forget(id)?;
11103        Ok(())
11104    }
11105
11106    /// The issue a comment call on `task` is about, or `None` when this board holds no such
11107    /// task.
11108    ///
11109    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
11110    /// read of the task cannot disagree about which ids name one: a project or a document of
11111    /// this board is not a task here either.
11112    ///
11113    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
11114    /// issues and a draft is not one. It is refused rather than answered with an empty page,
11115    /// which would read as a task nobody has commented on yet.
11116    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
11117        let cached = self.resolved_cache()?.get(task).cloned();
11118        let Some(item) = (match cached {
11119            Some(item) => Some(item),
11120            None => self.item_by_id(task).await?,
11121        })
11122        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11123            return Ok(None);
11124        };
11125        if item.content_kind == ContentKind::DraftIssue {
11126            return Err(self.draft_has_no_comments(task));
11127        }
11128        Ok(Some(item.id))
11129    }
11130
11131    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
11132    /// issues, and a draft is not one.
11133    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
11134        SourceError::Refused {
11135            message: format!(
11136                "task {} of source {} is a draft item on the board, and GitHub keeps \
11137                 comments on issues alone, so a draft has none to read or write; next: \
11138                 convert the draft to an issue on the board, then comment on the issue it \
11139                 becomes",
11140                task.0, self.name
11141            ),
11142        }
11143    }
11144
11145    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
11146    /// request — or `None` when this board holds no task by that id.
11147    ///
11148    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
11149    /// is answered with the draft and the refusal, at the price of the draft's own read.
11150    async fn issue_detail(
11151        &self,
11152        id: &NativeId,
11153        page: &PageRequest,
11154    ) -> Result<Option<TaskDetailRead>, SourceError> {
11155        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
11156        let asked = self
11157            .graphql(
11158                graphql::ISSUE_DETAIL,
11159                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
11160                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
11161                       "duplicates":true}),
11162            )
11163            .await;
11164        let data = match asked {
11165            Ok(data) => data,
11166            Err(error) if unresolvable_node(&error) => return Ok(None),
11167            Err(error) => return Err(error),
11168        };
11169        // `node` is null for an id that names nothing, and absent only from an answer this
11170        // source cannot read — never the same thing.
11171        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
11172            message: format!("GitHub answered the read of {} with no node", id.0),
11173        })?;
11174        self.detail_of(id, node, true, after).await
11175    }
11176
11177    /// Several tasks, each with the first page of its comments when `comments` is set, read
11178    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
11179    /// order.
11180    ///
11181    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
11182    /// one item at a time, so that id is answered as missing and the others as themselves; any
11183    /// other refusal is every id of that batch's answer.
11184    async fn issue_details(
11185        &self,
11186        ids: &[NativeId],
11187        comments: Option<&PageRequest>,
11188    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11189        let mut read = Vec::with_capacity(ids.len());
11190        for batch in ids.chunks(DETAIL_BATCH) {
11191            match self
11192                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
11193                .await
11194            {
11195                Ok(data) => {
11196                    for (slot, id) in batch.iter().enumerate() {
11197                        // Every alias asked for is answered, null for an id naming nothing;
11198                        // one missing is an answer this source cannot read.
11199                        let read_one = match data.get(format!("i{slot}")) {
11200                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
11201                            None => Err(SourceError::Malformed {
11202                                message: format!(
11203                                    "GitHub answered a batch read with no item for {}",
11204                                    id.0
11205                                ),
11206                            }),
11207                        };
11208                        read.push(read_one);
11209                    }
11210                }
11211                Err(error) if unresolvable_node(&error) => {
11212                    for id in batch {
11213                        read.push(match comments {
11214                            Some(page) => self.issue_detail(id, page).await,
11215                            None => self.task_read(id).await,
11216                        });
11217                    }
11218                }
11219                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
11220            }
11221        }
11222        read
11223    }
11224
11225    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
11226    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
11227        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
11228            task,
11229            comments: None,
11230        }))
11231    }
11232
11233    /// What one node a detail read reached says: the task this board holds by `id`, with the
11234    /// page of comments the node carries when `commented` — or `None` for a node that is no
11235    /// task of this board.
11236    ///
11237    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
11238    /// and an item this process created answers from this process's own record, which a node
11239    /// read taken moments after the write can still be behind.
11240    async fn detail_of(
11241        &self,
11242        id: &NativeId,
11243        node: &Value,
11244        commented: bool,
11245        after: Option<&str>,
11246    ) -> Result<Option<TaskDetailRead>, SourceError> {
11247        if node.is_null() {
11248            return Ok(None);
11249        }
11250        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
11251        // An issue answered under one id is that id's, or the answer is not one this source
11252        // can report: reporting another issue's task and comments under the qualified id asked
11253        // for would be the one wrong answer here. A draft's own read checks the same.
11254        if !draft
11255            && optional_str(node, "__typename")? == Some("Issue")
11256            && required_str(node, "id")? != id.0
11257        {
11258            return Err(SourceError::Malformed {
11259                message: format!(
11260                    "GitHub answered the read of {} with issue {}",
11261                    id.0,
11262                    required_str(node, "id")?
11263                ),
11264            });
11265        }
11266        let item = if draft {
11267            self.draft_by_id(id).await?
11268        } else {
11269            self.resolve_issue(node).await?
11270        };
11271        // A read that carried the whole of what the issue is blocked by — a copy's — is kept
11272        // for this command, so the dependency read the copy makes next is answered from it.
11273        if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
11274            self.resolved_cache()?.insert(read.id.clone(), read.clone());
11275        }
11276        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11277            return Ok(None);
11278        };
11279        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
11280        let task = own.unwrap_or(item).task()?;
11281        let comments = match (commented, draft) {
11282            (false, _) => None,
11283            (true, true) => Some(Err(self.draft_has_no_comments(id))),
11284            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
11285        };
11286        if commented && !draft && after.is_none() {
11287            self.expected.learn_comments(id, node);
11288        }
11289        Ok(Some(TaskDetailRead { task, comments }))
11290    }
11291
11292    /// Whether the comment `comment` is one of `issue`'s own.
11293    ///
11294    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
11295    /// comment's id and nothing else: a comment id given against the wrong task would
11296    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
11297    /// names something that is not an issue comment, is a comment this task does not have —
11298    /// which is what GitHub refusing to resolve it means too.
11299    async fn comment_is_on(
11300        &self,
11301        issue: &NativeId,
11302        comment: &NativeId,
11303    ) -> Result<bool, SourceError> {
11304        Ok(self.comment_on(issue, comment).await?.is_some())
11305    }
11306
11307    /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
11308    /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
11309    async fn comment_on(
11310        &self,
11311        issue: &NativeId,
11312        comment: &NativeId,
11313    ) -> Result<Option<String>, SourceError> {
11314        // A write the store's cache expects knows which comments its issue held, and their
11315        // bodies, at the version it expects: what this read would answer.
11316        if let Some(primed) = self.expected.primed_comment(issue, comment) {
11317            return Ok(primed);
11318        }
11319        let asked = self
11320            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
11321            .await;
11322        let data = match asked {
11323            Ok(data) => data,
11324            Err(error) if unresolvable_node(&error) => return Ok(None),
11325            Err(error) => return Err(error),
11326        };
11327        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
11328            return Ok(None);
11329        };
11330        if optional_str(node, "__typename")? != Some("IssueComment") {
11331            return Ok(None);
11332        }
11333        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
11334            message: format!("GitHub issue comment {} names no issue", comment.0),
11335        })?;
11336        if required_str(on, "id")? != issue.0 {
11337            return Ok(None);
11338        }
11339        Ok(Some(required_str(node, "body")?.to_owned()))
11340    }
11341
11342    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
11343    async fn partition_edges(
11344        &self,
11345        near_kind: BoardKind,
11346        near_content: ContentKind,
11347        carried: Option<&[Value]>,
11348        depends_on: &[DependencyEdge],
11349    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
11350        let mut native = Vec::new();
11351        let mut fallback = Vec::new();
11352        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
11353            .iter()
11354            .map(|edge| {
11355                // An opaque far end names another tracker, so it is never this board's own.
11356                let same_source = edge.to.designator().is_none()
11357                    && edge
11358                        .to
11359                        .source()
11360                        .is_none_or(|source| source == self.name.as_str());
11361                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
11362                // `DependencyEndpoint::source` both read it that way — and a native id may hold
11363                // colons of its own, so the far end is everything after that one separator.
11364                // Splitting at the last would truncate `work:urn:task:7` to `7`.
11365                let far_id = if edge.to.is_qualified() {
11366                    edge.to
11367                        .id()
11368                        .split_once(':')
11369                        .map_or(edge.to.id(), |(_, native)| native)
11370                } else {
11371                    edge.to.id()
11372                };
11373                // One that already blocks the near issue was answered by that issue's own
11374                // read, which carried each of its blockers' kinds — an issue every one — so it
11375                // is not read again.
11376                let blocking = carried.and_then(|nodes| {
11377                    nodes
11378                        .iter()
11379                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
11380                });
11381                (edge, far_id, same_source, blocking)
11382            })
11383            .collect();
11384        // Every other same-source far end is read by its own id, exactly as the item it is a
11385        // far end of is: whether this board holds it is that read's answer, never a listing's.
11386        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
11387        let mut unread: Vec<NativeId> = Vec::new();
11388        for (_, far_id, same_source, blocking) in &far_ends {
11389            let id = NativeId((*far_id).to_owned());
11390            if *same_source && blocking.is_none() && !unread.contains(&id) {
11391                unread.push(id);
11392            }
11393        }
11394        let read: BTreeMap<NativeId, Option<Resolved>> = unread
11395            .iter()
11396            .cloned()
11397            .zip(self.items_by_ids(&unread).await?)
11398            .collect();
11399        for (edge, far_id, same_source, blocking) in far_ends {
11400            let far = match (same_source, blocking) {
11401                (false, _) => None,
11402                (true, Some(node)) => Some(FarEnd {
11403                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
11404                        BoardKind::Document
11405                    } else {
11406                        BoardKind::Work(related_kind(node)?)
11407                    },
11408                    content_kind: ContentKind::Issue,
11409                }),
11410                (true, None) => {
11411                    let read = read
11412                        .get(&NativeId(far_id.to_owned()))
11413                        .cloned()
11414                        .flatten()
11415                        .ok_or_else(|| SourceError::Refused {
11416                            message: format!("GitHub dependency item {far_id} was not found"),
11417                        })?;
11418                    Some(FarEnd {
11419                        kind: read.kind,
11420                        content_kind: read.content_kind,
11421                    })
11422                }
11423            };
11424            let far = far.as_ref();
11425            // The caller says which kind the far end is, and this board holds the far end
11426            // itself, so a disagreement is settled here rather than stored: recorded, the
11427            // wrong kind would read back as a cross-level edge that never existed; written
11428            // natively, it would name a relationship of a different level than the caller
11429            // asked for.
11430            //
11431            // A far end this board holds as a *document* fails the same comparison and is
11432            // refused by the same sentence: `ItemKind` has no document variant because
11433            // nothing may point at one, so no caller can name it correctly and the refusal
11434            // is the only honest answer.
11435            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
11436                return Err(SourceError::Refused {
11437                    message: format!(
11438                        "GitHub dependency item {far_id} is a {} of this board, and this item \
11439                         names it as a {}; record the kind it is",
11440                        disagreeing.kind.describes(),
11441                        edge.to.kind.marker()
11442                    ),
11443                });
11444            }
11445            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
11446            // however the far end is spelled — and one classified native here would be
11447            // written nowhere at all, because a draft's native reconciliation never runs.
11448            let native_here = near_content == ContentKind::Issue
11449                && far.is_some_and(|far| {
11450                    far.content_kind == ContentKind::Issue
11451                        && BoardKind::Work(edge.to.kind) == near_kind
11452                });
11453            if native_here {
11454                native.push(far_id.to_owned());
11455            } else {
11456                fallback.push(edge.clone());
11457            }
11458        }
11459        Ok((native, fallback))
11460    }
11461
11462    async fn update_existing(
11463        &self,
11464        item: &Resolved,
11465        incoming: &Incoming<'_>,
11466        body: &Option<String>,
11467        status_target: Option<&StatusTarget>,
11468    ) -> Result<(), SourceError> {
11469        let title = incoming.written_title();
11470        // A terminal status closes the issue here, in the same mutation as its body: its board
11471        // option was selected before this, so a close never lands on an item whose board cannot
11472        // show it.
11473        let mut fields = match item.content_kind {
11474            ContentKind::DraftIssue => json!({"title":title,"body":body}),
11475            ContentKind::Issue => json!({"title":title,"body":body,
11476                                         "stateInput":state_input(status_target)}),
11477        };
11478        // Only the members whose value differs from the item as this write read it are sent,
11479        // and nothing at all when none does — whether or not the store's cache expects a
11480        // version of it. A member left out is one GitHub leaves as it is, so a person's edit
11481        // of it since that read is kept rather than overwritten with what it already held,
11482        // and a re-copy of an unchanged item costs no mutation.
11483        if let Some(members) = fields.as_object_mut() {
11484            if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
11485                members.remove("title");
11486            }
11487            let body_unchanged = match (members.get("body"), item.raw_body.as_deref()) {
11488                (Some(Value::String(sent)), Some(held)) => sent == held,
11489                (Some(Value::Null), held) => held.is_none_or(str::is_empty),
11490                _ => false,
11491            };
11492            if body_unchanged {
11493                members.remove("body");
11494            }
11495            // An issue already in the state the target asks for keeps it: an open one stays
11496            // open, and a closed one whose closure reads as the target's category keeps its
11497            // reason — a duplicate's says more than this write would.
11498            let unchanged_state = match status_target {
11499                Some(StatusTarget::Terminal(_, reason)) => {
11500                    item.closed
11501                        && closed_category(item.reason.as_deref())
11502                            == closed_category(Some(reason.reason()))
11503                }
11504                Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => !item.closed,
11505                None => true,
11506            };
11507            if unchanged_state {
11508                members.remove("stateInput");
11509            }
11510            if members.is_empty() {
11511                return Ok(());
11512            }
11513        }
11514        self.update_content(item.content_kind, &item.id, fields)
11515            .await
11516    }
11517
11518    /// Update one board item's content with exactly `fields` beside its id, through the
11519    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
11520    /// a draft.
11521    ///
11522    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
11523    /// is what lets a narrow write carry the one thing it changes and nothing else.
11524    async fn update_content(
11525        &self,
11526        kind: ContentKind,
11527        id: &NativeId,
11528        fields: Value,
11529    ) -> Result<(), SourceError> {
11530        let (operation, id_key, pointer) = match kind {
11531            ContentKind::DraftIssue => (
11532                graphql::UPDATE_DRAFT,
11533                "draftIssueId",
11534                "/updateProjectV2DraftIssue/draftIssue",
11535            ),
11536            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
11537        };
11538        if kind == ContentKind::Issue && self.hold_update(id, &fields)? {
11539            return Ok(());
11540        }
11541        let mut input = fields;
11542        input[id_key] = json!(id.0);
11543        self.send_update(operation, pointer, id, input).await
11544    }
11545
11546    /// Send one item's update, `input`, as `operation`, and hold GitHub's answer at `pointer`
11547    /// to naming that item, `id`.
11548    async fn send_update(
11549        &self,
11550        operation: &str,
11551        pointer: &str,
11552        id: &NativeId,
11553        input: Value,
11554    ) -> Result<(), SourceError> {
11555        let data = self.graphql(operation, json!({"input":input})).await?;
11556        let returned = data
11557            .pointer(pointer)
11558            .ok_or_else(|| SourceError::Malformed {
11559                message: "GitHub item update returned no item".into(),
11560            })?;
11561        if required_str(returned, "id")? != id.0 {
11562            return Err(SourceError::Malformed {
11563                message: "GitHub item update returned the wrong item".into(),
11564            });
11565        }
11566        Ok(())
11567    }
11568
11569    /// Creates one issue, files it on the board, and reports what a read of it would say:
11570    /// its content id, its board item id, and the web address GitHub gave it.
11571    ///
11572    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
11573    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
11574    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
11575    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
11576    /// "Content already exists in this project". A terminal status is not written here:
11577    /// `finish_write` selects its option first and closes the issue after, so a close never
11578    /// lands on an item whose board cannot show it.
11579    ///
11580    /// The address and the number come back here because this is the only place either is
11581    /// known before GitHub's own board read catches up — an item this run created answers
11582    /// the reads that follow it out of the record below, and one remembered without them
11583    /// would report no location and no key for the rest of the run.
11584    async fn create_and_file_issue(
11585        &self,
11586        board_id: &str,
11587        repository: &RepositoryTarget,
11588        incoming: &Incoming<'_>,
11589        body: &Option<String>,
11590    ) -> Result<Landed, SourceError> {
11591        let repository_id = self.repository_id(repository, incoming).await?;
11592        let data = self
11593            .graphql(
11594                graphql::CREATE_ISSUE,
11595                json!({"input":{
11596                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
11597                }}),
11598            )
11599            .await?;
11600        let created = data
11601            .pointer("/createIssue/issue")
11602            .filter(|value| !value.is_null())
11603            .ok_or_else(|| SourceError::Malformed {
11604                message: "GitHub issue creation returned no issue".into(),
11605            })?;
11606        let content_id = NativeId(required_str(created, "id")?.to_owned());
11607        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
11608        // a response without it is not worth failing a landed write over — the item simply
11609        // reports no location until the board read catches up, which is what it did before.
11610        let url = optional_str(created, "url")?.map(str::to_owned);
11611        // The issue exists from here on, so an unreadable number and a refused board
11612        // filing below each try, best effort, to take it back: an issue in the repository
11613        // that is on no board is an item nobody asked for and nothing here would find again.
11614        //
11615        // Its number is optional on the same terms its address is — a landed write is not
11616        // worth failing over a member that came back missing, and such an item reports no
11617        // handle until a board read catches up. A number that is *present* and is not an
11618        // unsigned integer is still a response this source cannot read.
11619        let number = match created_issue_number(created) {
11620            Ok(number) => number,
11621            Err(error) => {
11622                let _ = self.delete_issue(&content_id).await;
11623                return Err(error);
11624            }
11625        };
11626        let added = match self
11627            .graphql(
11628                graphql::ADD_TO_BOARD,
11629                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
11630            )
11631            .await
11632        {
11633            Ok(added) => added,
11634            Err(error) => {
11635                let _ = self.delete_issue(&content_id).await;
11636                return Err(error);
11637            }
11638        };
11639        let item = added
11640            .pointer("/addProjectV2ItemById/item")
11641            .filter(|value| !value.is_null())
11642            .ok_or_else(|| SourceError::Malformed {
11643                message: "GitHub board addition returned no project item".into(),
11644            })?;
11645        self.note_written(&content_id, url.as_deref());
11646        Ok(Landed {
11647            content_id,
11648            item_id: Some(required_str(item, "id")?.to_owned()),
11649            url,
11650            number,
11651        })
11652    }
11653
11654    /// Move one issue under the project it now belongs to, or out of the one it left.
11655    async fn reparent(
11656        &self,
11657        held: Option<NativeId>,
11658        child: &NativeId,
11659        wanted: Option<&NativeId>,
11660    ) -> Result<(), SourceError> {
11661        if held.as_ref() == wanted {
11662            return Ok(());
11663        }
11664        if let Some(held) = &held {
11665            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
11666                .await?;
11667        }
11668        if let Some(wanted) = wanted {
11669            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
11670                .await?;
11671        }
11672        Ok(())
11673    }
11674
11675    async fn sub_issue(
11676        &self,
11677        operation: &str,
11678        parent: &NativeId,
11679        child: &NativeId,
11680        root: &str,
11681    ) -> Result<(), SourceError> {
11682        let data = self
11683            .graphql(
11684                operation,
11685                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
11686            )
11687            .await?;
11688        let issue =
11689            data.pointer(&format!("/{root}/issue"))
11690                .ok_or_else(|| SourceError::Malformed {
11691                    message: "GitHub sub-issue update returned no issue".into(),
11692                })?;
11693        let sub =
11694            data.pointer(&format!("/{root}/subIssue"))
11695                .ok_or_else(|| SourceError::Malformed {
11696                    message: "GitHub sub-issue update returned no sub-issue".into(),
11697                })?;
11698        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
11699            return Err(SourceError::Malformed {
11700                message: "GitHub sub-issue update returned the wrong issues".into(),
11701            });
11702        }
11703        Ok(())
11704    }
11705
11706    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
11707    /// whether there was one.
11708    ///
11709    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
11710    /// relationships are not read: there is nothing a read of them could find.
11711    async fn reconcile_blocked_by(
11712        &self,
11713        content_id: &NativeId,
11714        native: &[String],
11715        issue: Issue<'_>,
11716    ) -> Result<bool, SourceError> {
11717        let current = match issue {
11718            Issue::Created => Vec::new(),
11719            Issue::Existing(Some(held)) => held
11720                .iter()
11721                .map(|far| required_str(far, "id").map(str::to_owned))
11722                .collect::<Result<Vec<_>, _>>()?,
11723            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
11724        };
11725        let mut changed = false;
11726        for (operation, far_id) in current
11727            .iter()
11728            .filter(|id| !native.contains(id))
11729            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
11730            .chain(
11731                native
11732                    .iter()
11733                    .filter(|id| !current.contains(id))
11734                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
11735            )
11736        {
11737            let data = self
11738                .graphql(
11739                    operation,
11740                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
11741                )
11742                .await?;
11743            let root = if operation == graphql::ADD_BLOCKED_BY {
11744                "addBlockedBy"
11745            } else {
11746                "removeBlockedBy"
11747            };
11748            let issue =
11749                data.pointer(&format!("/{root}/issue"))
11750                    .ok_or_else(|| SourceError::Malformed {
11751                        message: "GitHub dependency update returned no issue".into(),
11752                    })?;
11753            let blocker = data
11754                .pointer(&format!("/{root}/blockingIssue"))
11755                .ok_or_else(|| SourceError::Malformed {
11756                    message: "GitHub dependency update returned no blocking issue".into(),
11757                })?;
11758            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
11759            {
11760                return Err(SourceError::Malformed {
11761                    message: "GitHub dependency update returned the wrong issues".into(),
11762                });
11763            }
11764            changed = true;
11765        }
11766        Ok(changed)
11767    }
11768}
11769
11770/// The board field values one write sends, in the order GitHub runs them, and the fields it
11771/// clears after them: the origin field when it moves, the `Status` option `column` names, the
11772/// priority, then each projected field.
11773fn field_writes<'a>(
11774    existing: Option<&Resolved>,
11775    origin_field: Option<&str>,
11776    origin: &str,
11777    column: Option<(String, String)>,
11778    priority: Option<&'a PriorityWrite>,
11779    projections: &'a [ProjectionWrite],
11780) -> (Vec<(String, Value)>, Vec<&'a str>) {
11781    let mut fields = Vec::new();
11782    if let Some(field_id) = origin_field
11783        && existing.map_or(!origin.is_empty(), |item| {
11784            item.origin.as_deref().unwrap_or("") != origin
11785        })
11786    {
11787        fields.push((field_id.to_owned(), json!({"text":origin})));
11788    }
11789    if let Some((field_id, option_id)) = column {
11790        fields.push((field_id, json!({"singleSelectOptionId":option_id})));
11791    }
11792    let mut clears = Vec::new();
11793    match priority {
11794        Some(PriorityWrite::Select { field, option }) => {
11795            fields.push((field.clone(), json!({"singleSelectOptionId":option})));
11796        }
11797        Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
11798        None => {}
11799    }
11800    for projection in projections {
11801        projection.join(&mut fields, &mut clears);
11802    }
11803    (fields, clears)
11804}
11805
11806/// Everything one write decided before sending anything: see
11807/// [`GitHubProjectsSource::prepare_write`].
11808struct Prepared {
11809    /// What the write lands on.
11810    destination: WriteDestination,
11811    board: BoardFields,
11812    status_target: Option<StatusTarget>,
11813    /// The `Status` field, the option and the option's name the status lands on.
11814    // 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.
11815    column: Option<(String, String, String)>,
11816    priority_write: Option<PriorityWrite>,
11817    projections: Vec<ProjectionWrite>,
11818    projected: BTreeMap<String, String>,
11819    content_kind: ContentKind,
11820    own_repository: Option<Repository>,
11821    /// The far ends of the item's native blocked-by relationships.
11822    // 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.
11823    native: Vec<String>,
11824    slot: BTreeMap<String, Value>,
11825    content: Option<String>,
11826    body: Option<String>,
11827    /// The copy origin the item records, empty for none.
11828    // 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.
11829    origin: String,
11830    origin_field: Option<String>,
11831}
11832
11833/// One write a copy held back of the kinds sent together: a board field value, or a blocked-by
11834/// edge, each its input.
11835enum HeldWrite<'a> {
11836    Field(&'a Value),
11837    Edge(&'a Value),
11838}
11839
11840/// Whether `error` is GitHub refusing to file an issue on a board because it is filed there
11841/// already — what `addProjectV2ItemById` answers once `createIssue`'s `projectV2Ids` has placed
11842/// it, rather than answering with the item it holds.
11843fn already_on_the_board(error: &SourceError) -> bool {
11844    error
11845        .to_string()
11846        .contains("Content already exists in this project")
11847}
11848
11849/// Hold every alias `inputs` sent under `prefix` in `data` to answering with what its input
11850/// named — each `(root, named)` of `answers` — and refuse one that answered nothing or another
11851/// item.
11852fn check_aliased_answers(
11853    document: &str,
11854    data: &Value,
11855    prefix: &str,
11856    inputs: &[&Value],
11857    answers: &[(&str, &str)],
11858) -> Result<(), SourceError> {
11859    for (slot, input) in inputs.iter().enumerate() {
11860        for (root, named) in answers {
11861            let answered = data
11862                .pointer(&format!("/{prefix}{slot}/{root}/id"))
11863                .and_then(Value::as_str)
11864                .ok_or_else(|| SourceError::Malformed {
11865                    message: format!(
11866                        "GitHub answered {} with nothing at {prefix}{slot}",
11867                        operation_description(document)
11868                    ),
11869                })?;
11870            let asked = input.get(named).and_then(Value::as_str).unwrap_or_default();
11871            if answered != asked {
11872                return Err(SourceError::Malformed {
11873                    message: format!(
11874                        "GitHub answered {} at {prefix}{slot} with {answered}, which is not \
11875                         {asked}, the item it was asked to write",
11876                        operation_description(document)
11877                    ),
11878                });
11879            }
11880        }
11881    }
11882    Ok(())
11883}
11884
11885/// `error`, which stopped a batch of creates, and — when GitHub refused to take any of them
11886/// back — each issue of `left` it created that is still in the repository, which its caller,
11887/// never handed their ids, could not name.
11888fn left_behind(error: SourceError, left: &[&str]) -> SourceError {
11889    if left.is_empty() {
11890        return error;
11891    }
11892    SourceError::Refused {
11893        message: format!(
11894            "{error}; GitHub then refused to take back {}, which this write created and which \
11895             are still in the repository; next: delete {} before writing again",
11896            left.join(", "),
11897            if left.len() == 1 { "it" } else { "them" }
11898        ),
11899    }
11900}
11901
11902/// What one write lands on: the item it updates, as it stood, or where it creates a new one.
11903enum WriteDestination {
11904    /// The item as it stood, for an update.
11905    Existing(Box<Resolved>),
11906    /// Where a new item is created.
11907    New(RepositoryTarget),
11908}
11909
11910impl WriteDestination {
11911    /// The item an update writes over, as it stood; `None` for a create.
11912    fn existing(&self) -> Option<&Resolved> {
11913        match self {
11914            Self::Existing(item) => Some(item),
11915            Self::New(_) => None,
11916        }
11917    }
11918}
11919
11920/// One existing issue's update held back by [`GitHubProjectsSource::hold_update`]: the issue,
11921/// and the `UpdateIssueInput` its own [`graphql::UPDATE_ISSUE`] would have sent — the members
11922/// it writes beside that issue's id, which only [`Self::new`] sets.
11923struct HeldUpdate {
11924    issue: NativeId,
11925    // 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.
11926    input: Value,
11927}
11928
11929impl HeldUpdate {
11930    /// The update writing `members` — an `UpdateIssueInput` without its id — to `issue`.
11931    fn new(issue: &NativeId, members: &Value) -> Self {
11932        let mut input = members.clone();
11933        input["id"] = json!(issue.0);
11934        Self {
11935            issue: issue.clone(),
11936            input,
11937        }
11938    }
11939}
11940
11941/// What a copy has held back of the items it created; see
11942/// [`GitHubProjectsSource::hold_back`].
11943#[derive(Debug, Default)]
11944enum Deferred {
11945    /// Writes are being held back, and nothing has been held yet.
11946    #[default]
11947    Nothing,
11948    /// The items held back, every one of them on `board`.
11949    Held {
11950        /// The board every one of them is on.
11951        board: BoardId,
11952        /// Each created item, in the order it was created.
11953        created: Vec<HeldBack>,
11954    },
11955}
11956
11957impl Deferred {
11958    /// Whether `content` is one of the items held back.
11959    fn holds(&self, content: &NativeId) -> bool {
11960        match self {
11961            Self::Nothing => false,
11962            Self::Held { created, .. } => created.iter().any(|held| held.content == *content),
11963        }
11964    }
11965}
11966
11967/// One created item's parts that land once [`TaskSource::finish_writes`] is called.
11968#[derive(Debug)]
11969struct HeldBack {
11970    /// The issue.
11971    content: NativeId,
11972    /// Its board field values other than its `Status`, in the order they are written.
11973    fields: Vec<(String, Value)>,
11974    /// The `Status` field, and the option and its name that the item's status lands on.
11975    // llmlint: ignore[invalid_states_unrepresentable] The same triple `column_for` returns and `Prepared::column` carries, held back unchanged until it is sent.
11976    status: Option<(String, String, String)>,
11977    /// The fields it clears after those are written.
11978    clears: Vec<String>,
11979    /// The reason it is closed for, once its option is selected; `None` leaves it open.
11980    close: Option<ClosedState>,
11981    /// The issues it is blocked by.
11982    edges: Vec<NativeId>,
11983}
11984
11985/// The member [`answer_with_errors`] carries a response's GraphQL errors under, beside its data:
11986/// no GitHub type has a field of this name.
11987const ANSWER_ERRORS: &str = "onetaskgraphErrors";
11988
11989/// A response's `data` with its GraphQL errors beside it under [`ANSWER_ERRORS`], when it has
11990/// both — or `None`, for an answer [`GitHubProjectsSource::answer`] reads as it always has.
11991///
11992/// What [`graphql::CREATE_ISSUES`] is answered with: GitHub runs a document's mutation fields in
11993/// order and answers each one that ran, so a refusal of one alias leaves the issues the others
11994/// created named in the data beside it — the ids a copy needs to take them back.
11995fn answer_with_errors(body: &str) -> Option<Value> {
11996    let parsed: Value = serde_json::from_str(body).ok()?;
11997    let errors = parsed
11998        .get("errors")?
11999        .as_array()
12000        .filter(|errors| !errors.is_empty())?;
12001    let mut data = parsed.get("data").filter(|data| data.is_object())?.clone();
12002    data[ANSWER_ERRORS] = Value::Array(errors.clone());
12003    Some(data)
12004}
12005
12006/// The `createIssue` input that files the new issue on `board` and, when `incoming` names one,
12007/// under its project — see [`GitHubProjectsSource::create_filed_issue`].
12008fn filed_input(
12009    repository: &str,
12010    board: &str,
12011    incoming: &Incoming<'_>,
12012    body: &Option<String>,
12013) -> Value {
12014    let mut input = json!({"repositoryId":repository,"title":incoming.written_title(),
12015                           "body":body,"projectV2Ids":[board]});
12016    if let Some(parent) = incoming.parent {
12017        input["parentIssueId"] = json!(parent.0);
12018    }
12019    input
12020}
12021
12022/// The name of option `option` of field `field` among a board's `fields`.
12023fn option_name<'a>(fields: &'a Value, field: &str, option: &str) -> Option<&'a str> {
12024    fields
12025        .get("nodes")?
12026        .as_array()?
12027        .iter()
12028        .find(|node| node.get("id").and_then(Value::as_str) == Some(field))?
12029        .get("options")?
12030        .as_array()?
12031        .iter()
12032        .find(|candidate| candidate.get("id").and_then(Value::as_str) == Some(option))?
12033        .get("name")?
12034        .as_str()
12035}
12036
12037/// What a write needs to know of one far end it names: which kind of item it is, and whether
12038/// it is an issue a native relationship can name.
12039struct FarEnd {
12040    kind: BoardKind,
12041    content_kind: ContentKind,
12042}
12043
12044/// Whether the issue one write reconciles was created by that write or was already there.
12045#[derive(Clone, Copy, PartialEq, Eq)]
12046enum Issue<'a> {
12047    /// Created by this write, so it holds no relationships yet.
12048    Created,
12049    /// On the board before this write, holding whatever relationships it holds — the far
12050    /// ends of its whole `blockedBy`, when the read that reached it carried them.
12051    Existing(Option<&'a [Value]>),
12052}
12053
12054/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
12055enum Reached {
12056    /// An issue this board holds, resolved into everything this source reports about it.
12057    Held(Box<Resolved>),
12058    /// Nothing this board holds: no such node, or a node on some other board.
12059    Nothing,
12060    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
12061    /// again by [`GitHubProjectsSource::draft_by_id`].
12062    Draft,
12063}
12064
12065/// What GitHub says when a string is not a node id it can resolve.
12066///
12067/// Matched because it is the ordinary answer to a project selector naming a project by its
12068/// *name*, and reporting that as a failure would make naming one impossible. It is read
12069/// off the refusal GitHub sent, never guessed from the shape of the string: this source
12070/// does not define the syntax of a GitHub node id and would be wrong about it.
12071const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
12072
12073/// `error` with `note` added to the end of what it says, its kind and every other member
12074/// unchanged — so a caller still branches on the failure that happened, and reads beside it
12075/// what that failure left behind.
12076fn noting(error: SourceError, note: &str) -> SourceError {
12077    match error {
12078        SourceError::Config { message } => SourceError::Config {
12079            message: message + note,
12080        },
12081        SourceError::Auth { message } => SourceError::Auth {
12082            message: message + note,
12083        },
12084        SourceError::Refused { message } => SourceError::Refused {
12085            message: message + note,
12086        },
12087        SourceError::RateLimited {
12088            retry_after_seconds,
12089            message,
12090        } => SourceError::RateLimited {
12091            retry_after_seconds,
12092            message: Some(message.unwrap_or_default() + note),
12093        },
12094        SourceError::Unavailable { message } => SourceError::Unavailable {
12095            message: message + note,
12096        },
12097        SourceError::Malformed { message } => SourceError::Malformed {
12098            message: message + note,
12099        },
12100    }
12101}
12102
12103/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
12104/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
12105fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest, children: bool) -> Value {
12106    let mut variables = serde_json::Map::new();
12107    for slot in 0..COPY_DETAIL_BATCH {
12108        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12109        variables.insert(format!("id{slot}"), json!(id));
12110    }
12111    variables.insert("project".to_owned(), json!(batch.first().map(|id| &id.0)));
12112    variables.insert("children".to_owned(), json!(children));
12113    variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
12114    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12115    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12116    variables.insert("duplicates".to_owned(), json!(true));
12117    Value::Object(variables)
12118}
12119
12120/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
12121/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
12122/// for them.
12123///
12124/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
12125/// is read again at no added price.
12126fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
12127    let mut variables = serde_json::Map::new();
12128    for slot in 0..DETAIL_BATCH {
12129        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12130        variables.insert(format!("id{slot}"), json!(id));
12131    }
12132    variables.insert(
12133        "first".to_owned(),
12134        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
12135    );
12136    variables.insert("comments".to_owned(), json!(comments.is_some()));
12137    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12138    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12139    variables.insert("duplicates".to_owned(), json!(true));
12140    Value::Object(variables)
12141}
12142
12143/// Whether this refusal is GitHub saying the id names no node at all.
12144fn unresolvable_node(error: &SourceError) -> bool {
12145    matches!(error, SourceError::Refused { message }
12146        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
12147}
12148
12149/// One project name, as a search qualifier which filters on it at the server.
12150///
12151/// Quoted so the whole title is one phrase rather than a bag of words, with the two
12152/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
12153/// the way it documents. A title matched here is still compared for equality afterwards:
12154/// the qualifier narrows what the server sends, and this source decides what it names.
12155fn title_qualifier(name: &str) -> String {
12156    format!("in:title {}", quoted(name))
12157}
12158
12159/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
12160/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
12161/// it documents — so a value holding a qualifier's spelling is searched for rather than
12162/// obeyed.
12163fn quoted(value: &str) -> String {
12164    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
12165    format!("\"{escaped}\"")
12166}
12167
12168/// The search qualifier for the issues updated at or after `since`.
12169///
12170/// Written to the second, rounded down, which can only widen what the search returns.
12171fn updated_qualifier(since: DateTime<Utc>) -> String {
12172    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
12173}
12174
12175/// The search terms that narrow a board-scoped issue search to a task query's text and
12176/// metadata predicates, or `None` when it carries neither.
12177///
12178/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
12179/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
12180/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
12181/// matches each in any field the `in:` qualifier names, so a query naming a title search and
12182/// a metadata value searches both fields for both — wider than asked, never narrower, and
12183/// every candidate is confirmed in process afterwards.
12184///
12185/// **This narrows a text search, and that is this source's declared semantics.** GitHub
12186/// matches whole tokens where a substring rule would match inside a word, so an item holding
12187/// the text only inside a longer word is not returned. A text of nothing but whitespace
12188/// matches every item, so it narrows nothing and is not sent.
12189fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
12190    let text = query
12191        .text
12192        .as_ref()
12193        .filter(|text| !text.terms.trim().is_empty());
12194    if text.is_none() && query.metadata.is_empty() {
12195        return None;
12196    }
12197    let (title, body) = match text.map(|text| text.fields) {
12198        None => (false, true),
12199        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
12200        Some(TextFields::Content) => (false, true),
12201        Some(TextFields::TitleOrContent) => (true, true),
12202    };
12203    let fields = match (title, body) {
12204        (true, true) => "in:title,body",
12205        (true, false) => "in:title",
12206        _ => "in:body",
12207    };
12208    let phrases = text
12209        .map(|text| text.terms.clone())
12210        .into_iter()
12211        .chain(
12212            query
12213                .metadata
12214                .iter()
12215                .map(|wanted| as_stored(wanted.value())),
12216        )
12217        .map(|phrase| quoted(&phrase))
12218        .collect::<Vec<_>>();
12219    Some(format!("{fields} {}", phrases.join(" ")))
12220}
12221
12222/// The search terms that narrow a board-scoped issue search to a project or document query's
12223/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
12224/// carrying that text alone is sent as by [`narrowing_qualifiers`].
12225fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
12226    narrowing_qualifiers(&TaskQuery {
12227        text: text.cloned(),
12228        ..TaskQuery::default()
12229    })
12230}
12231
12232/// Refuses a project or document query's text GitHub's issue search cannot find, before
12233/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
12234/// query's.
12235fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
12236    refuse_unsearchable(&TaskQuery {
12237        text: text.cloned(),
12238        ..TaskQuery::default()
12239    })
12240}
12241
12242/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
12243/// before anything is asked of GitHub.
12244///
12245/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
12246/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
12247/// left out, the search is every issue of the board. So this source says it cannot answer
12248/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
12249/// nothing GitHub could search for, and keeps the board read it always had.
12250fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
12251    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
12252                       letter or digit with a bounded query";
12253    if let Some(text) = &query.text
12254        && !text.terms.trim().is_empty()
12255        && !has_words(&text.terms)
12256    {
12257        return Err(SourceError::Refused {
12258            message: format!(
12259                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
12260                text.terms
12261            ),
12262        });
12263    }
12264    if let Some(wanted) = query
12265        .metadata
12266        .iter()
12267        .find(|wanted| !has_words(wanted.value()))
12268    {
12269        return Err(SourceError::Refused {
12270            message: format!(
12271                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
12272                wanted.value(),
12273                std::iter::once(wanted.key())
12274                    .chain(wanted.path().iter().map(String::as_str))
12275                    .collect::<Vec<_>>()
12276                    .join("/"),
12277            ),
12278        });
12279    }
12280    Ok(())
12281}
12282
12283/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
12284fn has_words(phrase: &str) -> bool {
12285    phrase.chars().any(char::is_alphanumeric)
12286}
12287
12288/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
12289///
12290/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
12291/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
12292/// which GitHub's word match would read as different words.
12293fn as_stored(value: &str) -> String {
12294    let encoded = Value::String(value.to_owned()).to_string();
12295    encoded[1..encoded.len() - 1].to_owned()
12296}
12297
12298/// The one narrower question a task query carrying a text, metadata or origin predicate is
12299/// sent as.
12300enum Narrowing {
12301    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
12302    Origin(String),
12303    /// The board-scoped issue search narrowed by these qualifiers.
12304    Search(String),
12305}
12306
12307impl Narrowing {
12308    /// What this question is remembered under for the length of one command.
12309    fn key(&self) -> String {
12310        match self {
12311            Self::Origin(origin) => format!("origin {origin}"),
12312            Self::Search(also) => format!("search {also}"),
12313        }
12314    }
12315}
12316
12317/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
12318enum Resumed {
12319    /// It reported another page, which starts after this cursor.
12320    More(String),
12321    /// It has ended. Sending this cursor again — the page's own end when it had one, and
12322    /// otherwise the cursor it was reached from — answers an empty page, so the one document
12323    /// can go on walking the other connection.
12324    Ended(Option<String>),
12325}
12326
12327impl Resumed {
12328    /// Whether the connection has another page.
12329    const fn has_more(&self) -> bool {
12330        matches!(self, Self::More(_))
12331    }
12332
12333    /// The cursor to send this connection next.
12334    fn cursor(self) -> Option<String> {
12335        match self {
12336            Self::More(next) => Some(next),
12337            Self::Ended(last) => last,
12338        }
12339    }
12340}
12341
12342/// Where `connection`, reached from `after`, resumes — refused when it reports another page
12343/// with no cursor to it, or from a cursor that does not advance.
12344fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
12345    let info = connection
12346        .get("pageInfo")
12347        .ok_or_else(|| SourceError::Malformed {
12348            message: "GitHub connection has no pageInfo".into(),
12349        })?;
12350    let end = optional_str(info, "endCursor")?;
12351    if required_bool(info, "hasNextPage")? {
12352        let next = end.ok_or_else(|| SourceError::Malformed {
12353            message: "GitHub connection reports another page and no endCursor".into(),
12354        })?;
12355        validate_cursor_progress(after, next)?;
12356        return Ok(Resumed::More(next.to_owned()));
12357    }
12358    Ok(Resumed::Ended(
12359        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
12360    ))
12361}
12362
12363/// The board, and every item on it this source reports.
12364#[derive(Clone)]
12365struct Board {
12366    id: String,
12367    fields: Value,
12368    items: Vec<Resolved>,
12369}
12370
12371/// What a write needs of the board and nothing more: its node id and its field
12372/// definitions, in the shape a read of the board's own `fields` gives them.
12373///
12374/// Deliberately no items. A write decides which item it writes, which parent it files
12375/// under and which far ends it names by reading each of them by its own id; this is the
12376/// half of the board those reads cannot carry, and holding no item is what keeps it from
12377/// ever being asked whether an item is there.
12378#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
12379struct BoardFields {
12380    id: BoardId,
12381    fields: Value,
12382}
12383
12384/// A board's node id: what a field write and `addProjectV2ItemById` address.
12385///
12386/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
12387/// refused where it is read, and one an item names blank is read as not named at all.
12388#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
12389#[serde(try_from = "String")]
12390struct BoardId(String);
12391
12392impl TryFrom<String> for BoardId {
12393    type Error = String;
12394
12395    /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
12396    fn try_from(id: String) -> Result<Self, Self::Error> {
12397        Self::parse(&id).map_err(|error| error.to_string())
12398    }
12399}
12400
12401/// Where one write left its item, for the record the rest of the command reads it out of.
12402///
12403/// A named record rather than a tuple because the update arm and the create arm each fill
12404/// all four, and two `Option`s of different meaning side by side in a tuple are two
12405/// positions a reader has to count.
12406struct Landed {
12407    /// The issue's own node id, which is the [`NativeId`] this source reports.
12408    content_id: NativeId,
12409    /// The board item's id, which is what a field write addresses — `None` for an issue its
12410    /// create filed on the board itself, whose board item GitHub adds after answering and
12411    /// [`TaskSource::finish_writes`] reads.
12412    // 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.
12413    item_id: Option<String>,
12414    /// The web address GitHub gave the issue, when it gave one.
12415    // 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.
12416    url: Option<String>,
12417    /// The issue's number on its repository, when GitHub reported one.
12418    number: Option<u64>,
12419}
12420
12421impl BoardId {
12422    fn parse(id: &str) -> Result<Self, SourceError> {
12423        if id.trim().is_empty() {
12424            return Err(SourceError::Malformed {
12425                message: "GitHub named a board with a blank node id".into(),
12426            });
12427        }
12428        Ok(Self(id.to_owned()))
12429    }
12430
12431    fn as_str(&self) -> &str {
12432        &self.0
12433    }
12434}
12435
12436impl Board {
12437    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
12438        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
12439        let nodes = fields
12440            .get("nodes")
12441            .and_then(Value::as_array)
12442            .ok_or_else(|| SourceError::Malformed {
12443                message: "GitHub project fields.nodes is not an array".into(),
12444            })?;
12445        Ok(nodes
12446            .iter()
12447            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
12448    }
12449}
12450
12451/// One board item, resolved into everything this source reports about it.
12452///
12453/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
12454/// [`TaskSource::prime_write`] takes back — see [`expected`].
12455#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
12456struct Resolved {
12457    item_id: String,
12458    id: NativeId,
12459    content_kind: ContentKind,
12460    kind: BoardKind,
12461    title: String,
12462    body: Option<String>,
12463    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
12464    /// that changes the slot alone has to keep byte for byte outside it.
12465    raw_body: Option<String>,
12466    status: Status,
12467    /// The name of the board `Status` option this item sits in, as the board spells it.
12468    option: Option<String>,
12469    /// What its `Priority` field says, read through this instance's mapping.
12470    priority: HeldPriority,
12471    /// Whether this item's issue is closed. A draft has no such state and is never closed.
12472    closed: bool,
12473    /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
12474    /// issue, a draft, and an issue closed before GitHub recorded reasons.
12475    #[serde(default)]
12476    reason: Option<String>,
12477    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
12478    delivers: Vec<TaskRef>,
12479    /// Every task that delivers this one, read out of its slot. Empty for anything not a
12480    /// task.
12481    delivered_by: Vec<TaskRef>,
12482    labels: Vec<Label>,
12483    parent: Option<NativeId>,
12484    // 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.
12485    origin: Option<String>,
12486    /// What each board text field this instance projects metadata onto holds for this item,
12487    /// by field name — only the fields holding a value, an empty text included. What a write compares the
12488    /// item's metadata against, so a field already holding its value is not written again;
12489    /// never read back as metadata, which the slot alone reports.
12490    projected: BTreeMap<String, String>,
12491    /// The issue's own number on its repository, as GitHub reports it.
12492    ///
12493    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
12494    /// declares none, and a draft is not filed in a repository to be numbered by one — and
12495    /// an issue this run created whose creating mutation answered without one, which is a
12496    /// response GitHub's own schema says cannot happen and which a landed write is not
12497    /// worth failing over. An `Issue` read off the board always has one.
12498    number: Option<u64>,
12499    url: Option<String>,
12500    created_at: Option<DateTime<Utc>>,
12501    updated_at: Option<DateTime<Utc>>,
12502    /// When the board item itself last changed — what a board field write moves — when the
12503    /// read that reached it said.
12504    item_updated_at: Option<DateTime<Utc>>,
12505    /// How many comments the issue holds, when the read that reached it said.
12506    comment_count: Option<u64>,
12507    /// How many relationship events the issue's timeline holds — every dependency, as
12508    /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
12509    /// removed — when the read that reached it said.
12510    relationship_events: Option<u64>,
12511    /// The server's clock on the response that read it — its `Date` header — when the
12512    /// response carried one.
12513    served_at: Option<DateTime<Utc>>,
12514    /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
12515    /// its labels — when the read that reached it carried all three whole.
12516    #[serde(default)]
12517    facets: Option<change_token::FacetsDigest>,
12518    own_repository: Option<Repository>,
12519    repositories: Vec<Repository>,
12520    classification: Classification,
12521    slot: BTreeMap<String, Value>,
12522    /// The node id of the board this item sits on, when the read that reached it said.
12523    board_id: Option<String>,
12524    /// The definition of every board field this item holds a value of, in the shape a read
12525    /// of the board's own `fields` gives one.
12526    ///
12527    /// Only the fields this item has a value in: a field it holds nothing of is not here,
12528    /// which says nothing about whether the board has it.
12529    fields: Vec<Value>,
12530    /// Every field the board this item sits on defines, as its own read of the board's
12531    /// `fields` gives them — when the read that reached the item carried them, which a read
12532    /// of it by its own id does. What a write of it needs of the board, then, needs no read
12533    /// of the board.
12534    board_fields: Option<Value>,
12535    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
12536    /// selects one — when the read that reached it carried the connection to its end, which a
12537    /// read of it by its own id does for any issue blocked by no more than a page. What a
12538    /// write reconciles that relationship against, and what a read of its forward edges in
12539    /// the same command answers with.
12540    blocked_by: Option<Vec<Value>>,
12541}
12542
12543impl Resolved {
12544    /// The board this item's own read names it on, when that read named one this source can
12545    /// address.
12546    fn named_board(&self) -> Option<BoardId> {
12547        self.board_id
12548            .as_deref()
12549            .and_then(|id| BoardId::parse(id).ok())
12550    }
12551
12552    /// The board's id and every field it defines, when the read that reached this item
12553    /// carried both — which a read of it by its own id does.
12554    fn carried_board(&self) -> Option<BoardFields> {
12555        Some(BoardFields {
12556            id: self.named_board()?,
12557            fields: self.board_fields.clone()?,
12558        })
12559    }
12560
12561    /// Whether this item holds a value of the board field called `name`, and so carries
12562    /// that field's definition. `false` says nothing about whether the board has the field.
12563    fn defines(&self, name: &str) -> bool {
12564        self.fields
12565            .iter()
12566            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
12567    }
12568
12569    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
12570    /// in a field of its own, and none of the five keys that are only an encoding.
12571    ///
12572    /// The two delivery keys are left out for every kind, not only for a task: they are
12573    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
12574    /// document carrying one holds nothing a caller's own metadata could mean by it.
12575    fn metadata(&self) -> BTreeMap<String, Value> {
12576        let mut metadata = self.slot.clone();
12577        metadata.remove(Repository::METADATA_KEY);
12578        metadata.remove(DependencyEdge::RECORDED_KEY);
12579        metadata.remove(ItemKind::METADATA_KEY);
12580        metadata.remove(TaskRef::DELIVERS_KEY);
12581        metadata.remove(TaskRef::DELIVERED_BY_KEY);
12582        metadata.remove(Classification::METADATA_KEY);
12583        // The board field is the origin, and the body's copy of it is only a mirror for the
12584        // issue search to find: an item whose field holds none has none, whatever its body
12585        // says, so no reader ever sees two answers.
12586        metadata.remove(ORIGIN_KEY);
12587        if let Some(origin) = &self.origin {
12588            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
12589        }
12590        metadata
12591    }
12592
12593    /// Where this item is, as a link a reader can open.
12594    ///
12595    /// A board is a hosted place and every issue on it has a web address, so that address
12596    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
12597    /// of place it is, so a reader knows to open it rather than to read a file out. It
12598    /// does not replace or derive from `url`: the field goes on reporting exactly what it
12599    /// reported before, and this says what that address *is*.
12600    ///
12601    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
12602    /// rather than a third variant, which is the contract's "the source did not say". An
12603    /// issue this run created is not one of those: its address comes back from the
12604    /// creating mutation, so it is somewhere a reader can open from the moment it exists
12605    /// rather than from whenever the board read catches up.
12606    fn location(&self) -> Option<Location> {
12607        self.url.clone().map(Location::Url)
12608    }
12609
12610    /// The short handle this board's backend shows people for a task: the issue's number
12611    /// alone, as a decimal string.
12612    ///
12613    /// The number alone rather than `owner/repo#1043`, because that is the contract's
12614    /// value for this backend. A draft has no number and so no handle, which is the
12615    /// contract's *absent* rather than a handle of some other shape — and the native
12616    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
12617    /// derives from.
12618    fn key(&self) -> Option<String> {
12619        self.number.map(|number| number.to_string())
12620    }
12621
12622    /// This item's change token — see [`change_token`] — or `None` when the read that
12623    /// reached it did not carry all six of its parts, which a draft never does, did not say
12624    /// when GitHub served it, or found it changed too recently to have settled.
12625    fn version(&self) -> Option<String> {
12626        match self.content_kind {
12627            ContentKind::Issue => {
12628                let (issue, item) = (self.updated_at?, self.item_updated_at?);
12629                let parts = change_token::Parts {
12630                    issue,
12631                    item,
12632                    comments: self.comment_count?,
12633                    relationships: self.relationship_events?,
12634                    facets: self.facets.clone()?,
12635                    repository: self.own_repository.clone()?,
12636                };
12637                change_token::settled(issue, item, self.served_at?)
12638                    .then(|| change_token::spell(&parts))
12639            }
12640            ContentKind::DraftIssue => None,
12641        }
12642    }
12643
12644    /// Whether its `Priority` field holds a value at all, mapped or not.
12645    fn holds_priority(&self) -> bool {
12646        self.priority != HeldPriority::Read(Priority::None)
12647    }
12648
12649    /// The task this item is.
12650    ///
12651    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
12652    /// reading that as a level would be a guess, and reading it as `none` would let the next
12653    /// copy clear a priority a person set.
12654    fn task(&self) -> Result<Task, SourceError> {
12655        let priority = match &self.priority {
12656            HeldPriority::Read(priority) => *priority,
12657            HeldPriority::Unmapped(option) => {
12658                return Err(SourceError::Malformed {
12659                    message: format!(
12660                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
12661                         this source's priority_mapping does not name, so its priority cannot be \
12662                         read; next: name {option:?} under priority_mapping, or move the item to \
12663                         a mapped option",
12664                        self.id,
12665                        self.number
12666                            .map(|number| format!(" (#{number})"))
12667                            .unwrap_or_default()
12668                    ),
12669                });
12670            }
12671        };
12672        Ok(Task {
12673            version: self.version(),
12674            id: self.id.clone(),
12675            key: self.key(),
12676            title: self.title.clone(),
12677            content: self.body.clone(),
12678            status: self.status.clone(),
12679            priority,
12680            labels: self.labels.clone(),
12681            project: self.parent.clone(),
12682            url: self.url.clone(),
12683            location: self.location(),
12684            created_at: self.created_at,
12685            updated_at: self.updated_at,
12686            metadata: self.metadata(),
12687            repositories: self.repositories.clone(),
12688            delivers: self.delivers.clone(),
12689            delivered_by: self.delivered_by.clone(),
12690            classification: self.classification,
12691        })
12692    }
12693
12694    fn project(&self) -> Project {
12695        Project {
12696            version: self.version(),
12697            id: self.id.clone(),
12698            title: self.title.clone(),
12699            content: self.body.clone(),
12700            status: self.status.clone(),
12701            labels: self.labels.clone(),
12702            url: self.url.clone(),
12703            location: self.location(),
12704            created_at: self.created_at,
12705            updated_at: self.updated_at,
12706            metadata: self.metadata(),
12707            repositories: self.repositories.clone(),
12708            classification: self.classification,
12709        }
12710    }
12711
12712    /// The same issue as a document: the project it is filed under, and no status and no
12713    /// dependencies, because a document is not work.
12714    fn document(&self) -> Document {
12715        Document {
12716            version: self.version(),
12717            id: self.id.clone(),
12718            title: self.title.clone(),
12719            content: self.body.clone(),
12720            project: self.parent.clone(),
12721            labels: self.labels.clone(),
12722            url: self.url.clone(),
12723            location: self.location(),
12724            created_at: self.created_at,
12725            updated_at: self.updated_at,
12726            metadata: self.metadata(),
12727            repositories: self.repositories.clone(),
12728            classification: self.classification,
12729        }
12730    }
12731}
12732
12733/// Where one targeted update moves an item's status, and which of its two halves move.
12734struct StatusMove {
12735    /// The board the item's `Status` field is on.
12736    board: BoardId,
12737    /// The `Status` field's id.
12738    field: String,
12739    /// The option's id.
12740    option: String,
12741    /// The option's name, as the board spells it.
12742    name: String,
12743    /// What the status asks of the issue's state.
12744    target: StatusTarget,
12745    /// The status the item reads as once it is there.
12746    landed: Status,
12747    /// Which of the status's two halves differ from what the item holds.
12748    moves: Moves,
12749}
12750
12751/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
12752/// closed state of its issue, or both. A status neither half of which differs is no move at all,
12753/// and is not a value of this type.
12754#[derive(Clone, Copy, PartialEq, Eq)]
12755enum Moves {
12756    /// The option alone.
12757    Option,
12758    /// The issue's state alone: open, closed, or closed with another reason.
12759    State,
12760    /// Both.
12761    Both,
12762}
12763
12764impl Moves {
12765    /// What differs, or `None` when nothing does.
12766    const fn of(option: bool, state: bool) -> Option<Self> {
12767        match (option, state) {
12768            (true, true) => Some(Self::Both),
12769            (true, false) => Some(Self::Option),
12770            (false, true) => Some(Self::State),
12771            (false, false) => None,
12772        }
12773    }
12774
12775    /// Whether the option moves.
12776    const fn option(self) -> bool {
12777        matches!(self, Self::Option | Self::Both)
12778    }
12779
12780    /// Whether the issue's state moves.
12781    const fn state(self) -> bool {
12782        matches!(self, Self::State | Self::Both)
12783    }
12784}
12785
12786/// What one write is, and the status that comes with being it.
12787///
12788/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
12789/// status and a task or a project always has one, so "a document carrying a status" and
12790/// "a task carrying none" are states a write cannot be in rather than states every use
12791/// site below has to defend against.
12792enum Written<'a> {
12793    /// A document, which is not work and so has no status at all.
12794    Document,
12795    /// A task or a project, and the status it is being written with.
12796    Work(ItemKind, &'a Status),
12797}
12798
12799impl Written<'_> {
12800    /// Which of the board's three kinds this write is.
12801    const fn kind(&self) -> BoardKind {
12802        match self {
12803            Self::Document => BoardKind::Document,
12804            Self::Work(kind, _) => BoardKind::Work(*kind),
12805        }
12806    }
12807
12808    /// The status this write carries. A document carries none, so a write of one says
12809    /// nothing about the issue's open or closed state and selects no board `Status`
12810    /// option.
12811    const fn status(&self) -> Option<&Status> {
12812        match self {
12813            Self::Document => None,
12814            Self::Work(_, status) => Some(status),
12815        }
12816    }
12817
12818    /// The status this write carries with the kind whose half of `status_mapping` it is
12819    /// written through.
12820    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
12821        match self {
12822            Self::Document => None,
12823            Self::Work(kind, status) => Some((*kind, status)),
12824        }
12825    }
12826}
12827
12828/// The item being written, in the one shape all three write methods reach.
12829struct Incoming<'a> {
12830    written: Written<'a>,
12831    /// The title a person wrote. A document's goes onto the issue with
12832    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
12833    title: &'a str,
12834    content: Option<&'a str>,
12835    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
12836    labels: &'a [Label],
12837    metadata: &'a BTreeMap<String, Value>,
12838    repositories: &'a [Repository],
12839    /// Recorded in the slot while private, and nowhere while public.
12840    classification: Classification,
12841    parent: Option<&'a NativeId>,
12842    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
12843    /// what keeps either key out of their slot.
12844    delivers: &'a [TaskRef],
12845    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
12846    delivered_by: &'a [TaskRef],
12847    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
12848    /// project, a document, and every write to an instance with no `priority_mapping` —
12849    /// which is what keeps such a write's requests exactly what they were before.
12850    priority: Option<Priority>,
12851}
12852
12853/// What one write does to one projected metadata text field, by the field's id.
12854#[derive(Debug, Clone)]
12855enum ProjectionWrite {
12856    /// Write this text.
12857    Set {
12858        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.
12859        text: String,
12860    },
12861    /// Clear the field's value.
12862    Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
12863}
12864
12865impl ProjectionWrite {
12866    /// Add this write to a field write's list of values and list of clears.
12867    fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
12868        match self {
12869            Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
12870            Self::Clear { field } => clears.push(field),
12871        }
12872    }
12873}
12874
12875/// What one write does to an item's `Priority` field.
12876enum PriorityWrite {
12877    /// Select this option of this field.
12878    Select {
12879        /// The `Priority` field's id.
12880        field: String,
12881        /// The mapped option's id.
12882        option: String,
12883    },
12884    /// Clear the field's value, which is what `none` is.
12885    Clear {
12886        /// The `Priority` field's id.
12887        field: String,
12888    },
12889}
12890
12891impl Incoming<'_> {
12892    /// The title this write puts on the issue.
12893    fn written_title(&self) -> String {
12894        match self.written {
12895            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
12896            Written::Work(..) => self.title.to_owned(),
12897        }
12898    }
12899}
12900
12901#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12902enum ContentKind {
12903    DraftIssue,
12904    Issue,
12905}
12906
12907/// What one board issue is: a document, or the work an [`ItemKind`] names.
12908///
12909/// A type of this source's own rather than an `ItemKind` with a third variant, because
12910/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
12911/// document — the contract keeps a document out of that enum deliberately. Holding the
12912/// board's three answers in one value is what makes every place that asks "which is this?"
12913/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
12914/// two thirds of the board.
12915#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12916enum BoardKind {
12917    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
12918    Document,
12919    /// Every other issue, and every draft.
12920    Work(ItemKind),
12921}
12922
12923impl BoardKind {
12924    /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
12925    /// sub-issues and carrying the kind marker `marked` in its slot.
12926    ///
12927    /// The design prefix is read *first*, before either of the two rules that separate a
12928    /// project from a task. A document is not work whatever sub-issues it has and whatever
12929    /// marker it carries, and reading the prefix later would make a design issue with none of
12930    /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
12931    /// overrides it: an issue filed under a project is that project's task even when it has
12932    /// sub-issues of its own.
12933    fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
12934        if title.starts_with(DESIGN_TITLE_PREFIX) {
12935            Self::Document
12936        } else if filed {
12937            Self::Work(ItemKind::Task)
12938        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
12939            Self::Work(ItemKind::Project)
12940        } else {
12941            Self::Work(ItemKind::Task)
12942        }
12943    }
12944
12945    /// What the change token's facets call this kind.
12946    const fn facet(self) -> change_token::FacetKind {
12947        match self {
12948            Self::Document => change_token::FacetKind::Document,
12949            Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
12950            Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
12951        }
12952    }
12953
12954    /// Whose half of `status_mapping` an item of this kind reads its status through. A
12955    /// document has no status of its own, so the task half stands in for whatever the issue
12956    /// holds; nothing reports it.
12957    const fn status_kind(self) -> ItemKind {
12958        match self {
12959            Self::Document => ItemKind::Task,
12960            Self::Work(kind) => kind,
12961        }
12962    }
12963
12964    /// How a refusal names this kind to the person reading it.
12965    const fn describes(self) -> &'static str {
12966        match self {
12967            Self::Document => "document",
12968            Self::Work(kind) => kind.marker(),
12969        }
12970    }
12971}
12972
12973/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
12974///
12975/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
12976/// the shared cross-source journeys assert one answer to one question, so two sources
12977/// that disagree about what "carries the label bug" means fail them.
12978fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
12979    let holds = |name: &String| {
12980        labels
12981            .iter()
12982            .any(|label| label.name.eq_ignore_ascii_case(name))
12983    };
12984    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
12985        && filter.all_of.iter().all(holds)
12986        && !filter.none_of.iter().any(holds)
12987}
12988
12989/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
12990/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
12991fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
12992    statuses.is_empty() || statuses.contains(&category)
12993}
12994
12995/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
12996///
12997/// `content` is the item's own prose — the body with this source's trailing metadata
12998/// comment already taken off — so a search never matches an encoding the author of the
12999/// issue never wrote.
13000fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
13001    let terms = query.terms.to_lowercase();
13002    let in_title = title.to_lowercase().contains(&terms);
13003    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
13004    match query.fields {
13005        TextFields::Title => in_title,
13006        TextFields::Content => in_content,
13007        TextFields::TitleOrContent => in_title || in_content,
13008    }
13009}
13010
13011/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
13012///
13013/// The project predicate is passed separately because a read narrowed to one project has
13014/// already answered it by asking *that project* for its own items — and re-applying it
13015/// there would compare the caller's selector, which may be a project's **name**, against
13016/// the id of the project that name resolved to, and keep nothing. Every other read passes
13017/// `query.project` and applies it here, which is what keeps `projects` a predicate this
13018/// source really does apply.
13019fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
13020    labels_match(&task.labels, &query.labels)
13021        && status_matches(task.status.category, &query.statuses)
13022        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
13023        && match project {
13024            ProjectFilter::Any => true,
13025            ProjectFilter::Orphans => task.project.is_none(),
13026            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
13027        }
13028        && query
13029            .text
13030            .as_ref()
13031            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
13032        // Against the parsed metadata slot, and against the origin field, which is where
13033        // `Resolved::metadata` reads each of them from.
13034        && query.metadata_matches(&task.metadata)
13035        && query.origin_matches(&task.metadata)
13036}
13037
13038fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
13039    labels_match(&project.labels, &query.labels)
13040        && status_matches(project.status.category, &query.statuses)
13041        && query
13042            .text
13043            .as_ref()
13044            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
13045}
13046
13047/// The same three predicates a task query carries, minus the status filter.
13048///
13049/// A document is not work, so it has no status for one to compare against and the query
13050/// type carries none. The project predicate is the same one — a design issue filed under a
13051/// project issue is in that project, and one filed under nothing is in none — so it is
13052/// spelled the same way here rather than answered differently.
13053fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
13054    labels_match(&document.labels, &query.labels)
13055        && match project {
13056            ProjectFilter::Any => true,
13057            ProjectFilter::Orphans => document.project.is_none(),
13058            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
13059        }
13060        && query
13061            .text
13062            .as_ref()
13063            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
13064}
13065
13066/// Why a GitHub item's change token does not vouch for its forward edges.
13067///
13068/// A forward edge names its far end and that far end's kind, and the near item's token moves
13069/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
13070/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
13071/// nothing the near token reads, so an edge kept at the near version could name a far end that
13072/// is no longer what it said.
13073const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
13074     whether it became a document, or its deletion, so its forward edges are always read from \
13075     github";
13076
13077/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
13078/// categories an open issue reads as narrows a board-scoped search with.
13079const OPEN_QUALIFIER: &str = "is:open";
13080
13081/// The board filter `task status reconcile` reads the board through: closed items alone.
13082const CLOSED_FILTER: &str = "is:closed";
13083
13084/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
13085/// one reference, and a slot the batch has none for names its last one again.
13086fn reference_batch(batch: &[IssueRef]) -> Value {
13087    let mut variables = serde_json::Map::new();
13088    for slot in 0..REFERENCE_BATCH {
13089        let issue = batch.get(slot).or(batch.last());
13090        variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
13091        variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
13092        variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
13093    }
13094    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13095    variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
13096    variables.insert("duplicates".to_owned(), json!(true));
13097    Value::Object(variables)
13098}
13099
13100/// One issue an issue read by reference answered, in the shape every other read of an issue
13101/// hands its resolver: each board membership's `Status` value, read by name, as the one field
13102/// value that membership carries.
13103///
13104/// A membership's `status` is `null` when its card sits at no option — or on a board with no
13105/// field of that name — and otherwise an option value naming its option; anything else is not
13106/// an answer to this read, and is refused rather than read as a card at no option.
13107fn held_issue(issue: &Value) -> Result<Value, SourceError> {
13108    let malformed = |what: &str| SourceError::Malformed {
13109        message: format!("GitHub answered an issue read by reference with {what}"),
13110    };
13111    let mut issue = issue.clone();
13112    let nodes = issue
13113        .pointer_mut("/projectItems/nodes")
13114        .and_then(Value::as_array_mut)
13115        .ok_or_else(|| malformed("no list of board memberships"))?;
13116    for node in nodes {
13117        let node = node
13118            .as_object_mut()
13119            .ok_or_else(|| malformed("a board membership that is not an object"))?;
13120        let status = match node.get("status") {
13121            None => return Err(malformed("a board membership with no status member")),
13122            Some(Value::Null) => None,
13123            Some(status) if status.get("name").is_some_and(Value::is_string) => {
13124                Some(status.clone())
13125            }
13126            // A single-select value of another field type answers `{}`; only an option value
13127            // carries a name.
13128            Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
13129            Some(_) => return Err(malformed("a membership status that names no option")),
13130        };
13131        node.insert(
13132            "fieldValues".to_owned(),
13133            json!({"nodes": status.into_iter().collect::<Vec<_>>(),
13134                   "pageInfo": {"hasNextPage": false}}),
13135        );
13136    }
13137    Ok(issue)
13138}
13139
13140/// The issue one slot of a read by reference answered, `None` when GitHub answered that
13141/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
13142/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
13143/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
13144/// than a reference nothing holds.
13145fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
13146    let alias = format!("r{slot}");
13147    let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
13148        message: format!("GitHub answered a read by reference with no {alias}"),
13149    })?;
13150    if repository.is_null() {
13151        return Ok(None);
13152    }
13153    let issue = repository
13154        .get("issue")
13155        .ok_or_else(|| SourceError::Malformed {
13156            message: format!("GitHub answered {alias} of a read by reference with no issue"),
13157        })?;
13158    if issue.is_null() {
13159        return Ok(None);
13160    }
13161    // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
13162    if optional_str(issue, "__typename")? != Some("Issue") {
13163        return Err(SourceError::Malformed {
13164            message: format!(
13165                "GitHub answered {alias} of a read by reference with an issue whose __typename \
13166                 is not Issue"
13167            ),
13168        });
13169    }
13170    Ok(Some(issue))
13171}
13172
13173/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
13174fn spoken_reason(reason: &str) -> String {
13175    reason.to_ascii_lowercase().replace('_', " ")
13176}
13177
13178impl GitHubProjectsSource {
13179    /// Every task and project of this board whose card contradicts its issue's closure, each
13180    /// judged against its own kind's half of `status_mapping`, read through the board's own
13181    /// `is:closed` filter, page by page — never the board's unfiltered `items`.
13182    ///
13183    /// Each item the filter answers is confirmed from its own node: an open one, one whose
13184    /// closure reads `unknown`, and one whose card is already in line are left out whatever
13185    /// the filter let through. The board's fields ride along and are held for the option
13186    /// writes that follow, so those need no read of the board.
13187    async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
13188        let mut after: Option<String> = None;
13189        let mut found = Vec::new();
13190        loop {
13191            let data = self
13192                .graphql(
13193                    graphql::CLOSED_ITEMS,
13194                    json!({"owner":self.owner,"number":self.project_number,
13195                           "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
13196                           "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
13197                )
13198                .await?;
13199            let board = data
13200                .pointer("/closedItems/projectV2")
13201                .filter(|value| !value.is_null())
13202                .ok_or_else(|| SourceError::Refused {
13203                    message: format!(
13204                        "GitHub project {}/{} was not found or is not visible to the token",
13205                        self.owner, self.project_number
13206                    ),
13207                })?;
13208            *self.fields_cache()? = Some(BoardFields {
13209                id: BoardId::parse(required_str(board, "id")?)?,
13210                fields: board.get("fields").cloned().unwrap_or(Value::Null),
13211            });
13212            let items = board.get("items").ok_or_else(|| SourceError::Malformed {
13213                message: "GitHub project closed items are missing".into(),
13214            })?;
13215            for item in items
13216                .get("nodes")
13217                .and_then(Value::as_array)
13218                .ok_or_else(|| SourceError::Malformed {
13219                    message: "GitHub project items.nodes is not an array".into(),
13220                })?
13221            {
13222                let Some(resolved) = self.resolve(item)? else {
13223                    continue;
13224                };
13225                let BoardKind::Work(kind) = resolved.kind else {
13226                    continue;
13227                };
13228                let Some((closed_as, name)) = self.statuses.contradicted(
13229                    kind,
13230                    resolved.option.as_deref(),
13231                    resolved.closed,
13232                    resolved.reason.as_deref(),
13233                ) else {
13234                    continue;
13235                };
13236                found.push(StatusContradiction {
13237                    closed_as,
13238                    to: name.to_owned(),
13239                    option: resolved.option.clone(),
13240                    item: match kind {
13241                        ItemKind::Task => ContradictedItem::Task(resolved.task()?),
13242                        ItemKind::Project => ContradictedItem::Project(resolved.project()),
13243                    },
13244                });
13245            }
13246            let info = items
13247                .get("pageInfo")
13248                .ok_or_else(|| SourceError::Malformed {
13249                    message: "GitHub project items have no pageInfo".into(),
13250                })?;
13251            if !required_bool(info, "hasNextPage")? {
13252                return Ok(found);
13253            }
13254            let next = required_str(info, "endCursor")?;
13255            validate_cursor_progress(after.as_deref(), next)?;
13256            after = Some(next.to_owned());
13257        }
13258    }
13259
13260    /// What this board holds for each issue in `issues`, once each in the order given, read
13261    /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
13262    ///
13263    /// An issue GitHub cannot find for this source's credential, and one with no item on this
13264    /// board, are both absent. A closure that already says the work will not be done here —
13265    /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
13266    /// completed whose card sits at its kind's `done` option is unchanged, and every other item
13267    /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
13268    /// tasks as sub-issues — is held under exactly these rules, judged against the project
13269    /// kind's half of `status_mapping`.
13270    async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
13271        let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
13272        for issue in issues {
13273            if !distinct.contains(issue) {
13274                distinct.push(issue.clone());
13275            }
13276        }
13277        let done = |kind: ItemKind| {
13278            self.statuses
13279                .target(kind, StatusCategory::Done)
13280                .option()
13281                .unwrap_or("Done")
13282                .to_owned()
13283        };
13284        // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
13285        // its range names no issue any repository can hold: it is answered absent and never sent,
13286        // where binding it would have GitHub refuse the whole batch.
13287        let readable: Vec<IssueRef> = distinct
13288            .iter()
13289            .filter(|issue| i32::try_from(issue.number).is_ok())
13290            .cloned()
13291            .collect();
13292        let mut holdings = Vec::with_capacity(readable.len());
13293        for batch in readable.chunks(REFERENCE_BATCH) {
13294            let data = self
13295                .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
13296                .await?;
13297            for (slot, issue) in batch.iter().enumerate() {
13298                let node = referenced_issue(&data, slot)?;
13299                let resolved = match node {
13300                    Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
13301                    None => None,
13302                };
13303                // A task and a project are both held; a document is not work, so nothing is.
13304                let Some((item, kind)) = resolved.and_then(|item| match item.kind {
13305                    BoardKind::Work(kind) => Some((item, kind)),
13306                    BoardKind::Document => None,
13307                }) else {
13308                    holdings.push(IssueHolding {
13309                        issue: issue.clone(),
13310                        completion: Completion::Absent,
13311                    });
13312                    continue;
13313                };
13314                let task = match kind {
13315                    ItemKind::Task => {
13316                        let held = item.task()?;
13317                        HeldTask {
13318                            id: held.id,
13319                            kind,
13320                            option: item.option.clone(),
13321                            classification: held.classification,
13322                            repositories: held.repositories,
13323                            delivers: held.delivers,
13324                        }
13325                    }
13326                    // A project delivers no task, so what its write keeps in step is nothing.
13327                    ItemKind::Project => {
13328                        let held = item.project();
13329                        HeldTask {
13330                            id: held.id,
13331                            kind,
13332                            option: item.option.clone(),
13333                            classification: held.classification,
13334                            repositories: held.repositories,
13335                            delivers: Vec::new(),
13336                        }
13337                    }
13338                };
13339                let done = done(kind);
13340                let completion = match (item.closed, item.reason.as_deref()) {
13341                    (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
13342                        task,
13343                        reason: format!("closed as {}", spoken_reason(reason)),
13344                    },
13345                    (true, None | Some("COMPLETED"))
13346                        if item
13347                            .option
13348                            .as_deref()
13349                            .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
13350                    {
13351                        Completion::Unchanged { task }
13352                    }
13353                    _ => {
13354                        let kept = item.clone();
13355                        self.held_cache
13356                            .lock()
13357                            .map_err(|_| SourceError::Unavailable {
13358                                message: "items read by issue reference were left \
13359                                          inconsistent; run the command again"
13360                                    .into(),
13361                            })?
13362                            .insert(kept.id.clone(), kept);
13363                        Completion::Due {
13364                            task,
13365                            to: done.clone(),
13366                        }
13367                    }
13368                };
13369                holdings.push(IssueHolding {
13370                    issue: issue.clone(),
13371                    completion,
13372                });
13373            }
13374        }
13375        let mut read = holdings.into_iter();
13376        let mut answered = Vec::with_capacity(distinct.len());
13377        for issue in distinct {
13378            if i32::try_from(issue.number).is_ok() {
13379                answered.push(read.next().ok_or_else(|| SourceError::Malformed {
13380                    message: format!("no holding was read for {issue}"),
13381                })?);
13382            } else {
13383                answered.push(IssueHolding {
13384                    issue,
13385                    completion: Completion::Absent,
13386                });
13387            }
13388        }
13389        Ok(answered)
13390    }
13391}
13392
13393#[async_trait::async_trait]
13394impl TaskSource for GitHubProjectsSource {
13395    fn kind(&self) -> &'static str {
13396        KIND
13397    }
13398    fn attach_body_markup(&self, markup: onetaskgraph_plugin_api::SharedBodyMarkup) {
13399        let _ = self.body_markup.set(markup);
13400    }
13401    fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
13402        change_token::order(a, b)
13403    }
13404    /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
13405    /// member, the change each part of the token moves for. Not its forward edges — see
13406    /// [`FAR_ENDS_UNCOVERED`].
13407    fn version_coverage(&self) -> VersionCoverage {
13408        VersionCoverage {
13409            forward_edges: Coverage::Uncovered {
13410                reason: FAR_ENDS_UNCOVERED.to_owned(),
13411            },
13412            ..VersionCoverage::whole()
13413        }
13414    }
13415    /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
13416    /// and for a far end deleted, so the token sees every change to a task's edges but one — a
13417    /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
13418    /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
13419    /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
13420    // 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.
13421    fn copy_version_coverage(&self) -> VersionCoverage {
13422        VersionCoverage::whole()
13423    }
13424    /// A project's fields, and not its forward edges, for the same reason a task's are not.
13425    fn project_version_coverage(&self) -> ProjectVersionCoverage {
13426        ProjectVersionCoverage {
13427            fields: Coverage::Covered,
13428            forward_edges: Coverage::Uncovered {
13429                reason: FAR_ENDS_UNCOVERED.to_owned(),
13430            },
13431        }
13432    }
13433    /// A document's fields and the manifest of its image assets, which its metadata slot
13434    /// records: both are the issue's body, whose every edit moves the token's issue time.
13435    fn document_version_coverage(&self) -> DocumentVersionCoverage {
13436        DocumentVersionCoverage::whole()
13437    }
13438    fn write_context(&self, id: &NativeId) -> Option<Value> {
13439        self.context_of(id)
13440    }
13441    fn board_context(&self) -> Option<Value> {
13442        self.board_context_held()
13443    }
13444    fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
13445        self.prime(id, context, board)
13446    }
13447    fn unprime(&self, id: &NativeId) {
13448        self.unprime_held(id);
13449    }
13450    fn placement_context(&self) -> Option<Value> {
13451        self.placement_held()
13452    }
13453    fn prime_placement(&self, context: &Value) -> bool {
13454        self.prime_placement_held(context)
13455    }
13456    fn written_context(&self, id: &NativeId) -> Option<Value> {
13457        self.written_held(id)
13458    }
13459    fn prime_written(&self, id: &NativeId, context: &Value, task: &Task) -> bool {
13460        self.prime_written_held(id, context, task)
13461    }
13462    fn begin_journal(&self) {
13463        self.expected.begin();
13464    }
13465    fn end_journal(&self) -> Option<WriteJournal> {
13466        Some(self.expected.end())
13467    }
13468    fn capabilities(&self) -> Capabilities {
13469        Capabilities {
13470            projects: Support::Native,
13471            documents: Support::Native,
13472            comments: Support::Native,
13473            assets: Support::Native,
13474            priority: if self.priorities.is_some() {
13475                Support::Native
13476            } else {
13477                Support::Unsupported
13478            },
13479            filter_by_priority: Support::Native,
13480            filter_by_comment_activity: Support::Native,
13481            filter_by_metadata: Support::Native,
13482            filter_by_origin: Support::Native,
13483            reconcile_status: Support::Native,
13484            complete_by_issue: Support::Native,
13485            orphan_tasks: Support::Native,
13486            filter_by_label: Support::Native,
13487            filter_by_status: Support::Native,
13488            search_title: Support::Native,
13489            search_content: Support::Native,
13490            task_dependencies: DependencySupport::BothDirections,
13491            project_dependencies: DependencySupport::BothDirections,
13492            max_page_size: MAX_PAGE_SIZE,
13493        }
13494    }
13495    async fn visibility(
13496        &self,
13497        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
13498    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
13499        self.write_visibility(target).await
13500    }
13501    async fn visibility_with_project(
13502        &self,
13503        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
13504        project: &onetaskgraph_plugin_api::WriteTarget<'_>,
13505    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
13506        self.write_visibility_with_project(target, project).await
13507    }
13508    fn declare_visibility(&self, declared: onetaskgraph_plugin_api::Visibility) {
13509        self.declared_private.store(
13510            declared == onetaskgraph_plugin_api::Visibility::Private,
13511            std::sync::atomic::Ordering::SeqCst,
13512        );
13513    }
13514    fn scope_visibility<'a>(
13515        &'a self,
13516        operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
13517    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
13518        self.scope_visibility_reads(operation)
13519    }
13520
13521    /// Where a create of `target` would file its issue, by [`Self::creation_rule`] — the rule
13522    /// the create applies — reading the parent project's issue only when `target` names one.
13523    async fn creation_repository(
13524        &self,
13525        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
13526    ) -> Result<Option<Repository>, SourceError> {
13527        let onetaskgraph_plugin_api::WriteTarget::New {
13528            repositories,
13529            project,
13530        } = target
13531        else {
13532            return Ok(None);
13533        };
13534        let target = self
13535            .creation_rule(repositories, *project, "a new item")
13536            .await?;
13537        Repository::try_from(target.origin())
13538            .map(Some)
13539            .map_err(|message| SourceError::Malformed { message })
13540    }
13541    /// Every issue of the repository a create of `target` files in whose body carries `key` in
13542    /// its metadata slot, read off that repository's issues newest first back to `since` —
13543    /// never through search, whose index lags a fresh issue by seconds.
13544    async fn created_with_key(
13545        &self,
13546        record: onetaskgraph_plugin_api::MetadataRecord,
13547        key: &str,
13548        since: Option<DateTime<Utc>>,
13549        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
13550    ) -> Result<Vec<NativeId>, SourceError> {
13551        let _ = record;
13552        let onetaskgraph_plugin_api::WriteTarget::New {
13553            repositories,
13554            project,
13555        } = target
13556        else {
13557            return Ok(Vec::new());
13558        };
13559        let repository = self
13560            .creation_rule(repositories, *project, "a new item")
13561            .await?;
13562        let wanted = Value::String(key.to_owned());
13563        let mut found = Vec::new();
13564        let mut after: Option<String> = None;
13565        let mut followed = std::collections::HashSet::new();
13566        loop {
13567            let data = self
13568                .graphql(
13569                    graphql::ISSUES_BY_CREATION,
13570                    json!({"owner": repository.owner, "name": repository.name,
13571                           "first": MAX_PAGE_SIZE, "after": after}),
13572                )
13573                .await?;
13574            let issues =
13575                data.pointer("/repository/issues")
13576                    .ok_or_else(|| SourceError::Malformed {
13577                        message: format!(
13578                            "GitHub answered no issues for repository {}",
13579                            repository.slug()
13580                        ),
13581                    })?;
13582            let malformed = |what: &str| SourceError::Malformed {
13583                message: format!(
13584                    "GitHub answered a page of repository {}'s issues {what}",
13585                    repository.slug()
13586                ),
13587            };
13588            let nodes = issues
13589                .get("nodes")
13590                .and_then(Value::as_array)
13591                .ok_or_else(|| malformed("with no list of issues"))?;
13592            let mut older = false;
13593            for node in nodes {
13594                let id = node
13595                    .get("id")
13596                    .and_then(Value::as_str)
13597                    .filter(|id| !id.is_empty())
13598                    .ok_or_else(|| malformed("holding an issue with no id"))?;
13599                let created = optional_time(node, "createdAt")?
13600                    .ok_or_else(|| malformed("holding an issue with no creation time"))?;
13601                if since.is_some_and(|since| created < since) {
13602                    older = true;
13603                    break;
13604                }
13605                let body = match node.get("body") {
13606                    Some(Value::String(body)) => Some(body.clone()),
13607                    Some(Value::Null) => None,
13608                    _ => return Err(malformed("holding an issue with no body member")),
13609                };
13610                let (_, metadata) = metadata_body(body)?;
13611                if metadata.get(MetadataKey::CREATE_KEY) == Some(&wanted) {
13612                    found.push(NativeId(id.to_owned()));
13613                }
13614            }
13615            let next = issues
13616                .pointer("/pageInfo/hasNextPage")
13617                .and_then(Value::as_bool)
13618                .ok_or_else(|| malformed("that does not say whether another follows"))?;
13619            if older || !next {
13620                return Ok(found);
13621            }
13622            // A page that says another follows names where it starts, and never a page already
13623            // read: anything else would ask for the same pages again for ever.
13624            let cursor = issues
13625                .pointer("/pageInfo/endCursor")
13626                .and_then(Value::as_str)
13627                .filter(|cursor| !cursor.is_empty())
13628                .map(str::to_owned);
13629            if cursor
13630                .as_ref()
13631                .is_none_or(|cursor| !followed.insert(cursor.clone()))
13632            {
13633                return Err(SourceError::Malformed {
13634                    message: format!(
13635                        "GitHub answered a page of repository {}'s issues that says another \
13636                         follows without a new cursor to it",
13637                        repository.slug()
13638                    ),
13639                });
13640            }
13641            after = cursor;
13642        }
13643    }
13644
13645    /// `Repository.issues` lists an issue GitHub committed only once its listing catches up.
13646    fn create_key_lags(&self) -> bool {
13647        true
13648    }
13649
13650    /// The task as this process's own latest write of it left it, from the item it recorded
13651    /// that write as — never a request. Its `version` is always `None`: a board's change token
13652    /// is given only once an item has gone unchanged for [`change_token::SETTLED`], which an
13653    /// item this command has just written has not, so no write's own answer can vouch for one.
13654    async fn written_task(&self, id: &NativeId) -> Option<Task> {
13655        let held = self
13656            .created()
13657            .ok()?
13658            .iter()
13659            .find(|held| held.id == *id)
13660            .cloned()
13661            .or_else(|| {
13662                self.updated()
13663                    .ok()?
13664                    .iter()
13665                    .find(|held| held.id == *id)
13666                    .cloned()
13667            })?;
13668        let mut task = held.task().ok()?;
13669        task.version = None;
13670        Some(task)
13671    }
13672
13673    /// Why `creation_owners` refuses a create of `target`, by [`Self::creation_rule`] — read
13674    /// only when the list is not empty, so a source restricting nothing reads nothing.
13675    async fn creation_refusal(
13676        &self,
13677        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
13678    ) -> Result<Option<String>, SourceError> {
13679        let onetaskgraph_plugin_api::WriteTarget::New {
13680            repositories,
13681            project,
13682        } = target
13683        else {
13684            return Ok(None);
13685        };
13686        if self.creation_owners.is_empty() {
13687            return Ok(None);
13688        }
13689        let target = self
13690            .creation_rule(repositories, *project, "a new item")
13691            .await?;
13692        Ok(self.owner_refusal(&target))
13693    }
13694
13695    async fn health(&self) -> Result<Health, SourceError> {
13696        let board = self.board_page(None, 1).await?;
13697        Ok(Health {
13698            reachable: true,
13699            detail: Some(format!(
13700                "reading GitHub project {}/{} ({})",
13701                self.owner,
13702                self.project_number,
13703                required_str(&board, "title")?
13704            )),
13705        })
13706    }
13707    /// The board's own node id, digested: the one request reading its fields, or none when
13708    /// this command has read them already.
13709    async fn designator(&self) -> Result<Option<Designator>, SourceError> {
13710        let board = self.board_fields().await?;
13711        Designator::of_backend(KIND, board.id.as_str())
13712            .map(Some)
13713            .map_err(|message| SourceError::Malformed { message })
13714    }
13715    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
13716        self.item_by_id(id)
13717            .await?
13718            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
13719            .map(|item| item.task())
13720            .transpose()
13721    }
13722    async fn task_assets(
13723        &self,
13724        id: &NativeId,
13725    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
13726        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
13727    }
13728    async fn task_asset(
13729        &self,
13730        id: &NativeId,
13731        name: &onetaskgraph_plugin_api::AssetName,
13732    ) -> Result<Option<Vec<u8>>, SourceError> {
13733        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
13734            .await
13735    }
13736    async fn set_task_rendering_with_assets(
13737        &self,
13738        id: &NativeId,
13739        content: &str,
13740        provenance: &Value,
13741        _answers: &BTreeMap<String, Value>,
13742        assets: &onetaskgraph_plugin_api::AssetWrite,
13743    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
13744        self.replace_rendering(
13745            id,
13746            BoardKind::Work(ItemKind::Task),
13747            content,
13748            provenance,
13749            Some(assets),
13750        )
13751        .await
13752    }
13753    async fn document_assets(
13754        &self,
13755        id: &NativeId,
13756    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
13757        self.held_assets(id, BoardKind::Document).await
13758    }
13759    async fn document_asset(
13760        &self,
13761        id: &NativeId,
13762        name: &onetaskgraph_plugin_api::AssetName,
13763    ) -> Result<Option<Vec<u8>>, SourceError> {
13764        self.held_asset(id, BoardKind::Document, name).await
13765    }
13766    async fn set_document_rendering_with_assets(
13767        &self,
13768        id: &NativeId,
13769        content: &str,
13770        provenance: &Value,
13771        _answers: &BTreeMap<String, Value>,
13772        assets: &onetaskgraph_plugin_api::AssetWrite,
13773    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
13774        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
13775            .await
13776    }
13777    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
13778        Ok(self
13779            .item_by_id(id)
13780            .await?
13781            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
13782            .map(|item| item.project()))
13783    }
13784    async fn query_tasks(
13785        &self,
13786        query: &TaskQuery,
13787        page: &PageRequest,
13788    ) -> Result<Page<Task>, SourceError> {
13789        validate_page(page)?;
13790        refuse_unsearchable(query)?;
13791        // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
13792        // category — `unknown`; every other category reads only off an open issue or a draft. So
13793        // a status filter naming none of those three asks every issue search it sends for open
13794        // issues alone; each candidate's category is still confirmed from its own item below.
13795        let open_only = !query.statuses.is_empty()
13796            && query.statuses.iter().all(|category| {
13797                !matches!(
13798                    category,
13799                    StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
13800                )
13801            });
13802        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
13803            let mut qualifiers: Vec<String> = [
13804                query.commented_since.map(updated_qualifier),
13805                narrowing_qualifiers(query),
13806            ]
13807            .into_iter()
13808            .flatten()
13809            .collect();
13810            // A narrowed search never answers a draft, which is no issue, so narrowing it to
13811            // open issues too leaves out nothing such a search could have answered.
13812            if !qualifiers.is_empty() {
13813                if open_only {
13814                    qualifiers.push(OPEN_QUALIFIER.to_owned());
13815                }
13816                return self.search_tasks(query, page, &qualifiers.join(" ")).await;
13817            }
13818        }
13819
13820        // A read narrowed to one project asks that project for its own tasks, so nothing
13821        // about it costs what the rest of the board holds. A read carrying a text, metadata
13822        // or origin predicate asks GitHub the narrower question those predicates are, and a
13823        // read narrowed to comment activity alone asks the board's own issue search for the
13824        // issues updated since, which is every issue a comment could have been written or
13825        // edited on since. Every other task read is a question about the whole board and is
13826        // answered by reading it.
13827        let (held, membership) = match (&query.project, query.commented_since) {
13828            (ProjectFilter::Is(project), _) => (
13829                match self.held_task_children(project)? {
13830                    // Taken as `project_children` takes the project's own sub-issue read: with
13831                    // every item this process wrote under it since, and none it moved away.
13832                    Some(held) => self.completed_with_written(
13833                        held.into_iter()
13834                            .filter(|child| child.parent.as_ref() == Some(project))
13835                            .collect(),
13836                        |own| own.parent.as_ref() == Some(project),
13837                    )?,
13838                    None => self.project_children(project).await?,
13839                },
13840                // Answered by where these items came from; see `task_matches`.
13841                &ProjectFilter::Any,
13842            ),
13843            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
13844                match (self.narrowed(query).await?, since) {
13845                    (Some(narrowed), _) => (narrowed, &query.project),
13846                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
13847                    // The board's own items list every draft; only the search half, which
13848                    // completes them with issues they lag behind on, is narrowed — a closed
13849                    // issue that half leaves out is at no category the filter keeps.
13850                    (None, None) if open_only => (
13851                        self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
13852                        &query.project,
13853                    ),
13854                    (None, None) => (self.board().await?.items, &query.project),
13855                }
13856            }
13857        };
13858        // Filtered before paged: a page of a filtered result is a page of the survivors,
13859        // never the survivors of a page.
13860        let mut tasks = Vec::new();
13861        for item in held
13862            .iter()
13863            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
13864        {
13865            let task = item.task()?;
13866            if task_matches(&task, query, membership)
13867                && self.commented_since(item, query.commented_since).await?
13868            {
13869                tasks.push(task);
13870            }
13871        }
13872        Ok(offset_page(
13873            tasks,
13874            numeric_cursor(page.cursor.as_ref())?,
13875            page.limit.min(MAX_PAGE_SIZE) as usize,
13876        ))
13877    }
13878    /// Read board pages directly, at the board connection's consistency. Unlike
13879    /// `query_tasks`, this does not complete a lagging board with issue search: a complete
13880    /// sweep needs one request per listing page, including the first comments.
13881    async fn query_task_details(
13882        &self,
13883        query: &TaskQuery,
13884        page: &PageRequest,
13885        comments: Option<&PageRequest>,
13886    ) -> Result<Page<TaskDetailRead>, SourceError> {
13887        let Some(comments) = comments else {
13888            let tasks = self.query_tasks(query, page).await?;
13889            return Ok(Page {
13890                items: tasks
13891                    .items
13892                    .into_iter()
13893                    .map(|task| TaskDetailRead {
13894                        task,
13895                        comments: None,
13896                    })
13897                    .collect(),
13898                next: tasks.next,
13899            });
13900        };
13901        validate_page(page)?;
13902        validate_page(comments)?;
13903        refuse_unsearchable(query)?;
13904        if comments.cursor.is_some() || query.commented_since.is_some() {
13905            return Err(SourceError::Refused {
13906                message: "a board listing with comments requires a first comment page and cannot filter by comment activity".into(),
13907            });
13908        }
13909        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
13910        let data = self
13911            .graphql(
13912                graphql::BOARD_WITH_COMMENTS,
13913                json!({
13914                    "owner":self.owner,"number":self.project_number,
13915                    "first":page.limit.min(MAX_PAGE_SIZE),"after":after,
13916                    "commentFirst":comments.limit.min(MAX_PAGE_SIZE),
13917                    "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true,
13918                }),
13919            )
13920            .await?;
13921        let connection = data
13922            .pointer("/owner/projectV2/items")
13923            .filter(|value| !value.is_null())
13924            .ok_or_else(|| SourceError::Refused {
13925                message: "GitHub project was not found or is not visible to the token".into(),
13926            })?;
13927        let mut items = Vec::new();
13928        for node in optional_nodes(Some(connection), "board items")?
13929            .into_iter()
13930            .flatten()
13931        {
13932            let Some(item) = self.resolve(node)? else {
13933                continue;
13934            };
13935            if item.kind != BoardKind::Work(ItemKind::Task) {
13936                continue;
13937            }
13938            let task = item.task()?;
13939            if !task_matches(&task, query, &query.project) {
13940                continue;
13941            }
13942            let read = if item.content_kind == ContentKind::DraftIssue {
13943                Err(self.draft_has_no_comments(&task.id))
13944            } else {
13945                self.expected.learn_comments(&task.id, &node["content"]);
13946                comment_page(&node["content"], &task.id.0, None).map(Some)
13947            };
13948            items.push(TaskDetailRead {
13949                task,
13950                comments: Some(read),
13951            });
13952        }
13953        let next = next_cursor(connection)?;
13954        if let Some(next) = &next {
13955            validate_cursor_progress(after, &next.0)?;
13956        }
13957        Ok(Page { items, next })
13958    }
13959
13960    async fn query_projects(
13961        &self,
13962        query: &ProjectQuery,
13963        page: &PageRequest,
13964    ) -> Result<Page<Project>, SourceError> {
13965        validate_page(page)?;
13966        refuse_unsearchable_text(query.text.as_ref())?;
13967        // The projects a board holds are found by an issue search scoped to that board,
13968        // never by walking the board's own item connection: what tells a project from a
13969        // task is the `parent` each issue carries, which costs nothing to read. A query
13970        // carrying a text asks that search for the text too, so it reads the issues that
13971        // hold it rather than every issue of the board.
13972        let held = match self.text_searched(query.text.as_ref()).await? {
13973            Some(searched) => searched,
13974            None => self.board_issues().await?,
13975        };
13976        let projects = held
13977            .iter()
13978            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
13979            .map(Resolved::project)
13980            .filter(|project| project_matches(project, query))
13981            .collect();
13982        Ok(offset_page(
13983            projects,
13984            numeric_cursor(page.cursor.as_ref())?,
13985            page.limit.min(MAX_PAGE_SIZE) as usize,
13986        ))
13987    }
13988    async fn query_projects_by_origin(
13989        &self,
13990        origin: &str,
13991        page: &PageRequest,
13992    ) -> Result<Page<Project>, SourceError> {
13993        validate_page(page)?;
13994        let projects = self
13995            .copied_from(origin, BoardKind::Work(ItemKind::Project))
13996            .await?
13997            .iter()
13998            .map(Resolved::project)
13999            .collect();
14000        Ok(offset_page(
14001            projects,
14002            numeric_cursor(page.cursor.as_ref())?,
14003            page.limit.min(MAX_PAGE_SIZE) as usize,
14004        ))
14005    }
14006    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
14007        let Some(item) = self
14008            .document_by_id(id)
14009            .await?
14010            .filter(|item| item.kind == BoardKind::Document)
14011        else {
14012            return Ok(None);
14013        };
14014        self.expected.read_document(&item);
14015        Ok(Some(item.document()))
14016    }
14017    async fn query_documents_by_origin(
14018        &self,
14019        origin: &str,
14020        page: &PageRequest,
14021    ) -> Result<Page<Document>, SourceError> {
14022        validate_page(page)?;
14023        let documents = self
14024            .copied_from(origin, BoardKind::Document)
14025            .await?
14026            .iter()
14027            .map(Resolved::document)
14028            .collect();
14029        Ok(offset_page(
14030            documents,
14031            numeric_cursor(page.cursor.as_ref())?,
14032            page.limit.min(MAX_PAGE_SIZE) as usize,
14033        ))
14034    }
14035    async fn query_documents(
14036        &self,
14037        query: &DocumentQuery,
14038        page: &PageRequest,
14039    ) -> Result<Page<Document>, SourceError> {
14040        validate_page(page)?;
14041        // Narrowed to one project, this is the same sub-issue read a task list scoped to
14042        // that project makes — a document filed under a project is a sub-issue of it too,
14043        // and which of them come back is the kind this caller asked for. Unscoped, a query
14044        // carrying a text asks the board-scoped issue search for it, as a task query does,
14045        // and only one carrying none reads the board.
14046        let (held, membership) = match &query.project {
14047            ProjectFilter::Is(project) => (
14048                self.project_children(project).await?,
14049                // Answered by where these items came from; see `task_matches`.
14050                &ProjectFilter::Any,
14051            ),
14052            ProjectFilter::Any | ProjectFilter::Orphans => {
14053                refuse_unsearchable_text(query.text.as_ref())?;
14054                match self.text_searched(query.text.as_ref()).await? {
14055                    Some(searched) => (searched, &query.project),
14056                    None => (self.board().await?.items, &query.project),
14057                }
14058            }
14059        };
14060        // Filtered before paged, exactly as a task read is: a page of a filtered result is
14061        // a page of the survivors, never the survivors of a page.
14062        let documents = held
14063            .iter()
14064            .filter(|item| item.kind == BoardKind::Document)
14065            .map(Resolved::document)
14066            .filter(|document| document_matches(document, query, membership))
14067            .collect();
14068        Ok(offset_page(
14069            documents,
14070            numeric_cursor(page.cursor.as_ref())?,
14071            page.limit.min(MAX_PAGE_SIZE) as usize,
14072        ))
14073    }
14074    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
14075        validate_page(page)?;
14076        let offset = numeric_cursor(page.cursor.as_ref())?;
14077        let mut labels = self
14078            .board()
14079            .await?
14080            .items
14081            .into_iter()
14082            .flat_map(|item| item.labels)
14083            .fold(Vec::new(), |mut all, label| {
14084                if !all.iter().any(|x: &Label| x.id == label.id) {
14085                    all.push(label);
14086                }
14087                all
14088            });
14089        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
14090        Ok(offset_page(
14091            labels,
14092            offset,
14093            page.limit.min(MAX_PAGE_SIZE) as usize,
14094        ))
14095    }
14096    async fn task_dependencies(
14097        &self,
14098        id: &NativeId,
14099        direction: Direction,
14100        page: &PageRequest,
14101    ) -> Result<Page<DependencyEdge>, SourceError> {
14102        self.dependencies(id, ItemKind::Task, direction, page).await
14103    }
14104    async fn project_dependencies(
14105        &self,
14106        id: &NativeId,
14107        direction: Direction,
14108        page: &PageRequest,
14109    ) -> Result<Page<DependencyEdge>, SourceError> {
14110        self.dependencies(id, ItemKind::Project, direction, page)
14111            .await
14112    }
14113
14114    fn writes(&self) -> WriteSupport {
14115        WriteSupport::Supported
14116    }
14117
14118    /// Create or update one task.
14119    ///
14120    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
14121    /// neither may name the task itself or name one task twice — and land in the body's
14122    /// metadata slot under their reserved keys, in place of any caller metadata of those
14123    /// names.
14124    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
14125        self.write_task_assets(write, None)
14126            .await
14127            .map(|written| written.id)
14128    }
14129
14130    async fn read_ahead_origins_for_copy(&self, origins: &[String]) -> Result<(), SourceError> {
14131        self.origins_ahead(origins).await
14132    }
14133
14134    async fn member_classifications(
14135        &self,
14136        project: &NativeId,
14137    ) -> Result<Option<Vec<(Classification, Vec<Repository>)>>, SourceError> {
14138        Ok(self
14139            .member_classes
14140            .lock()
14141            .map_err(|_| SourceError::Unavailable {
14142                message: "a project's members held for its classification were left \
14143                          inconsistent; run the command again"
14144                    .into(),
14145            })?
14146            .remove(project))
14147    }
14148
14149    /// The issue's own web address, as the `createIssue` that made it in this command answered
14150    /// it: a task, a project and a document are each an issue here.
14151    fn written_location(
14152        &self,
14153        _record: onetaskgraph_plugin_api::MetadataRecord,
14154        id: &NativeId,
14155    ) -> Option<Location> {
14156        self.written
14157            .lock()
14158            .unwrap_or_else(std::sync::PoisonError::into_inner)
14159            .get(id)
14160            .cloned()
14161            .map(Location::Url)
14162    }
14163
14164    /// [`MUTATION_BATCH`], while writes are held back: [`graphql::CREATE_ISSUES`] creates that many
14165    /// in one request, and a copy's tasks are created together only then, since their fields
14166    /// and edges are what is held back.
14167    fn create_batch_limit(&self) -> usize {
14168        if self.deferring().unwrap_or(false) {
14169            MUTATION_BATCH
14170        } else {
14171            1
14172        }
14173    }
14174
14175    // 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.
14176    async fn write_tasks(&self, writes: &[ItemWrite<Task>]) -> Result<Vec<NativeId>, SourceError> {
14177        if let Some(write) = writes.iter().find(|write| write.target.is_some()) {
14178            return Err(SourceError::Refused {
14179                message: format!(
14180                    "write_tasks creates tasks, and the write of {} names the existing task {}; \
14181                     next: update it with write_task",
14182                    write.item.id,
14183                    write.target.as_ref().map_or("", |target| target.0.as_str())
14184                ),
14185            });
14186        }
14187        if !self.deferring()? {
14188            let mut created = Vec::with_capacity(writes.len());
14189            for write in writes {
14190                match self.write_task(write).await {
14191                    Ok(id) => created.push(id),
14192                    Err(error) => {
14193                        let mut left: Vec<&str> = Vec::new();
14194                        for id in &created {
14195                            if self.delete_issue(id).await.is_err() {
14196                                left.push(id.0.as_str());
14197                            }
14198                        }
14199                        return Err(left_behind(error, &left));
14200                    }
14201                }
14202            }
14203            return Ok(created);
14204        }
14205        self.create_together(writes).await
14206    }
14207
14208    fn begin_deferred_writes(&self) {
14209        if let Ok(mut deferred) = self.deferred.lock() {
14210            deferred.get_or_insert_with(Deferred::default);
14211        }
14212    }
14213
14214    async fn finish_writes(&self) -> Result<(), SourceError> {
14215        self.send_held_updates().await?;
14216        self.send_held_back().await
14217    }
14218
14219    async fn write_task_with_assets(
14220        &self,
14221        write: &ItemWrite<Task>,
14222        _answers: Option<&BTreeMap<String, Value>>,
14223        assets: &onetaskgraph_plugin_api::AssetWrite,
14224    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
14225        self.write_task_assets(write, Some(assets)).await
14226    }
14227
14228    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
14229        self.write_item(
14230            &Incoming {
14231                written: Written::Work(ItemKind::Project, &write.item.status),
14232                title: &write.item.title,
14233                content: write.item.content.as_deref(),
14234                assets: None,
14235                labels: &write.item.labels,
14236                metadata: &write.item.metadata,
14237                repositories: &write.item.repositories,
14238                classification: write.item.classification,
14239                parent: None,
14240                delivers: &[],
14241                delivered_by: &[],
14242                priority: None,
14243            },
14244            write.target.as_ref(),
14245            &write.depends_on,
14246        )
14247        .await
14248        .map(|written| written.id)
14249    }
14250
14251    /// Create or update one document, which is one issue titled the way this board spells
14252    /// a document.
14253    ///
14254    /// Everything else is exactly a task write: caller metadata goes to the same canonical
14255    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
14256    /// or a field this board cannot carry is refused by name rather than dropped, a target
14257    /// naming an issue this board does not hold is refused rather than created, and an
14258    /// issue this call created is taken back when the rest of the write fails.
14259    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
14260        self.write_document_assets(write, None)
14261            .await
14262            .map(|written| written.id)
14263    }
14264
14265    async fn write_document_with_assets(
14266        &self,
14267        write: &ItemWrite<Document>,
14268        _answers: Option<&BTreeMap<String, Value>>,
14269        assets: &onetaskgraph_plugin_api::AssetWrite,
14270    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
14271        self.write_document_assets(write, Some(assets)).await
14272    }
14273
14274    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
14275    /// which reads nothing; then the board's `Status` option. Over an existing item that is
14276    /// read off the item, as the write reads it, and the item is held among this command's
14277    /// resolved records so the write that follows reuses that read rather than repeating it;
14278    /// an item that does not carry the field takes the board's fields, which are held once
14279    /// read. A create is checked against the board's fields only when this command already
14280    /// holds them, because a create reads them together with its repository, in one request,
14281    /// and refuses a missing option before it writes anything.
14282    async fn check_status_write(
14283        &self,
14284        kind: ItemKind,
14285        category: StatusCategory,
14286        target: Option<&NativeId>,
14287    ) -> Result<(), SourceError> {
14288        let status = self.resolved_target(kind, category)?;
14289        if status.option().is_none() {
14290            return Ok(());
14291        }
14292        let fields = match target {
14293            Some(target) => {
14294                // A target this board does not hold is the write's own refusal to make.
14295                let Some(item) = self.bound_item(target).await? else {
14296                    return Ok(());
14297                };
14298                self.resolved_cache()?.insert(target.clone(), item.clone());
14299                // Only the `Status` field decides whether the write can land.
14300                self.fields_for(Some(&item), false, true, false)
14301                    .await?
14302                    .fields
14303            }
14304            None => {
14305                let held = self
14306                    .board_cache()?
14307                    .as_ref()
14308                    .map(|board| board.fields.clone());
14309                match held.or_else(|| {
14310                    self.fields_cache()
14311                        .ok()
14312                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
14313                }) {
14314                    Some(fields) => fields,
14315                    None => return Ok(()),
14316                }
14317            }
14318        };
14319        self.column_for(&fields, kind, category, &status)
14320            .map(|_| ())
14321    }
14322
14323    /// What a write of `status` reads back as: the `Status` option its category maps to for
14324    /// `kind`, and for a closing category the closure that option and its fixed reason read
14325    /// as — exactly what [`BoardStatuses::status`] reports of the item once written. A category
14326    /// this instance has no option for is answered as given, since writing it is refused.
14327    fn status_as_written(&self, kind: ItemKind, status: &Status) -> Status {
14328        match self.statuses.target(kind, status.category) {
14329            StatusTarget::Column(name) => {
14330                self.statuses.status(kind, Some(name.as_str()), false, None)
14331            }
14332            StatusTarget::Terminal(name, reason) => {
14333                self.statuses
14334                    .status(kind, Some(name.as_str()), true, Some(reason.reason()))
14335            }
14336            StatusTarget::Disabled(_) => status.clone(),
14337        }
14338    }
14339
14340    /// Set one task's status alone.
14341    ///
14342    /// An open target reopens a closed issue with an `updateIssue` carrying only its
14343    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
14344    /// terminal target selects its mapped option, then closes with its fixed reason. No
14345    /// request carries a title, a body or a label. The status
14346    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
14347    /// what a re-read reports.
14348    async fn set_task_status(
14349        &self,
14350        id: &NativeId,
14351        category: StatusCategory,
14352    ) -> Result<Option<Status>, SourceError> {
14353        self.set_status(id, ItemKind::Task, category).await
14354    }
14355
14356    /// Set one task's status and answer the task with it, reading the item once: the read the
14357    /// task is answered from is handed to the status write, which would otherwise read the
14358    /// same issue again — two requests on a fresh process, the read and the field write,
14359    /// where the default's `get_task` before `set_task_status` sent three.
14360    async fn set_task_status_reading(
14361        &self,
14362        id: &NativeId,
14363        category: StatusCategory,
14364    ) -> Result<Option<Task>, SourceError> {
14365        // Refused before anything is read, in the words the write itself refuses it.
14366        self.resolved_target(ItemKind::Task, category)?;
14367        let Some(item) = self
14368            .bound_item(id)
14369            .await?
14370            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14371        else {
14372            return Ok(None);
14373        };
14374        let mut task = item.task()?;
14375        self.held_cache
14376            .lock()
14377            .map_err(|_| SourceError::Unavailable {
14378                message: "items read by issue reference were left inconsistent; run the \
14379                          command again"
14380                    .into(),
14381            })?
14382            .insert(id.clone(), item);
14383        let Some(status) = self.set_status(id, ItemKind::Task, category).await? else {
14384            return Ok(None);
14385        };
14386        task.status = status;
14387        Ok(Some(task))
14388    }
14389
14390    /// Set one project's status alone, by exactly the paired write a task's status takes, for
14391    /// the project kind: a terminal category selects the project kind's mapped option and then
14392    /// closes the issue with the category's fixed reason — an issue already closed with a
14393    /// reason that reads that category is left as it is — and an open category reopens a closed
14394    /// issue and then selects its option. A board draft is refused a terminal category as a
14395    /// task's is.
14396    async fn set_project_status(
14397        &self,
14398        id: &NativeId,
14399        category: StatusCategory,
14400    ) -> Result<Option<Status>, SourceError> {
14401        self.set_status(id, ItemKind::Project, category).await
14402    }
14403
14404    /// Every task and project whose card contradicts its issue's closure, read through the
14405    /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
14406    async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
14407        self.contradictions().await
14408    }
14409
14410    /// What this board holds for each issue reference, one request per fifty of them; see
14411    /// [`TaskSource::issue_holdings`].
14412    async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
14413        self.holdings(issues).await
14414    }
14415
14416    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
14417    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
14418    /// for `none`. Refused by an instance with no `priority_mapping`.
14419    async fn set_task_priority(
14420        &self,
14421        id: &NativeId,
14422        priority: Priority,
14423    ) -> Result<Option<Priority>, SourceError> {
14424        self.set_priority(id, priority).await
14425    }
14426
14427    /// Replace one task's content with a single body update that keeps the metadata slot
14428    /// byte for byte.
14429    async fn set_task_content(
14430        &self,
14431        id: &NativeId,
14432        content: &str,
14433    ) -> Result<Option<()>, SourceError> {
14434        self.replace_content(id, content).await
14435    }
14436
14437    /// Replace one task issue's content and its provenance slot entry with a single body
14438    /// update. The answers are not kept: see `replace_rendering`.
14439    async fn set_task_rendering(
14440        &self,
14441        id: &NativeId,
14442        content: &str,
14443        provenance: &Value,
14444        _answers: &BTreeMap<String, Value>,
14445    ) -> Result<Option<()>, SourceError> {
14446        self.replace_rendering(
14447            id,
14448            BoardKind::Work(ItemKind::Task),
14449            content,
14450            provenance,
14451            None,
14452        )
14453        .await
14454        .map(|written| written.map(|_| ()))
14455    }
14456
14457    /// Replace one design-document issue's content and its provenance slot entry, on exactly
14458    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
14459    async fn set_document_rendering(
14460        &self,
14461        id: &NativeId,
14462        content: &str,
14463        provenance: &Value,
14464        _answers: &BTreeMap<String, Value>,
14465    ) -> Result<Option<()>, SourceError> {
14466        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
14467            .await
14468            .map(|written| written.map(|_| ()))
14469    }
14470
14471    /// Replace one project issue's content and its provenance slot entry, on exactly the
14472    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
14473    async fn set_project_rendering(
14474        &self,
14475        id: &NativeId,
14476        content: &str,
14477        provenance: &Value,
14478        _answers: &BTreeMap<String, Value>,
14479    ) -> Result<Option<()>, SourceError> {
14480        self.replace_rendering(
14481            id,
14482            BoardKind::Work(ItemKind::Project),
14483            content,
14484            provenance,
14485            None,
14486        )
14487        .await
14488        .map(|written| written.map(|_| ()))
14489    }
14490
14491    /// Apply a targeted update with one read of the item and a write only for what differs:
14492    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
14493    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
14494    async fn update_task(
14495        &self,
14496        id: &NativeId,
14497        update: &TaskUpdate,
14498    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
14499        self.targeted_update(id, update).await
14500    }
14501
14502    /// Replace one task's `delivered_by` with a single body update that changes the
14503    /// metadata slot and nothing outside it.
14504    async fn set_delivered_by(
14505        &self,
14506        id: &NativeId,
14507        delivered_by: &[TaskRef],
14508    ) -> Result<Option<()>, SourceError> {
14509        self.replace_delivered_by(id, delivered_by).await
14510    }
14511
14512    /// Set one key of one task issue's metadata with a single body update that changes the
14513    /// metadata slot and nothing outside it — no title, label, state or board field request —
14514    /// and sends nothing when the task already holds that value under the key.
14515    async fn set_task_metadata(
14516        &self,
14517        id: &NativeId,
14518        key: &MetadataKey,
14519        value: &Value,
14520    ) -> Result<Option<Task>, SourceError> {
14521        Ok(self
14522            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
14523            .await?
14524            .map(|item| item.task())
14525            .transpose()?)
14526    }
14527
14528    /// Set one key of one project issue's metadata, on exactly the terms of
14529    /// [`set_task_metadata`](TaskSource::set_task_metadata).
14530    async fn set_project_metadata(
14531        &self,
14532        id: &NativeId,
14533        key: &MetadataKey,
14534        value: &Value,
14535    ) -> Result<Option<Project>, SourceError> {
14536        Ok(self
14537            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
14538            .await?
14539            .map(|item| item.project()))
14540    }
14541
14542    /// Set one key of one design-document issue's metadata, on exactly the terms of
14543    /// [`set_task_metadata`](TaskSource::set_task_metadata).
14544    async fn set_document_metadata(
14545        &self,
14546        id: &NativeId,
14547        key: &MetadataKey,
14548        value: &Value,
14549    ) -> Result<Option<Document>, SourceError> {
14550        Ok(self
14551            .set_slot_key(id, BoardKind::Document, key, value)
14552            .await?
14553            .map(|item| item.document()))
14554    }
14555
14556    /// Take one key out of one issue's metadata slot with a single body update that changes the
14557    /// slot and nothing outside it, sending nothing when the slot does not hold the key.
14558    async fn remove_record_metadata(
14559        &self,
14560        record: onetaskgraph_plugin_api::MetadataRecord,
14561        id: &NativeId,
14562        key: &MetadataKey,
14563    ) -> Result<Option<Option<String>>, SourceError> {
14564        let kind = match record {
14565            onetaskgraph_plugin_api::MetadataRecord::Task => BoardKind::Work(ItemKind::Task),
14566            onetaskgraph_plugin_api::MetadataRecord::Project => BoardKind::Work(ItemKind::Project),
14567            onetaskgraph_plugin_api::MetadataRecord::Document => BoardKind::Document,
14568        };
14569        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
14570            return Ok(None);
14571        };
14572        if item.slot.contains_key(key.as_str()) {
14573            let mut slot = item.slot.clone();
14574            slot.remove(key.as_str());
14575            self.write_slot(&mut item, &slot).await?;
14576            self.remember_written(item.clone(), false)?;
14577        }
14578        Ok(Some(match record {
14579            onetaskgraph_plugin_api::MetadataRecord::Task => item.task()?.version,
14580            onetaskgraph_plugin_api::MetadataRecord::Project => item.project().version,
14581            onetaskgraph_plugin_api::MetadataRecord::Document => item.document().version,
14582        }))
14583    }
14584
14585    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
14586        self.delete_item(id).await
14587    }
14588
14589    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
14590        self.delete_item(id).await
14591    }
14592
14593    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
14594        self.delete_item(id).await
14595    }
14596
14597    /// One page of the task issue's own comments, walked by GitHub's own cursor.
14598    ///
14599    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
14600    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
14601    ///
14602    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
14603    /// board is the read of its comments. A draft this process already resolved is refused
14604    /// without one.
14605    async fn task_comments(
14606        &self,
14607        task: &NativeId,
14608        page: &PageRequest,
14609    ) -> Result<Option<Page<Comment>>, SourceError> {
14610        validate_page(page)?;
14611        let cached = self.resolved_cache()?.get(task).cloned();
14612        if let Some(item) = cached {
14613            if item.kind != BoardKind::Work(ItemKind::Task) {
14614                return Ok(None);
14615            }
14616            if item.content_kind == ContentKind::DraftIssue {
14617                return Err(self.draft_has_no_comments(task));
14618            }
14619        }
14620        match self.issue_detail(task, page).await? {
14621            Some(TaskDetailRead {
14622                comments: Some(comments),
14623                ..
14624            }) => comments,
14625            _ => Ok(None),
14626        }
14627    }
14628
14629    /// Every id's task, with the first page of its comments when `comments` names it:
14630    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
14631    /// comments in one [`graphql::ISSUE_DETAIL`] request.
14632    async fn get_task_details(
14633        &self,
14634        ids: &[NativeId],
14635        comments: Option<&PageRequest>,
14636    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
14637        if let Some(page) = comments
14638            && let Err(error) = validate_page(page)
14639        {
14640            return ids.iter().map(|_| Err(error.clone())).collect();
14641        }
14642        match (ids, comments) {
14643            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
14644            ([id], None) => vec![self.task_read(id).await],
14645            _ => self.issue_details(ids, comments).await,
14646        }
14647    }
14648
14649    /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
14650    /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
14651    /// request, so the dependency read a copy makes of each next is answered from this one.
14652    async fn get_task_details_for_copy(
14653        &self,
14654        ids: &[NativeId],
14655        comments: &PageRequest,
14656    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
14657        // A page past the first is a page of one task's comments, which the detail read of it
14658        // alone answers.
14659        if comments.cursor.is_some() {
14660            return self.get_task_details(ids, Some(comments)).await;
14661        }
14662        if let Err(error) = validate_page(comments) {
14663            return ids.iter().map(|_| Err(error.clone())).collect();
14664        }
14665        let mut read = Vec::with_capacity(ids.len());
14666        for batch in ids.chunks(COPY_DETAIL_BATCH) {
14667            match self
14668                .graphql(
14669                    graphql::ISSUE_COPY_DETAILS,
14670                    copy_detail_batch(batch, comments, false),
14671                )
14672                .await
14673            {
14674                Ok(data) => {
14675                    for (slot, id) in batch.iter().enumerate() {
14676                        let read_one = match data.get(format!("i{slot}")) {
14677                            Some(node) => self.detail_of(id, node, true, None).await,
14678                            None => Err(SourceError::Malformed {
14679                                message: format!(
14680                                    "GitHub answered a batch read with no item for {}",
14681                                    id.0
14682                                ),
14683                            }),
14684                        };
14685                        read.push(read_one);
14686                    }
14687                }
14688                Err(error) if unresolvable_node(&error) => {
14689                    for id in batch {
14690                        read.push(self.issue_detail(id, comments).await);
14691                    }
14692                }
14693                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
14694            }
14695        }
14696        read
14697    }
14698
14699    /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
14700    /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
14701    /// and held for the next read of each by id; see `read_ahead`.
14702    ///
14703    /// What this run wrote is never read: its own record answers, as it does for a read by id.
14704    /// Nor is an item a listing of its project carried whole earlier in this command — see
14705    /// `carried` — which is held in place of reading it.
14706    /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
14707    /// so each of its ids is read on its own and answered as that read answers; a draft, and an
14708    /// answer naming another issue than the one asked for, are left to their own reads too.
14709    async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
14710        // The origins asked just before go out now, beside the members of the one project this
14711        // names when it names one; a project those origins answered whole is held for its next
14712        // read by id rather than read again here.
14713        let hinted = match ids {
14714            [one] => Some(one),
14715            _ => None,
14716        };
14717        self.send_pending_origins(hinted).await?;
14718        if let Some(project) = hinted {
14719            let answered = self.resolved_cache()?.get(project).cloned();
14720            if let Some(answered) = answered
14721                && answered.kind == BoardKind::Work(ItemKind::Project)
14722            {
14723                self.read_ahead()?.insert(project.clone(), Some(answered));
14724                return Ok(());
14725            }
14726        }
14727        let mut unread: Vec<NativeId> = Vec::new();
14728        // Read whole by a listing of its project in this command, which a mutation naming it
14729        // would have dropped: held as a batch would have held it, once every batch is answered.
14730        let mut listed: Vec<NativeId> = Vec::new();
14731        {
14732            let created = self.created()?;
14733            let held = self.read_ahead()?;
14734            let carried = self.carried()?;
14735            for id in ids {
14736                if unread.contains(id)
14737                    || listed.contains(id)
14738                    || held.contains_key(id)
14739                    || created.iter().any(|own| own.id == *id)
14740                {
14741                    continue;
14742                }
14743                // An issue a listing carried without the whole of what blocks it is read in a
14744                // batch too, which carries that, rather than having its dependency read sent
14745                // on its own once the copy reconciles its edges.
14746                let whole = carried.get(id).is_some_and(|item| {
14747                    item.content_kind != ContentKind::Issue || item.blocked_by.is_some()
14748                });
14749                if whole {
14750                    listed.push(id.clone());
14751                } else {
14752                    unread.push(id.clone());
14753                }
14754            }
14755        }
14756        // Nothing of an item's comments is wanted here: the smallest page keeps the read to
14757        // what the item itself costs.
14758        let comments = PageRequest {
14759            cursor: None,
14760            limit: 1,
14761        };
14762        // Held only once every batch has been answered, so a read-ahead that fails part way
14763        // holds nothing — what a listing carried included, which stays carried — and every
14764        // item is read on its own, as the method promises.
14765        let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
14766        // The issues the first id names as sub-issues, as `ISSUE_COPY_DETAILS`'s `children`
14767        // answered them: what a project copy's orphan check asks, when that first id is its
14768        // project.
14769        let mut children: Option<(NativeId, Vec<(String, String)>)> = None;
14770        for (index, batch) in unread.chunks(COPY_DETAIL_BATCH).enumerate() {
14771            let data = match self
14772                .graphql(
14773                    graphql::ISSUE_COPY_DETAILS,
14774                    copy_detail_batch(batch, &comments, index == 0),
14775                )
14776                .await
14777            {
14778                Ok(data) => data,
14779                Err(error) if unresolvable_node(&error) => continue,
14780                Err(error) => return Err(error),
14781            };
14782            for (slot, id) in batch.iter().enumerate() {
14783                let Some(node) = data.get(format!("i{slot}")) else {
14784                    continue;
14785                };
14786                let held = if node.is_null() {
14787                    None
14788                } else {
14789                    match optional_str(node, "__typename")? {
14790                        Some("DraftIssue") => continue,
14791                        Some("Issue") if required_str(node, "id")? != id.0 => continue,
14792                        _ => {}
14793                    }
14794                    // Remembered below with the rest, so a later batch refused leaves no
14795                    // dependency read answered from this one either.
14796                    self.resolve_issue_as(node, false).await?
14797                };
14798                read.push((id.clone(), held));
14799            }
14800            if index == 0
14801                && let Some(node) = data.get("children").filter(|node| !node.is_null())
14802                && node.get("id").and_then(Value::as_str) == batch.first().map(|id| id.0.as_str())
14803                && node.pointer("/subIssues/pageInfo/hasNextPage") == Some(&Value::Bool(false))
14804            {
14805                // A list GitHub did not answer is not an empty one: it would answer the project's
14806                // task listing with nothing, and the orphan check with no orphans.
14807                let listed = node
14808                    .pointer("/subIssues/nodes")
14809                    .and_then(Value::as_array)
14810                    .ok_or_else(|| SourceError::Malformed {
14811                        message: format!(
14812                            "GitHub answered the sub-issues of project {} with no list of them",
14813                            batch[0].0
14814                        ),
14815                    })?
14816                    .iter()
14817                    .map(|child| {
14818                        Ok((
14819                            required_str(child, "id")?.to_owned(),
14820                            required_str(child, "title")?.to_owned(),
14821                        ))
14822                    })
14823                    .collect::<Result<Vec<_>, SourceError>>()?;
14824                children = Some((batch[0].clone(), listed));
14825            }
14826        }
14827        // The project's tasks are known from this read alone when every child it named is held
14828        // from it, but for a document, which its title says is one and which a task listing
14829        // never answers: then a listing of the project's tasks is answered from them.
14830        if let Some((project, listed)) = children
14831            && read.iter().any(|(id, held)| {
14832                *id == project
14833                    && held
14834                        .as_ref()
14835                        .is_some_and(|held| held.kind == BoardKind::Work(ItemKind::Project))
14836            })
14837        {
14838            let mut tasks = Vec::new();
14839            let mut whole = true;
14840            for (child, title) in &listed {
14841                match read.iter().find(|(id, _)| id.0 == *child) {
14842                    Some((_, Some(held))) if held.parent.as_ref() == Some(&project) => {
14843                        if held.kind == BoardKind::Work(ItemKind::Task) {
14844                            tasks.push(held.clone());
14845                        }
14846                    }
14847                    _ if title.starts_with(DESIGN_TITLE_PREFIX) => {}
14848                    _ => whole = false,
14849                }
14850            }
14851            if whole {
14852                self.task_children()?.insert(project, tasks);
14853            }
14854        }
14855        let mut resolved = self.resolved_cache()?;
14856        let mut ahead = self.read_ahead()?;
14857        let mut carried = self.carried()?;
14858        for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
14859            resolved.insert(item.id.clone(), item.clone());
14860        }
14861        // Taken only if still carried: a mutation naming it while the batches ran dropped it,
14862        // and its next read is then its own.
14863        for id in &listed {
14864            let Some(item) = carried.remove(id) else {
14865                continue;
14866            };
14867            // The whole of what it is blocked by came with it, so the dependency read a copy
14868            // makes next is answered from this one, as a copy's detail read is.
14869            if item.blocked_by.is_some() {
14870                resolved.insert(item.id.clone(), item.clone());
14871            }
14872            read.push((id.clone(), Some(item)));
14873        }
14874        for (id, held) in read {
14875            ahead.insert(id, held);
14876        }
14877        Ok(())
14878    }
14879
14880    /// Add one comment to the task's issue, as the account the token belongs to.
14881    ///
14882    /// The author is refused before anything is sent — not even the task is read — because
14883    /// no answer GitHub could give would make posting under another name than the one asked
14884    /// for the right outcome.
14885    async fn add_comment(
14886        &self,
14887        task: &NativeId,
14888        comment: &NewComment,
14889    ) -> Result<Option<Comment>, SourceError> {
14890        if let Some(author) = &comment.author {
14891            return Err(SourceError::Refused {
14892                message: format!(
14893                    "source {} cannot post a comment as {author:?}: GitHub records the account \
14894                     the token signs in as the author of every comment; next: leave --author \
14895                     out, and the comment is posted as that account",
14896                    self.name
14897                ),
14898            });
14899        }
14900        let Some(issue) = self.commented_issue(task).await? else {
14901            return Ok(None);
14902        };
14903        let data = self
14904            .graphql(
14905                graphql::ADD_COMMENT,
14906                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
14907            )
14908            .await?;
14909        let subject = data
14910            .pointer("/addComment/subject")
14911            .filter(|value| !value.is_null())
14912            .ok_or_else(|| SourceError::Malformed {
14913                message: "GitHub comment addition returned no subject".into(),
14914            })?;
14915        if required_str(subject, "id")? != issue.0 {
14916            return Err(SourceError::Malformed {
14917                message: "GitHub comment addition answered about another issue".into(),
14918            });
14919        }
14920        let added = data
14921            .pointer("/addComment/commentEdge/node")
14922            .filter(|value| !value.is_null())
14923            .ok_or_else(|| SourceError::Malformed {
14924                message: "GitHub comment addition returned no comment".into(),
14925            })?;
14926        let added = comment_from(added)?;
14927        self.remember_commented(&issue)?;
14928        Ok(Some(added))
14929    }
14930
14931    async fn edit_comment(
14932        &self,
14933        task: &NativeId,
14934        comment: &NativeId,
14935        body: &CommentBody,
14936    ) -> Result<Option<Comment>, SourceError> {
14937        let Some(issue) = self.commented_issue(task).await? else {
14938            return Ok(None);
14939        };
14940        let Some(held) = self.comment_on(&issue, comment).await? else {
14941            return Ok(None);
14942        };
14943        // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
14944        let body = CommentOrigin::kept(&held, body)?;
14945        let data = self
14946            .graphql(
14947                graphql::UPDATE_COMMENT,
14948                json!({"input":{"id":comment.0,"body":body.as_str()}}),
14949            )
14950            .await?;
14951        let edited = data
14952            .pointer("/updateIssueComment/issueComment")
14953            .filter(|value| !value.is_null())
14954            .ok_or_else(|| SourceError::Malformed {
14955                message: "GitHub comment update returned no comment".into(),
14956            })?;
14957        let edited = comment_from(edited)?;
14958        if edited.id != *comment {
14959            return Err(SourceError::Malformed {
14960                message: "GitHub comment update returned the wrong comment".into(),
14961            });
14962        }
14963        self.remember_commented(&issue)?;
14964        Ok(Some(edited))
14965    }
14966
14967    async fn delete_comment(
14968        &self,
14969        task: &NativeId,
14970        comment: &NativeId,
14971    ) -> Result<Option<NativeId>, SourceError> {
14972        let Some(issue) = self.commented_issue(task).await? else {
14973            return Ok(None);
14974        };
14975        if !self.comment_is_on(&issue, comment).await? {
14976            return Ok(None);
14977        }
14978        let data = self
14979            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
14980            .await?;
14981        // The payload says nothing about the comment it removed, so what is checked is that
14982        // GitHub answered the mutation at all rather than leaving it unanswered.
14983        data.get("deleteIssueComment")
14984            .filter(|value| !value.is_null())
14985            .ok_or_else(|| SourceError::Malformed {
14986                message: "GitHub comment deletion returned no payload".into(),
14987            })?;
14988        Ok(Some(comment.clone()))
14989    }
14990
14991    /// Every request this source has recorded, and what each of GitHub's two budgets was
14992    /// attributed — read off the same accounting the session report is rendered from, so
14993    /// the two cannot count one request two ways.
14994    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
14995        Ok(Some(self.ledger.snapshot().metering()))
14996    }
14997
14998    /// Drop every item, search answer and board read this source holds, so the next command
14999    /// reads the board as a person has since left it.
15000    ///
15001    /// Every one of those is held on the assumption that nothing but this source writes the
15002    /// board while a command runs, which stops being true the moment the command is over: a
15003    /// body a person edited would be overwritten from the record held here, and a card they
15004    /// moved would be read as still where this source left it. The board's own field
15005    /// definitions go too, because a person can add or delete a `Status` option and a write
15006    /// resolved against the held list would not re-read on a miss. What stays is what stays
15007    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
15008    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
15009    /// accounting [`metering`](TaskSource::metering) answers from.
15010    ///
15011    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
15012    /// refused, because clearing it is what puts it right.
15013    async fn end_command(&self) -> Result<(), SourceError> {
15014        fn clear<T: Default>(held: &Mutex<T>) {
15015            *held
15016                .lock()
15017                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
15018            held.clear_poison();
15019        }
15020        clear(&self.created);
15021        clear(&self.updated);
15022        clear(&self.commented);
15023        clear(&self.board_cache);
15024        clear(&self.search_cache);
15025        clear(&self.narrowed_cache);
15026        clear(&self.search_next);
15027        clear(&self.resolved_cache);
15028        clear(&self.read_ahead);
15029        clear(&self.pending_origins);
15030        clear(&self.member_classes);
15031        clear(&self.carried);
15032        clear(&self.held_cache);
15033        clear(&self.children_cache);
15034        clear(&self.task_children);
15035        clear(&self.written);
15036        clear(&self.fields_cache);
15037        self.expected.end_command();
15038        Ok(())
15039    }
15040}
15041
15042/// One member of a project as a classification-only read answers it: its classification and
15043/// the repositories its work changes.
15044type MemberClass = (Classification, Vec<Repository>);
15045
15046/// Each project's sub-issues as one command read them, keyed by the selector they were asked
15047/// for under, beside the project that selector named; see `children_cache`.
15048type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
15049
15050/// One issue comment as the contract carries it.
15051///
15052/// `author` is absent both when GitHub answers `null` for an account that no longer exists
15053/// and when it answers an actor with no login, because either way the source did not say who
15054/// wrote it — which is what an absent author means, rather than an author called nothing.
15055///
15056/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
15057/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
15058/// and is read back with the marker taken out and reported as its origin.
15059fn comment_from(value: &Value) -> Result<Comment, SourceError> {
15060    Ok(Comment {
15061        origin: None,
15062        id: NativeId(required_str(value, "id")?.to_owned()),
15063        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
15064            .map(str::to_owned),
15065        created_at: optional_time(value, "createdAt")?,
15066        updated_at: optional_time(value, "updatedAt")?,
15067        body: required_str(value, "body")?.to_owned(),
15068        url: optional_str(value, "url")?.map(str::to_owned),
15069    }
15070    .unmarked())
15071}
15072
15073/// The page of comments one issue node carries, resumed from `after`.
15074fn comment_page(
15075    node: &Value,
15076    issue: &str,
15077    after: Option<&str>,
15078) -> Result<Page<Comment>, SourceError> {
15079    let connection = node
15080        .get("comments")
15081        .filter(|value| !value.is_null())
15082        .ok_or_else(|| SourceError::Malformed {
15083            message: format!("GitHub issue {issue} answered with no comments connection"),
15084        })?;
15085    let items = optional_nodes(Some(connection), "issue comments")?
15086        .into_iter()
15087        .flatten()
15088        .map(comment_from)
15089        .collect::<Result<Vec<_>, _>>()?;
15090    let next = next_cursor(connection)?;
15091    if let Some(next) = &next {
15092        validate_cursor_progress(after, &next.0)?;
15093    }
15094    Ok(Page { items, next })
15095}
15096
15097/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
15098/// end — `None` when it carried none, or a page with more past it.
15099fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
15100    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
15101        return Ok(None);
15102    };
15103    if next_cursor(connection)?.is_some() {
15104        return Ok(None);
15105    }
15106    Ok(Some(
15107        optional_nodes(Some(connection), "blocked-by issues")?
15108            .into_iter()
15109            .flatten()
15110            .cloned()
15111            .collect(),
15112    ))
15113}
15114
15115/// Where the recorded tail of a dependency walk resumes; see
15116/// [`GitHubProjectsSource::recorded_edges`].
15117const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
15118
15119/// The board text field this source keeps a copy's origin in.
15120///
15121/// Named after the key it holds, and held to that name by the guard below rather than by
15122/// a reader noticing.
15123const ORIGIN_FIELD: &str = "onetaskgraph.origin";
15124
15125/// The metadata key that field holds.
15126///
15127/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
15128/// constructs or interprets the qualified id it carries. This source names it only to
15129/// route it — a short, typed value belongs in a typed field rather than in the body slot
15130/// a caller's own prose shares.
15131///
15132/// Restated rather than imported, because no plugin crate may depend on the engine. What
15133/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
15134/// target in `check`: it reads the engine's own literal and fails naming the file and the
15135/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
15136/// that creates a second item every run instead of finding the one it wrote — and that is
15137/// too late to learn it.
15138const ORIGIN_KEY: &str = "onetaskgraph.origin";
15139
15140/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
15141///
15142/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
15143/// is derived from the far end, never written down on the near item — so only a forward
15144/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
15145/// it did not come from, and it is told so rather than answered with an empty page that
15146/// reads as a walk which ended.
15147fn recorded_offset(
15148    cursor: Option<&str>,
15149    direction: Direction,
15150) -> Result<Option<usize>, SourceError> {
15151    cursor
15152        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
15153        .map(|offset| {
15154            if direction != Direction::DependsOn {
15155                return Err(SourceError::Config {
15156                    message: format!(
15157                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
15158                         reverse dependency read never issues; resume it in the direction \
15159                         that reported it"
15160                    ),
15161                });
15162            }
15163            offset.parse().map_err(|_| SourceError::Config {
15164                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
15165            })
15166        })
15167        .transpose()
15168}
15169
15170/// What a dependency read's own answer says of a board draft: `None` when the item is not a
15171/// draft, or the answer did not carry its board item; `Some(None)` for a draft whose one board
15172/// item is not on this board, which this source does not hold; and `Some(Some(slot))`, the
15173/// draft's metadata, for one it does — its board item naming this board by its number and by
15174/// the id the same answer read of the board, as [`GitHubProjectsSource::draft_by_id`] decides.
15175fn draft_slot(
15176    data: &Value,
15177    id: &NativeId,
15178    project_number: u32,
15179) -> Result<Option<Option<BTreeMap<String, Value>>>, SourceError> {
15180    let Some(draft) = data
15181        .get("node")
15182        .filter(|node| node.get("__typename").and_then(Value::as_str) == Some("DraftIssue"))
15183    else {
15184        return Ok(None);
15185    };
15186    let Some(items) = data.pointer("/draft/projectV2Items") else {
15187        return Ok(None);
15188    };
15189    if required_str(draft, "id")? != id.0 {
15190        return Err(SourceError::Malformed {
15191            message: format!("GitHub answered a different draft for {}", id.0),
15192        });
15193    }
15194    let nodes = items
15195        .get("nodes")
15196        .and_then(Value::as_array)
15197        .ok_or_else(|| SourceError::Malformed {
15198            message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
15199        })?;
15200    let info = items
15201        .get("pageInfo")
15202        .ok_or_else(|| SourceError::Malformed {
15203            message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
15204        })?;
15205    if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
15206        return Err(SourceError::Malformed {
15207            message: format!(
15208                "GitHub draft {} reports more board items than the one GitHub links a draft to",
15209                id.0
15210            ),
15211        });
15212    }
15213    let board = data
15214        .pointer("/board/projectV2/id")
15215        .and_then(Value::as_str)
15216        .ok_or_else(|| SourceError::Malformed {
15217            message: format!(
15218                "GitHub answered draft {} without this board's own id beside it",
15219                id.0
15220            ),
15221        })?;
15222    let mut held = false;
15223    for node in nodes {
15224        let project = node.get("project").ok_or_else(|| SourceError::Malformed {
15225            message: format!("GitHub draft {} board item has no project", id.0),
15226        })?;
15227        let number = project
15228            .get("number")
15229            .and_then(Value::as_u64)
15230            .ok_or_else(|| SourceError::Malformed {
15231                message: format!(
15232                    "GitHub draft {} board item has no numeric project number",
15233                    id.0
15234                ),
15235            })?;
15236        let project_id = required_str(project, "id")?;
15237        held |= number == u64::from(project_number) && project_id == board;
15238    }
15239    if !held {
15240        return Ok(Some(None));
15241    }
15242    Ok(Some(Some(
15243        metadata_body(optional_str(draft, "body")?.map(str::to_owned))?.1,
15244    )))
15245}
15246
15247fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
15248    let mut page = offset_page(edges, offset, limit.max(1));
15249    page.next = page
15250        .next
15251        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
15252    page
15253}
15254
15255/// The kind of one issue reached through a dependency connection.
15256///
15257/// The same questions the board scan asks, over the fields the dependency document
15258/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
15259/// then anything with sub-issues or the marker is a project.
15260///
15261/// # Errors
15262///
15263/// A far end this board holds as a document is refused rather than reported. The two
15264/// answers that are not refusals would both be wrong: reporting it as a task names an id
15265/// no task read of this source can find, and reporting it as a project names one no
15266/// project read can. There is no third value to return — `ItemKind` has no document
15267/// variant, because nothing may point at a document — so the relationship itself is what
15268/// the person is told about.
15269fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
15270    let id = required_str(value, "id")?;
15271    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
15272        return Err(SourceError::Refused {
15273            message: format!(
15274                "GitHub issue {id} is a document of this board — its title begins \
15275                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
15276                 on by one; next: remove that issue's blocking relationship on this board"
15277            ),
15278        });
15279    }
15280    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
15281    if parent.is_some() {
15282        return Ok(ItemKind::Task);
15283    }
15284    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
15285    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
15286        message: format!("GitHub issue {id}: {message}"),
15287    })?;
15288    let sub_issues = sub_issue_total(value)?;
15289    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
15290        ItemKind::Project
15291    } else {
15292        ItemKind::Task
15293    })
15294}
15295
15296/// The `stateInput` that closes an issue for `reason`.
15297fn closed_input(reason: ClosedState) -> Value {
15298    json!({"value":"CLOSED","stateReason":reason.reason()})
15299}
15300
15301/// The `IssueStateUpdateInput` one status target asks for.
15302///
15303/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
15304/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
15305/// a currently-closed issue: without that the item would read back `Unknown` and a copy
15306/// would report a change forever. A document has no status at all, and asks for neither.
15307fn state_input(target: Option<&StatusTarget>) -> Value {
15308    match target {
15309        Some(StatusTarget::Terminal(_, reason)) => closed_input(*reason),
15310        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
15311        // A document has no status, so a write of one says nothing about the issue's open
15312        // or closed state rather than forcing it open: `stateInput` is what carries that
15313        // instruction, and an explicit null asks for no change to it.
15314        None => Value::Null,
15315    }
15316}
15317
15318/// The metadata one write stores in the item's body slot.
15319///
15320/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
15321/// rather than carried: the kind marker so an empty project stays readable, the
15322/// repository list only when it is not exactly the issue's own repository, and the far
15323/// ends no relationship here can name.
15324///
15325/// The copy origin is the one typed field that is also mirrored here, and only as a
15326/// mirror: it lands in the board's origin field as well, which stays the one every reader
15327/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
15328/// and catches up with a write in seconds rather than minutes — can find the item by it.
15329/// A reader of the release before this one drops the slot's copy and reads the field, so an
15330/// item written here still reads with exactly one origin there.
15331fn slot_metadata(
15332    incoming: &Incoming<'_>,
15333    own_repository: Option<&Repository>,
15334    fallback: &[DependencyEdge],
15335) -> BTreeMap<String, Value> {
15336    let mut metadata = incoming.metadata.clone();
15337    match metadata.remove(ORIGIN_KEY) {
15338        Some(Value::String(origin)) if !origin.is_empty() => {
15339            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
15340        }
15341        _ => {}
15342    }
15343    match incoming.written.kind() {
15344        BoardKind::Work(kind) => metadata.insert(
15345            ItemKind::METADATA_KEY.to_owned(),
15346            Value::String(kind.marker().to_owned()),
15347        ),
15348        // A document is told by its title, so it carries no kind marker: that key names
15349        // what a dependency endpoint points at, and nothing may point at a document.
15350        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
15351    };
15352    incoming.classification.record(&mut metadata);
15353    let derivable = own_repository
15354        .map(|own| incoming.repositories == [own.clone()])
15355        .unwrap_or(incoming.repositories.is_empty());
15356    if derivable {
15357        metadata.remove(Repository::METADATA_KEY);
15358    } else {
15359        metadata.insert(
15360            Repository::METADATA_KEY.to_owned(),
15361            Value::Array(
15362                incoming
15363                    .repositories
15364                    .iter()
15365                    .map(|repository| Value::String(repository.as_str().to_owned()))
15366                    .collect(),
15367            ),
15368        );
15369    }
15370    // The typed lists are what land, whatever the caller's own metadata held under their
15371    // keys: a key of either name travelling beside the field would otherwise be a second
15372    // answer to the same question, and the field is the one the contract names.
15373    for (key, entries) in [
15374        (TaskRef::DELIVERS_KEY, incoming.delivers),
15375        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
15376    ] {
15377        set_task_list(&mut metadata, key, entries);
15378    }
15379    record_edges(&mut metadata, fallback);
15380    metadata
15381}
15382
15383/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
15384/// one slot's metadata, or no such key when there are none.
15385fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
15386    if fallback.is_empty() {
15387        metadata.remove(DependencyEdge::RECORDED_KEY);
15388    } else {
15389        metadata.insert(
15390            DependencyEdge::RECORDED_KEY.to_owned(),
15391            Value::Array(
15392                fallback
15393                    .iter()
15394                    .map(|edge| {
15395                        serde_json::to_value(&edge.to).expect("an endpoint renders as JSON")
15396                    })
15397                    .collect(),
15398            ),
15399        );
15400    }
15401}
15402
15403/// Every label one item carries, from its content's own connection and nowhere else.
15404///
15405/// There is no second place to read one from: no document this source sends selects the
15406/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
15407/// cannot carry one at all. The module documentation records the three schema facts that
15408/// settle it.
15409fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
15410    optional_nodes(content.get("labels"), "content labels")?
15411        .into_iter()
15412        .flatten()
15413        .map(|v| {
15414            Ok(Label {
15415                id: NativeId(required_str(v, "id")?.to_owned()),
15416                name: required_str(v, "name")?.to_owned(),
15417                color: optional_str(v, "color")?.map(str::to_owned),
15418            })
15419        })
15420        .collect()
15421}
15422
15423/// Each board field value one item holds, as the change token's facets name it: the field's
15424/// name, with the single-select option's name or the text. A value of a type no read here
15425/// selects, and one naming no field, says nothing a person can rename and is left out.
15426fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
15427    field_values
15428        .iter()
15429        .filter_map(|value| {
15430            let field = value.pointer("/field/name")?.as_str()?;
15431            let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
15432            Some((field.to_owned(), held.to_owned()))
15433        })
15434        .collect()
15435}
15436
15437/// The digest of the facets of the issue `content`, whose board item's values are
15438/// `field_values` — or `None` when the response did not carry, whole, every member they are read
15439/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
15440/// label, and every field value.
15441///
15442/// What a write's own response spells its token from, by exactly the rules a read takes them by.
15443fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
15444    let ended = |connection: &Value| {
15445        connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
15446    };
15447    let labels = content.get("labels").filter(|labels| ended(labels))?;
15448    if !ended(field_values) {
15449        return None;
15450    }
15451    let parent = content.get("parent")?;
15452    let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
15453    let kind = BoardKind::of(
15454        content.get("title")?.as_str()?,
15455        !parent.is_null(),
15456        sub_issue_total(content).ok()?,
15457        ItemKind::from_metadata(&slot).ok()?,
15458    );
15459    let labels = self::labels(&json!({"labels": labels})).ok()?;
15460    let nodes = field_values.get("nodes")?.as_array()?;
15461    Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
15462}
15463
15464/// The definition of each board field one item's values are values of, in the shape a read
15465/// of the board's own `fields` gives one.
15466///
15467/// A value names its field through a fragment on that field's own type, so the type is
15468/// known from which kind of value it is: a single-select value's field is a
15469/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
15470/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
15471fn field_definitions(field_values: &[Value]) -> Vec<Value> {
15472    field_values
15473        .iter()
15474        .filter_map(|value| {
15475            let field = value.get("field")?.as_object()?;
15476            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
15477            let typename = if value.get("text").is_some() {
15478                "ProjectV2Field"
15479            } else if value.get("name").is_some() {
15480                "ProjectV2SingleSelectField"
15481            } else {
15482                return None;
15483            };
15484            let mut defined = field.clone();
15485            defined.insert("__typename".to_owned(), json!(typename));
15486            // Only a text field holds a text value, so the value says the field's type too.
15487            if typename == "ProjectV2Field" {
15488                defined.insert("dataType".to_owned(), json!("TEXT"));
15489            }
15490            Some(Value::Object(defined))
15491        })
15492        .collect()
15493}
15494
15495fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
15496    let Some(node) = field_values
15497        .iter()
15498        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
15499    else {
15500        return Ok(None);
15501    };
15502    Ok(optional_str(node, "text")?.map(str::to_owned))
15503}
15504
15505fn valid_github_owner(owner: &str) -> bool {
15506    !owner.is_empty()
15507        && owner.len() <= 39
15508        && !owner.starts_with('-')
15509        && !owner.ends_with('-')
15510        && !owner.contains("--")
15511        && owner
15512            .bytes()
15513            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
15514}
15515
15516/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
15517/// neither of the two names a path segment already means.
15518fn valid_github_repository_name(name: &str) -> bool {
15519    !name.is_empty()
15520        && name.len() <= 100
15521        && name != "."
15522        && name != ".."
15523        && name
15524            .bytes()
15525            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
15526}
15527
15528fn valid_environment_name(name: &str) -> bool {
15529    let mut bytes = name.bytes();
15530    bytes
15531        .next()
15532        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
15533        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
15534}
15535
15536/// How many sub-issues one issue has.
15537///
15538/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
15539/// absent or non-integer one is a response this source cannot read — and reading it as
15540/// zero would classify a project as a task, which is exactly the mistake the marker
15541/// exists to keep from happening quietly.
15542fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
15543    let summary = issue
15544        .get("subIssuesSummary")
15545        .ok_or_else(|| SourceError::Malformed {
15546            message: "GitHub issue is missing subIssuesSummary".into(),
15547        })?;
15548    summary
15549        .get("total")
15550        .and_then(Value::as_u64)
15551        .ok_or_else(|| SourceError::Malformed {
15552            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
15553        })
15554}
15555
15556/// One issue's own `number`.
15557///
15558/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
15559/// an issue in this module asks for it. So a read of one that comes back without it, or
15560/// with something that is not an unsigned integer, is a response this source cannot read —
15561/// absence here is **not** "this issue has no number". A draft is the content that has
15562/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
15563/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
15564fn issue_number(issue: &Value) -> Result<u64, SourceError> {
15565    issue
15566        .get("number")
15567        .and_then(Value::as_u64)
15568        .ok_or_else(|| SourceError::Malformed {
15569            message: "GitHub issue number is missing or is not an unsigned integer".into(),
15570        })
15571}
15572
15573/// The `number` a creating mutation answered with, and `None` when it answered without one;
15574/// why a missing one is tolerated is at the call in `create_and_file_issue`.
15575fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
15576    match created.get("number") {
15577        None | Some(Value::Null) => Ok(None),
15578        Some(value) => value
15579            .as_u64()
15580            .map(Some)
15581            .ok_or_else(|| SourceError::Malformed {
15582                message: "GitHub created issue number is not an unsigned integer".into(),
15583            }),
15584    }
15585}
15586
15587fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
15588    value
15589        .get(field)
15590        .and_then(Value::as_str)
15591        .ok_or_else(|| SourceError::Malformed {
15592            message: format!("GitHub response is missing string field {field}"),
15593        })
15594}
15595
15596fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
15597    let found = required_str(value, field)?;
15598    if found.trim().is_empty() {
15599        return Err(SourceError::Malformed {
15600            message: format!("GitHub response has blank string field {field}"),
15601        });
15602    }
15603    Ok(found)
15604}
15605
15606/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
15607/// needs one — Linear spells them too, in its own description field.
15608///
15609/// Restated rather than shared, because a plugin crate depends on the contract crate and
15610/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
15611/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
15612/// source round-trips its own writes perfectly well under its own spelling.
15613const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
15614const METADATA_CLOSE: &str = "\n-->";
15615
15616/// What the composer puts between a non-empty visible body and the slot, and the one thing
15617/// the parser takes off the visible body when it takes the slot off — exactly once, so every
15618/// other trailing byte of the body comes back as it was written.
15619// 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.
15620const METADATA_SEPARATOR: &str = "\n\n";
15621
15622/// The visible body and the metadata slot at the end of it.
15623///
15624/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
15625/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
15626/// own content and is left alone. The visible body is everything before the slot less the
15627/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
15628fn metadata_body(
15629    body: Option<String>,
15630) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
15631    let Some(body) = body else {
15632        return Ok((None, BTreeMap::new()));
15633    };
15634    let Some(slot) = slot_span(&body)? else {
15635        return Ok((Some(body), BTreeMap::new()));
15636    };
15637    let metadata =
15638        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
15639            SourceError::Malformed {
15640                message: format!(
15641                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
15642                ),
15643            }
15644        })?;
15645    let before = &body[..slot.start];
15646    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
15647    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
15648}
15649
15650/// Where the metadata slot sits in one body, as byte offsets into it.
15651struct SlotSpan {
15652    /// Where [`METADATA_OPEN`] begins.
15653    start: usize,
15654    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
15655    encoded_start: usize,
15656    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
15657    encoded_end: usize,
15658    /// Just past [`METADATA_CLOSE`].
15659    end: usize,
15660}
15661
15662/// The slot at the very end of `body`, or `None` when it has none.
15663///
15664/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
15665/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
15666/// slot.
15667fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
15668    let Some(start) = body.rfind(METADATA_OPEN) else {
15669        return Ok(None);
15670    };
15671    let encoded_start = start + METADATA_OPEN.len();
15672    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
15673        return Err(SourceError::Malformed {
15674            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
15675        });
15676    };
15677    let encoded_end = encoded_start + relative_end;
15678    let end = encoded_end + METADATA_CLOSE.len();
15679    if !body[end..].trim().is_empty() {
15680        return Ok(None);
15681    }
15682    Ok(Some(SlotSpan {
15683        start,
15684        encoded_start,
15685        encoded_end,
15686        end,
15687    }))
15688}
15689
15690/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
15691/// slot as it was.
15692///
15693/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
15694/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
15695/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
15696/// or alone in an empty body — and a body with no slot that is given no metadata is
15697/// returned as it is.
15698fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
15699    let encoded = if metadata.is_empty() {
15700        None
15701    } else {
15702        Some(
15703            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
15704                message: error.to_string(),
15705            })?,
15706        )
15707    };
15708    Ok(match (slot_span(body)?, encoded) {
15709        (Some(slot), Some(encoded)) => format!(
15710            "{}{encoded}{}",
15711            &body[..slot.encoded_start],
15712            &body[slot.encoded_end..]
15713        ),
15714        (Some(slot), None) => {
15715            let before = &body[..slot.start];
15716            format!(
15717                "{}{}",
15718                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
15719                &body[slot.end..]
15720            )
15721        }
15722        (None, None) => body.to_owned(),
15723        (None, Some(encoded)) if body.is_empty() => {
15724            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
15725        }
15726        (None, Some(encoded)) => {
15727            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
15728        }
15729    })
15730}
15731
15732/// `body` with everything before its metadata slot replaced by `content`, and the slot
15733/// itself kept byte for byte.
15734///
15735/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
15736/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
15737/// `content` is empty — so a read of the result reports `content` as the visible body and
15738/// the slot's metadata exactly as it was.
15739fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
15740    let Some(slot) = slot_span(body)? else {
15741        return Ok(content.to_owned());
15742    };
15743    let kept = &body[slot.start..];
15744    Ok(if content.is_empty() {
15745        kept.to_owned()
15746    } else {
15747        format!("{content}{METADATA_SEPARATOR}{kept}")
15748    })
15749}
15750
15751/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
15752fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
15753    if entries.is_empty() {
15754        metadata.remove(key);
15755    } else {
15756        metadata.insert(
15757            key.to_owned(),
15758            Value::Array(
15759                entries
15760                    .iter()
15761                    .map(|entry| Value::String(entry.as_str().to_owned()))
15762                    .collect(),
15763            ),
15764        );
15765    }
15766}
15767
15768fn compose_body(
15769    content: Option<&str>,
15770    metadata: &BTreeMap<String, Value>,
15771) -> Result<Option<String>, SourceError> {
15772    let visible = content.unwrap_or_default();
15773    if metadata.is_empty() {
15774        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
15775    }
15776    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
15777        message: error.to_string(),
15778    })?;
15779    Ok(Some(if visible.is_empty() {
15780        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
15781    } else {
15782        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
15783    }))
15784}
15785
15786fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
15787    value
15788        .get(field)
15789        .and_then(Value::as_bool)
15790        .ok_or_else(|| SourceError::Malformed {
15791            message: format!("GitHub response is missing boolean field {field}"),
15792        })
15793}
15794fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
15795    match value.get(field) {
15796        None | Some(Value::Null) => Ok(None),
15797        Some(value) => value
15798            .as_str()
15799            .map(Some)
15800            .ok_or_else(|| SourceError::Malformed {
15801                message: format!("GitHub response field {field} is not a string or null"),
15802            }),
15803    }
15804}
15805fn optional_nodes<'a>(
15806    connection: Option<&'a Value>,
15807    name: &str,
15808) -> Result<Option<&'a Vec<Value>>, SourceError> {
15809    match connection {
15810        None | Some(Value::Null) => Ok(None),
15811        Some(value) => value
15812            .get("nodes")
15813            .and_then(Value::as_array)
15814            .map(Some)
15815            .ok_or_else(|| SourceError::Malformed {
15816                message: format!("GitHub {name}.nodes is not an array"),
15817            }),
15818    }
15819}
15820fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
15821    let page_info = connection
15822        .get("pageInfo")
15823        .ok_or_else(|| SourceError::Malformed {
15824            message: format!("GitHub {name} has no pageInfo"),
15825        })?;
15826    if required_bool(page_info, "hasNextPage")? {
15827        return Err(SourceError::Malformed {
15828            message: format!(
15829                "GitHub {name} exceeds the supported nested connection size of {size}"
15830            ),
15831        });
15832    }
15833    Ok(())
15834}
15835fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
15836    optional_str(value, field)?
15837        .map(|timestamp| {
15838            timestamp.parse().map_err(|error| SourceError::Malformed {
15839                message: format!("GitHub response field {field} is not a timestamp: {error}"),
15840            })
15841        })
15842        .transpose()
15843}
15844fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
15845    if page.limit == 0 {
15846        Err(SourceError::Config {
15847            message: "page limit must be at least 1".into(),
15848        })
15849    } else {
15850        Ok(())
15851    }
15852}
15853fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
15854    let page = connection
15855        .get("pageInfo")
15856        .filter(|value| value.is_object())
15857        .ok_or_else(|| SourceError::Malformed {
15858            message: "GitHub connection is missing pageInfo".into(),
15859        })?;
15860    if required_bool(page, "hasNextPage")? {
15861        let cursor = required_str(page, "endCursor")?;
15862        validate_cursor_progress(None, cursor)?;
15863        Ok(Some(Cursor(cursor.into())))
15864    } else {
15865        Ok(None)
15866    }
15867}
15868fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
15869    if next.is_empty() || previous == Some(next) {
15870        Err(SourceError::Malformed {
15871            message: "GitHub pagination cursor is empty or did not advance".into(),
15872        })
15873    } else {
15874        Ok(())
15875    }
15876}
15877/// The version of this plugin's opaque narrowing-search cursor.
15878pub const SEARCH_CURSOR_VERSION: u32 = 4;
15879
15880#[derive(Serialize, Deserialize)]
15881#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
15882enum SearchConnection {
15883    Initial {},
15884    Continuing { after: Cursor },
15885    Exhausted {},
15886}
15887impl SearchConnection {
15888    fn after(&self) -> Option<&str> {
15889        match self {
15890            Self::Continuing { after } => Some(&after.0),
15891            _ => None,
15892        }
15893    }
15894    fn exhausted(&self) -> bool {
15895        matches!(self, Self::Exhausted { .. })
15896    }
15897    /// Whether a cursor naming this position, `offset` rows into its page, is one this
15898    /// plugin could have handed out: a page is resumed only part of the way through it — an
15899    /// offset of a whole page or more would skip rows nobody was given — an initial page
15900    /// only once some of it was handed out, and an exhausted connection has no page to be
15901    /// part of the way through.
15902    fn valid_resume(&self, offset: usize) -> bool {
15903        let within = offset < SEARCH_PAGE_SIZE as usize;
15904        match self {
15905            Self::Initial { .. } => offset > 0 && within,
15906            Self::Continuing { after } => !after.0.is_empty() && within,
15907            Self::Exhausted { .. } => offset == 0,
15908        }
15909    }
15910}
15911
15912/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
15913#[derive(Serialize, Deserialize)]
15914#[serde(deny_unknown_fields)]
15915struct SearchPosition {
15916    version: u32,
15917    connection: SearchConnection,
15918    /// How many rows of the page `connection` starts were already handed out.
15919    #[serde(default, skip_serializing_if = "is_zero")]
15920    offset: usize,
15921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15922    seen: Vec<NativeId>,
15923    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15924    own: Vec<NativeId>,
15925}
15926impl Default for SearchPosition {
15927    fn default() -> Self {
15928        Self {
15929            version: SEARCH_CURSOR_VERSION,
15930            connection: SearchConnection::Initial {},
15931            offset: 0,
15932            seen: Vec::new(),
15933            own: Vec::new(),
15934        }
15935    }
15936}
15937
15938fn is_zero(offset: &usize) -> bool {
15939    *offset == 0
15940}
15941
15942fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
15943    cursor.map_or(Ok(0), |c| {
15944        c.0.parse().map_err(|_| SourceError::Config {
15945            message: "page cursor is invalid".into(),
15946        })
15947    })
15948}
15949fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
15950    if offset > items.len() {
15951        return Page::last(vec![]);
15952    }
15953    let tail = items.split_off(offset);
15954    let mut selected = tail;
15955    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
15956    selected.truncate(limit);
15957    Page {
15958        items: selected,
15959        next,
15960    }
15961}
15962
15963impl GitHubProjectsSource {
15964    async fn write_task_assets(
15965        &self,
15966        write: &ItemWrite<Task>,
15967        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15968    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15969        self.write_item(
15970            &self.task_incoming(write, assets)?,
15971            write.target.as_ref(),
15972            &write.depends_on,
15973        )
15974        .await
15975    }
15976
15977    /// One task write as [`Self::write_item`] takes it, once what it delivers and its priority
15978    /// are known to be ones this source can write.
15979    fn task_incoming<'a>(
15980        &self,
15981        write: &'a ItemWrite<Task>,
15982        assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
15983    ) -> Result<Incoming<'a>, SourceError> {
15984        let near = write.target.as_ref().unwrap_or(&write.item.id);
15985        for (key, entries) in [
15986            (TaskRef::DELIVERS_KEY, &write.item.delivers),
15987            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
15988        ] {
15989            TaskRef::listed(key, near, Some(&self.name), entries.clone())
15990                .map_err(|message| SourceError::Refused { message })?;
15991        }
15992        if self.priorities.is_none() && write.item.priority != Priority::None {
15993            return Err(self.holds_no_priority());
15994        }
15995        Ok(Incoming {
15996            written: Written::Work(ItemKind::Task, &write.item.status),
15997            title: &write.item.title,
15998            content: write.item.content.as_deref(),
15999            assets,
16000            labels: &write.item.labels,
16001            metadata: &write.item.metadata,
16002            repositories: &write.item.repositories,
16003            classification: write.item.classification,
16004            parent: write.item.project.as_ref(),
16005            delivers: &write.item.delivers,
16006            delivered_by: &write.item.delivered_by,
16007            priority: self.priorities.as_ref().map(|_| write.item.priority),
16008        })
16009    }
16010    async fn write_document_assets(
16011        &self,
16012        write: &ItemWrite<Document>,
16013        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
16014    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
16015        // A document takes part in no dependency graph, so there is no far end to write
16016        // natively and none to record: a caller naming one is told so rather than having it
16017        // stored under the reserved key, where a later read would report an edge the
16018        // contract says cannot exist.
16019        if !write.depends_on.is_empty() {
16020            return Err(SourceError::Refused {
16021                message: format!(
16022                    "this write names {} dependencies for a document, and a document takes \
16023                     part in no dependency graph; next: put the dependency on the task or \
16024                     project the document is about",
16025                    write.depends_on.len()
16026                ),
16027            });
16028        }
16029        self.write_item(
16030            &Incoming {
16031                written: Written::Document,
16032                title: &write.item.title,
16033                content: write.item.content.as_deref(),
16034                assets,
16035                labels: &write.item.labels,
16036                metadata: &write.item.metadata,
16037                repositories: &write.item.repositories,
16038                classification: write.item.classification,
16039                parent: write.item.project.as_ref(),
16040                delivers: &[],
16041                delivered_by: &[],
16042                priority: None,
16043            },
16044            write.target.as_ref(),
16045            &[],
16046        )
16047        .await
16048    }
16049}
16050
16051/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
16052/// itself, for the two things no journey can observe.
16053///
16054/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
16055/// with and without the call, that a settlement, a board listing and a metadata search each
16056/// read afresh after it — the resolved records, the written-item overlay, the board and its
16057/// search, and the narrowed searches. What they cannot reach is the held field definitions,
16058/// because a status write naming an option a person deleted is refused the same whether or
16059/// not the list is held, and a poisoned lock, because nothing outside the source can panic
16060/// while one of its locks is held. So these assert those directly, and every other holder
16061/// beside them so a holder added later without a clear in the call fails here.
16062#[cfg(test)]
16063mod end_command_tests {
16064    use super::*;
16065
16066    struct Token;
16067
16068    impl SecretResolver for Token {
16069        fn get(&self, var: &str) -> Option<SecretString> {
16070            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
16071        }
16072    }
16073
16074    fn source() -> GitHubProjectsSource {
16075        let config = serde_json::from_value(json!({
16076            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
16077            // Nothing here is sent: the source is only built and its state inspected.
16078            "endpoint": "http://127.0.0.1:9/graphql",
16079        }))
16080        .expect("a usable configuration");
16081        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
16082            .expect("the source builds")
16083    }
16084
16085    /// One issue as a board read answers it.
16086    fn resolved(source: &GitHubProjectsSource) -> Resolved {
16087        source
16088            .resolve(&json!({
16089                "id": "ITEM-1",
16090                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
16091                            "body": "what a person may since have edited", "state": "OPEN",
16092                            "stateReason": null, "url": null, "number": 1,
16093                            "subIssuesSummary": {"total": 0},
16094                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
16095                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
16096            }))
16097            .expect("the item reads")
16098            .expect("an issue")
16099    }
16100
16101    /// Hold something in every holder the call clears, and the repository id it keeps.
16102    fn fill(source: &GitHubProjectsSource) {
16103        let item = resolved(source);
16104        source.created.lock().unwrap().push(item.clone());
16105        source.updated.lock().unwrap().push(item.clone());
16106        *source.board_cache.lock().unwrap() = Some(Board {
16107            id: "PVT-board".into(),
16108            fields: json!({"nodes": []}),
16109            items: vec![item.clone()],
16110        });
16111        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
16112        source
16113            .narrowed_cache
16114            .lock()
16115            .unwrap()
16116            .insert("status:todo".into(), vec![item.clone()]);
16117        source.children_cache.lock().unwrap().insert(
16118            NativeId("P-1".into()),
16119            (NativeId("P-1".into()), vec![item.clone()]),
16120        );
16121        source
16122            .search_next
16123            .lock()
16124            .unwrap()
16125            .insert("status:todo".into(), Some("cursor".into()));
16126        source
16127            .resolved_cache
16128            .lock()
16129            .unwrap()
16130            .insert(item.id.clone(), item);
16131        *source.fields_cache.lock().unwrap() = Some(BoardFields {
16132            id: BoardId::parse("PVT-board").unwrap(),
16133            fields: json!({"nodes": []}),
16134        });
16135        source
16136            .repository_cache
16137            .lock()
16138            .unwrap()
16139            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
16140    }
16141
16142    fn assert_dropped(source: &GitHubProjectsSource) {
16143        assert!(source.created().unwrap().is_empty(), "created");
16144        assert!(source.updated().unwrap().is_empty(), "updated");
16145        assert!(source.board_cache().unwrap().is_none(), "board");
16146        assert!(source.search_cache.lock().unwrap().is_none(), "search");
16147        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
16148        assert!(
16149            source.children_cache.lock().unwrap().is_empty(),
16150            "project children"
16151        );
16152        assert!(
16153            source.search_next.lock().unwrap().is_empty(),
16154            "search paging"
16155        );
16156        assert!(
16157            source.resolved_cache().unwrap().is_empty(),
16158            "resolved records"
16159        );
16160        assert!(source.fields_cache().unwrap().is_none(), "board fields");
16161        assert_eq!(
16162            source.repository_cache().unwrap().len(),
16163            1,
16164            "a repository's node id stays valid and is kept"
16165        );
16166    }
16167
16168    fn end(source: &GitHubProjectsSource) {
16169        tokio::runtime::Builder::new_current_thread()
16170            .build()
16171            .unwrap()
16172            .block_on(source.end_command())
16173            .expect("the command ends");
16174    }
16175
16176    #[test]
16177    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
16178        let source = source();
16179        fill(&source);
16180        end(&source);
16181        assert_dropped(&source);
16182    }
16183
16184    #[test]
16185    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
16186        fn poison<T: Send>(held: &Mutex<T>) {
16187            std::thread::scope(|scope| {
16188                let _ = scope
16189                    .spawn(|| {
16190                        let _guard = held.lock().unwrap();
16191                        panic!("a failure while the lock is held");
16192                    })
16193                    .join();
16194            });
16195            assert!(held.is_poisoned());
16196        }
16197        let source = source();
16198        fill(&source);
16199        poison(&source.created);
16200        poison(&source.updated);
16201        poison(&source.board_cache);
16202        poison(&source.search_cache);
16203        poison(&source.narrowed_cache);
16204        poison(&source.search_next);
16205        poison(&source.resolved_cache);
16206        poison(&source.fields_cache);
16207        assert!(
16208            source.resolved_cache().is_err(),
16209            "a poisoned lock is refused before the call"
16210        );
16211        end(&source);
16212        assert_dropped(&source);
16213    }
16214}
16215
16216#[cfg(test)]
16217mod unprojectable_fields_tests {
16218    use super::*;
16219
16220    /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
16221    fn documented() -> std::collections::BTreeSet<String> {
16222        let document = std::fs::read_to_string(
16223            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
16224        )
16225        .expect("docs/metadata.md is readable");
16226        let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
16227        let lead = "a `field` equal, ignoring case, to ";
16228        let start = document.find(lead).expect("the refused-field sentence") + lead.len();
16229        let sentence = &document[start..];
16230        let sentence = &sentence[..sentence
16231            .find("; and two entries naming one field")
16232            .expect("the refused-field sentence ends at the duplicate rule")];
16233        let (own, github) = sentence
16234            .split_once(" or a field GitHub owns on every board (")
16235            .expect("the sentence names GitHub's own fields");
16236        own.split('`')
16237            .skip(1)
16238            .step_by(2)
16239            .chain(
16240                github
16241                    .strip_suffix(')')
16242                    .expect("GitHub's own fields close the sentence")
16243                    .split(", "),
16244            )
16245            .map(str::to_owned)
16246            .collect()
16247    }
16248
16249    #[test]
16250    fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
16251        let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
16252            .iter()
16253            .map(|&name| name.to_owned())
16254            .collect();
16255        assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
16256        let instance = SourceName::new("work").unwrap();
16257        for name in UNPROJECTABLE_FIELDS {
16258            for spelled in [name.to_owned(), name.to_uppercase()] {
16259                let entry = MetadataFieldConfig {
16260                    field: spelled.clone(),
16261                    key: "team.machine".to_owned(),
16262                    path: Vec::new(),
16263                };
16264                let refused = MetadataField::resolve(vec![entry], &instance)
16265                    .expect_err("an unprojectable field is refused");
16266                assert!(
16267                    refused.to_string().contains(&format!("{name:?}")),
16268                    "{spelled}: {refused}"
16269                );
16270            }
16271        }
16272    }
16273}