Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A write held to a version** (`--if-version`) is checked immediately before its first
4//! mutation and not as one step with it: GitHub takes no conditional write, so the engine reads
5//! the task through this source just before writing it, and a change landing between that read
6//! and the mutation — the time one request takes — is GitHub's residual race.
7//!
8//! **A board is a container of projects, not a project.** Its own `title`,
9//! `shortDescription` and `readme` are never read as an item's fields and are never
10//! written: nothing in this source can rename the board a user configured.
11//!
12//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
13//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
14//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
15//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
16//! naming the field — but that repository is the *fallback*, not the home of every item.
17//!
18//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
19//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
20//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
21//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
22//! change to the rule. -->
23//! **Which repository an issue is created in is decided by the item's own `repositories`
24//! field, under one rule.** Exactly one entry names the repository the issue is created in:
25//! a task issue is where a person finds the work from the repository it changes, and one
26//! filed in a board's nominated repository is invisible from every other. Zero entries, or
27//! two or more, name none, so a task's or a document's issue is created in the repository
28//! its parent project's issue lives in — read from the board, or from this process's own
29//! record of a project it created earlier in the same command — and a project's issue, or
30//! a task or document written with no parent, is created in the configured `repository:`.
31//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
32//! existing issue is never moved: the update path leaves the issue where it is and records
33//! the list in the metadata slot when it differs, so the read side's derivation and the
34//! creation rule agree by construction.
35//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
36//!
37//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
38//! board has no document type and nothing but issues to hold one in, so the title is the
39//! discriminator and it is the whole of it. The title this source *reports* is the one a
40//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
41//! the body so `content` is what the person wrote — and writing a document puts the prefix
42//! back, so a round trip returns the title that went in.
43//!
44//! **Telling a document from a project from a task.** The design prefix is read **first**:
45//! a document is never a project and never a task, whatever sub-issues it has or does not
46//! have. Only then does the rest apply — a board issue is a project when *either* it has
47//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
48//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
49//! necessary: it is what makes an *empty* project — the state a project copy passes
50//! through between creating the project and filing its first task — readable as a
51//! project, while the sub-issue arm lets a person author a project on the board by hand
52//! with no knowledge of this product's metadata at all. Reading the prefix later than the
53//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
54//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
55//! task nor a document and are ignored.
56//!
57//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
58//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
59//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
60//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
61//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
62//! every comment as the account the token belongs to, so a comment handed an author of its
63//! own is refused rather than posted under another name. GitHub's comment mutations take the
64//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
65//! is on, and a comment on some other issue is one this task does not have.
66//!
67//! **Where an entity is, is a link.** Every project, task and document this source reports
68//! carries a [`Location::Url`] naming the issue's own web address — the same address the
69//! `url` field already reports, in the shape that says a reader can open it. That is the
70//! contrast the location contract exists for: a reader holding an entity from this source
71//! is handed something to link to and one holding an entity from a folder of Markdown is
72//! handed a path, and neither has to know which plugin answered. It does not replace or
73//! derive from `url`; that field goes on reporting what it always reported.
74//!
75//! **Where metadata lives.** Short typed things go to typed fields and native relations:
76//! status to the board's `Status` single-select and the issue's own state, the copy
77//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
78//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
79//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
80//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
81//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
82//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
83//! a project or a document alike — is one update of the issue body that changes that slot
84//! and not one byte outside it, and it is not sent at all when the key already holds the
85//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
86//! is kept in that same slot, written by that same update.
87//!
88//! **A caller's value can also be projected onto a board text field**, so the board can filter
89//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
90//! metadata key and a path inside its value. The slot stays the value's one home — every read
91//! reports metadata from it alone — and the field follows it on every write of an item, a
92//! create, an update and a copy alike: a string is written, and not sent again while the field
93//! holds it — an empty string included, which is written as itself; nothing there or `null`
94//! clears a field holding a value; any other
95//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
96//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
97//! own moves its field first and the body last, as every write of an existing item does. A
98//! board without the field, or with a non-text field of that name, is refused before any
99//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
100//! a text field and never changes a field that is there.
101//!
102// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
103//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
104//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
105//! option of the board's one `Status` field for a task and for a project: a bare name is
106//! the option for both kinds, `null` disables the category for both, and `{task, project}`
107//! names it per kind. A category the mapping does not mention keeps its shipped default for
108//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
109//! gets the shipped default for the kind it leaves out. Two categories one kind would read
110//! back from one option are refused as the configuration is read, ignoring case, while one
111//! option may stand for different categories of the two kinds. Writes go by the kind of the
112//! item written: a status that kind has no option for, or whose option the board lacks, is
113//! refused before any mutation, naming the source, the kind, the category and the key
114//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
115//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
116//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
117//! before selecting its option. Reads give a closed issue's reason precedence over its
118//! option, while an open issue's option decides its category through its own kind's
119//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
120//! The guarded [`GitHubProjectsSource::status_options`] and
121//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
122//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
123//! existing option id and verify the field and item assignments immediately afterwards.
124//! They ask for both kinds' options, counting a terminal category's mapped option as
125//! configured because a terminal write refuses without it. No ordinary source read or
126//! write calls that mutation, whose
127//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
128//! and a mistake destroys every item's status. A status this board cannot represent is a
129//! refusal naming the status and the instance instead.
130//!
131//! `unknown` has no shipped option because this source cannot preserve an open-ended
132//! status word: it writes an existing board option and never
133//! creates an option. An operator may map `unknown` to one existing option, in which case
134//! every unknown word lands on that option and reads back as `unknown` under the option's
135//! name. This differs from `local-md`, which writes and reads the original word itself.
136//!
137//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
138//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
139//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
140//! finished tasks were only moved to a "Done" column would read 0% complete forever.
141// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
142//!
143//! # What this source declares, field by field
144//!
145//! One verdict per field of [`Capabilities`], and what `Native` means when this source
146//! says it. *Proven* means a shared journey drives it against the real
147//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
148//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
149//! [`capabilities`](TaskSource::capabilities) from parting.
150//!
151//! | Field | Verdict |
152//! | --- | --- |
153//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
154//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
155//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
156//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
157//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
158//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
159//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
160//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
161//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
162//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
163//! | `filter_by_metadata` | **Supported, and asked of GitHub** — through the body and never through a projected `metadata_fields` text field, which is the board's to filter on and not this source's to read. A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
164//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
165//! | `reconcile_status` | **Supported,** and proven by `onetaskgraph-github-projects-e2e`'s `status_reconcile.rs`: the board's own `ProjectV2.items(query:)` filter, `is:closed`, read page by page and never the unfiltered items, each candidate confirmed from its own item node — a closed task whose card is not at the option the mapping gives its closure's category. |
166//! | `complete_by_issue` | **Supported,** and proven by `status_complete.rs` there and in the engine's suite: each issue a reference names read in one aliased request per fifty — its repository, its number, its first page of board memberships with each card's `Status` read by name — and nothing else of the board. |
167//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
168//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
169//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
170//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
171//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
172//!
173//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
174//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
175//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
176//! and the raw bytes as the request body. The numeric repository id is read at most once
177//! per repository per source instance. A loopback API endpoint also selects the loopback
178//! uploads host; production needs no assets repository, commits or extra configuration.
179//! Every returned JSON `url` is read with the same token before a body is written: only
180//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
181//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
182//! asset and HTTP status and says the token type may not be accepted by the upload
183//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
184//! fine-grained PATs or OAuth tokens.
185//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
186//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
187//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
188//! and verified afresh, in the repository the issue already lives in on an update.
189//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
190//! Uploads take the same mutation spacing as content writes. Both uploads and verification
191//! reads wait out classified rate limits within the configured per-call retry budget,
192//! on the supplied clock; verifying reads take no mutation slot.
193//!
194//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
195//! source has documents and that its tasks have comments, both of which hold — and the three
196//! facts behind the uniform `Native` on the
197//! predicates beside it are recorded below rather than re-derived, because a reader who
198//! takes `Native` to mean *the remote service filters* will read that uniformity as a
199//! lie.
200//!
201//! First, the plugin contract defines `Support::Native` as *the source applies this
202//! predicate itself*, and says nothing about where it applies it. What the declaration
203//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
204//! — so that the engine may push it down and apply nothing of its own.
205//!
206//! Second, this source can keep that promise for every predicate at no additional API
207//! cost, because whichever of the reads below answers a query has already read every
208//! candidate that query will return before it filters anything. Filtering those items is
209//! in-process work over data already in hand.
210//!
211//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
212//! applied in process over what that question returned. A project filter has a relationship — a
213//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
214//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
215//! a search for metadata values, is the board-scoped issue search carrying the text and each
216//! value as quoted phrases; an origin is the board's own field filter over its origin field
217//! beside the same search for the id. **The text search narrows, and that is this source's
218//! declared semantics:** GitHub matches whole words where the substring rule this source and
219//! the local Markdown source confirm with would match inside one, so an item holding the text
220//! only inside a longer word is never a candidate. Every item returned does contain the text.
221//! A project query's text, and a document query's scoped to no project, is that same search
222//! and narrows on the same terms, its candidates confirmed by their kind as well.
223//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
224//! so those three are applied in process over the candidates, and a query carrying none of
225//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
226//! engine compensate for work this source has already done, and declaring `projects` native
227//! while ignoring the filter (which this source once did) silently returns another project's
228//! tasks, because the engine trusts the declaration and applies nothing locally.
229//!
230//! # The three ways this source reaches an item, and what each costs
231//!
232//! A board read is charged for what its *nested* connections could return rather than for
233//! what was asked, so one whole-board read costs the same whether the question was about
234//! one project or about all of them. That is why a question about one project is never
235//! answered by reading the board:
236//!
237//! | The question | What is sent | What it costs |
238//! | --- | --- | --- |
239//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)`, carrying the field definitions of the boards it sits on and the far ends of its `blockedBy`, which is what a write of it needs — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
240//! | one task with its first page of comments, for `task show` and a comment listing | [`graphql::ISSUE_DETAIL`] — the same `node(id:)` read with the issue's `comments` | the item and a page of its comments |
241//! | one board page with each task's first comments, for `TaskSource::query_task_details` | [`graphql::BOARD_WITH_COMMENTS`] — the board's own `items`, without search completion | tasks and first comment pages in one request; board consistency, with comment-activity filtering refused |
242//! | several tasks with their comments, for `task show-many` | [`graphql::ISSUE_DETAILS`] — [`DETAIL_BATCH`] aliased `node(id:)` fields per request | each item and a page of its comments |
243//! | the board's own id and field definitions, for a write whose item does not carry them | [`graphql::BOARD_FIELDS`] — the board's `id` and `fields`, and no `items` — or, for a create that needs the repository's id too, [`graphql::CREATION_CONTEXT`], both in one request | the board's fields |
244//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
245//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
246//! | which projects hold a text, or which documents do when no project narrows the question | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text as one quoted phrase, `in:title`, `in:body` or both, as a task's text is sent — walked to its end in pages of twenty | the issues that match |
247//! | which tasks were commented on since an instant | [`graphql::SEARCH_ISSUES`] — the same board-scoped search with an `updated:>=` qualifier — then [`graphql::ISSUE_COMMENTS`] for each candidate it names | the issues updated since, and their comments |
248//! | which tasks hold a text, or a metadata value | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text and each value as quoted phrases, `in:title`, `in:body` or both, and an `updated:>=` qualifier too when comment activity is asked for — in pages of twenty, only as many as the caller's rows need | the issues that match |
249//! | which tasks were copied from one origin | [`graphql::ORIGIN_LOOKUP`] — the board's own `items` under its field filter on the origin field, and the same board-scoped search for the id `in:body`, in one request, each paged at three | the carriers of that origin, which is one item |
250//! | every task, every document, every label, when nothing above narrows the question | [`graphql::BOARD`] — the board's own `items` — **and** [`graphql::SEARCH_ISSUES`], because neither enumeration of a board is complete alone; see [`GitHubProjectsSource::board`] | the board, twice over |
251//! | which board item one issue is, past the page that came with it | [`graphql::ISSUE_BOARD_ITEMS`] — that issue's own `projectItems` | one issue's memberships |
252//!
253//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
254//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
255//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
256//! equal to declared points equal to the row. They include the origin lookup and the
257//! field/repository discovery a create needs. A bound re-copy changes status, priority,
258//! content and metadata; comment recount means a subsequent detail read. Each request here
259//! costs one declared point. A membership beyond the embedded page can additionally require
260//! the one-point membership recovery described above. A bound re-copy of a task filed under a
261//! project adds one read, the engine confirming that project's link by its own id once per
262//! command; and the same-source far ends a write newly names — those that do not already block
263//! the item, whose own read answered for them — are read together by their own ids,
264//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
265//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
266//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
267//!
268//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
269//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
270//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
271//! holds both halves.
272//!
273//! **An existing item is written body last.** A bound re-copy and a `task update` send its
274//! board fields first — the `Status` option and the `Priority` together, in one request — then
275//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
276//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
277//! undoing an earlier field when a later one fails, so that order is what makes a write
278//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
279//! the one piece of metadata written before the body, an origin a copy re-points, is put back
280//! when a later write is refused — and when putting it back is refused too, the write's own
281//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
282//! of those writes in turn, whole and as one aliased field failing after the one before it.
283//!
284//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
285//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
286//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
287//!
288//! - **A board is accepted at creation but its item is not answered, so a create still files
289//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
290//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
291//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
292//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
293//!   against a real board on 2026-10-01 with a create sending the board there and reading the
294//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
295//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
296//!   refused "Content already exists in this project" — GitHub had filed the issue after
297//!   answering, and refuses a second filing rather than answering with the item it holds. A
298//!   create therefore sends no `projectV2Ids` and files the issue with
299//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
300//!   real is the read before it: the board's fields and the repository's id together, in
301//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
302//! - **A comment still reads its target first, so a comment is 2 requests.**
303//!   `AddCommentInput.subjectId: ID!` is declared there with
304//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
305//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
306//!   project's issue, a document's issue, an issue on no board of this source and a pull
307//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
308//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
309//!   refuses those by name.
310//!
311//! | Verb | Requests / points | Documents |
312//! | --- | --- | --- |
313//! | new copy | 4 | ORIGIN_LOOKUPS (the origin, the board's fields and the repository's node and numeric ids together, a page of one carrier for each origin), CREATE_ISSUE filing the issue on the board and under its project, CREATED_BOARD_ITEMS once GitHub has added it, WRITE_FIELD_VALUES |
314//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, CREATED_BOARD_ITEMS, WRITE_FIELD_VALUES: the new copy with no origin to ask about |
315//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS when a board field differs, then UPDATE_ISSUE last carrying only the title, body or state that differs — none, when nothing does |
316//! | bound copy, filed under a project | 4 | the bound copy's three, and one ISSUE of the destination project its link names, read once per command |
317//! | bound copy, newly naming n dependencies | + ceil(n / DETAIL_BATCH) + n | ISSUE_DETAILS for the far ends that do not already block the item, DETAIL_BATCH (24) to a request (one alone is ISSUE), then one ADD_BLOCKED_BY each; a far end already blocking it is answered by its own read and costs nothing |
318//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
319//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
320//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
321//! | copy --comments, n changed tasks | ceil(n / COPY_DETAIL_BATCH) | ISSUE_COPY_DETAILS: each task with its first page of comments and its `blockedBy`, COPY_DETAIL_BATCH (21) at a time; a task whose comments run past that page adds one ISSUE_DETAIL per further page, and a task skipped from its listing costs nothing beyond it |
322//! | recount | 1 | ISSUE_DETAIL |
323//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
324//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
325//! | content | 2 | ISSUE, UPDATE_ISSUE |
326//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
327//! | document metadata, `--expect` a version | 2 | ISSUE, then UPDATE_ISSUE only when the issue is still at that version — the precondition GitHub's own update cannot take — primed with what the read answered |
328//! | update | 3 | `task update` naming any of title, body, metadata, status and priority — all five included: ISSUE, UPDATE_FIELDS (the status option and the priority together), UPDATE_ISSUE (title, body with its metadata slot, and state) last |
329//! | record only | 1 | ISSUE |
330//!
331//! <!-- github-search-paging:start -->
332//! Board-scoped text, metadata, project-name and comment-activity searches send every
333//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
334//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
335//! needs rows. A page is never resized to the rows still needed: GitHub orders one
336//! search differently at different page sizes, so one fixed size makes a paged walk
337//! send exactly the requests one whole read sends, and the answer's order is the order
338//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
339//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
340//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
341//! returned and fetched pages: a limit is sliced from the pages it needs, and local
342//! confirmation can require more candidates than matching rows. Walking all pages
343//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
344//! cursor and how far into that page the last answer stopped, and resumes in the same
345//! process or a new one, without duplicates or gaps. It carries no rows: one process
346//! sends each page's search once, and a new process re-reads only the page it resumes
347//! in, then sends a further page once, never as a re-read, only when its limit still
348//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
349//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
350//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
351//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
352//! not required to include the original process's writes still omitted by the index.
353//! <!-- github-search-paging:end -->
354//!
355//! The board half of an issue — its board item's id, its `Status` option and this
356//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
357//! an item reached any of those ways resolves through the same
358//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
359//! same status, the same labels and the same qualified id. That connection comes back a
360//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
361//! on the page in hand and — only if that page reports more of the connection — in the
362//! last row's read of that one issue's memberships, resumed from the page's own cursor and
363//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
364//! report, which is what keeps an id naming another repository's issue from being answered
365//! as an item of this board; and because the page is where the search starts rather than
366//! where it ends, that answer is one about a connection read to exhaustion and never about
367//! an unread page. Nothing costs the extra read but an issue on more boards than a page
368//! holds: an issue this board really does not hold reports no next page, so its
369//! memberships are already exhausted where they arrived.
370//!
371//! **No document here selects the board's own `Labels` field, and nothing is lost by
372//! that.** An item's labels are read from its content alone, wherever that content is
373//! reached: the three documents above select `Issue.labels` on the fragment, and
374//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
375//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
376//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
377//! create one, and `ProjectV2FieldValue` — the whole of what
378//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
379//! it from the content, for every content type it exists on, and there is nothing it can
380//! hold that the content does not already say: for an `Issue` it *is* that issue's own
381//! labels, so selecting it beside them unions a set with itself.
382//!
383//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
384//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
385//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
386//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
387//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
388//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
389//! label set, and that set is the fixture issue's own, by
390//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
391//! item whose content is a draft reports an empty set, by
392//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
393//! selection is held over [`graphql::DOCUMENTS`] by
394//! `no_document_selects_the_boards_own_labels_field`.
395//!
396//! The whole-board row is still the board's own item connection, and deliberately: a
397//! **draft** board item is not an issue, so no search can list one, and the reads that have
398//! to answer for the whole board are the ones whose cost is the board's size anyway.
399//!
400//! **A question about one item this source already names by id never lists the board.**
401//! Whether that item is on this board, and what its board fields are, is answered by reading
402//! that item — its own `Issue.projectItems`, walked to exhaustion by
403//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
404//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
405//! holds. That covers a write's destination, the project a new item is filed under, a
406//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
407//! and the delete that takes back an item a copy made. What such a write needs of the board
408//! and the item does not carry — the board's id, the `Status` and origin field definitions —
409//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
410//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
411//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
412//! minutes. Scanning this host's 842-item board has refused a document copy and an update
413//! even though the items' own reads named that board. A scan there gives the wrong answer
414//! as well as paying for every page. So a `board.items` lookup does not belong on any of
415//! those paths.
416//!
417//! **What a read may return is capped too, and that cap is on the document rather than on
418//! the board.** GitHub limits the number of nodes **one query may return** to
419//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
420//! an error naming the connection the count crossed at, not a slow or a partial result.
421//! Every board this source reads is refused the same way, so no board is too big for these
422//! documents and none is small enough to save one that is over.
423//!
424//! The count is arithmetic over the document's own text: each connection contributes the
425//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
426//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
427//! restate them — `github-graphql-node-count` implements them, and
428//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
429//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
430//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
431//! same text on every run and fails naming any that reaches the limit — so a connection
432//! added to a shared fragment is caught there rather than by GitHub.
433//!
434//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
435//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
436//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
437//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
438//! effectively squared there, which is why it is the one the limit is most sensitive to.
439//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
440//! page of memberships misses is recovered by one further read rather than refused, so it
441//! buys a bound every read pays for at the price of a request only a multi-board issue
442//! pays.
443//!
444//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
445//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
446//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
447//! `cost` is rate-limit points, metered per hour across everything one credential does; it
448//! is what the two limiters [`Limiter`] tells apart meter, and a document under
449//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
450//! that second number, and `tests/point_cost.rs` pins every document in
451//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
452//! one under, the pin itself is the check. The credentialed lane reconciles both figures
453//! against GitHub's own, off a probe it already sends.
454//!
455//! **What is pinned that way is a per-document price and never a session's.** The record in
456//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
457//! **requests** and **worst-case nodes** — and neither is points. What one whole session
458//! consumes of the hourly point allowance is observable only from a credentialed run's own
459//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
460//! and what `tests/live.rs` prints at the end of every run.
461//!
462//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
463//!
464//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
465//! enumerations of a board can supply one alone.** Resolving a node id is strongly
466//! consistent, so a read by id and a project's own sub-issues are already current. The
467//! other two are not, and they are behind by different amounts and in different directions:
468//!
469//! - GitHub's **issue search** is an index and answers a write made moments ago with the
470//!   value from before it — usually for a second or two.
471//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
472//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
473//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
474//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
475//!
476//! That second one is a measurement rather than a caution. This repository's own
477//! credentialed journey writes a project and waits for the board to report it, then writes a
478//! task and waits for the same thing seconds later on the same board: the project wait is
479//! answered through the search and converged in two or three attempts in each of three runs,
480//! and the task wait is answered through `ProjectV2.items` and converged in none of them
481//! inside thirty. Separately, an item added to a second and larger board was read back by
482//! `Issue.projectItems` on that board's own id while every one of that connection's nine
483//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
484//! through the lagging one alone is what had a board read deny an issue that had certainly
485//! landed on it.
486//!
487//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
488//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
489//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
490//! search reports what the projection is behind on. What closes the last
491//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
492//! source answers is completed with what this process itself wrote, so an item created
493//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
494//! nothing is written down, and the record dies with the process. **A wait that has to
495//! observe GitHub's own data cannot be answered from that record** — which is why the
496//! credentialed journey asks through a source built afresh, and why the union above rather
497//! than a longer wait is what makes such a wait converge.
498//!
499//! **A narrowed read is the same bargain, stated for each of the three predicates it
500//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
501//! than walking the board, and every such answer is completed with what this process wrote —
502//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
503//! each filtered by the same predicates as the rest — so an item this command wrote a moment
504//! ago is returned by a query that matches it whether or not the index has caught up. An item
505//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
506//! consistent. What is left is stated rather than papered over:
507//!
508//! | Read | Finds | Behind by |
509//! | --- | --- | --- |
510//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
511//! | origin, first read | the board's field filter over the origin field — every carrier, whichever release wrote it | what `ProjectV2.items` is behind on, which the measurements above put in minutes |
512//! | origin, second read | the issue search for the id in the body, where a write of this release mirrors it | a second or two, as any search |
513//! | origin, third read | this process's own writes | nothing |
514//!
515//! So an origin carrier another process added within the last second or two, before either
516//! index has it, can be missing from an origin query, and one written by the release before
517//! this one — its origin in the field alone — can be missing for as long as the board's own
518//! item connection is behind on it. A copy that must not duplicate its own earlier write
519//! relies on the link it records, not on either index. **A board draft is not an issue**, so
520//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
521//! lists one, the origin lookup drops any the board's own field filter names, and one this
522//! process wrote is not added back either.
523//!
524//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
525//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
526//! body's metadata slot, so the issue search can find it in seconds. The field is
527//! authoritative: this source reads an item's origin from the field alone, so a slot that
528//! disagrees with it, or holds one where the field holds none, is never read as a second
529//! origin — and the release before this one reads the slot, drops that key's copy for the
530//! field's, and sees the same one origin.
531//!
532//! Filtering happens before paging, so a page of a filtered result is a page of the
533//! survivors rather than the survivors of a page. Label matching and the substring rule a
534//! text candidate is confirmed by answer the same question the same way the local Markdown
535//! source's do; which candidates a text search has to confirm is GitHub's word match, which
536//! is the one place the two sources can answer the same text differently.
537//!
538//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
539//! has one source, `capabilities`, and the note above is the reasoning behind it rather
540//! than a second copy of it: without the three facts recorded here a reader takes the
541//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
542//! crate's own capabilities test, which pins every field of it against a fully spelled-out
543//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
544//! fails to compile there rather than going unasserted. -->
545//! The fixture-server tests above run wherever this crate is selected; the credentialed
546//! lane runs in the same required check, beside them, and can fail it — it verifies the
547//! current schema, then drives every field of the table above against the real board. It builds its own fixture there — two projects, one task filed under each,
548//! one filed under neither, a label on one of the three and a closed status on another —
549//! because that shape is what tells an honoured predicate from an ignored one: a board
550//! holding a single project answers a project filter the same way whether or not this
551//! source applies it, which is exactly how the defect above went unseen.
552//!
553//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
554//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
555//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
556//! session or request, independently of the live demand. Fix that variable in the machine's
557//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
558//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
559//! policy crate; absent credentials or nominations otherwise skip.
560//!
561//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
562//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
563//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
564//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
565//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
566//! after its run reads back as `completed`, immediately before the listed-artifact batch.
567//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
568//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
569//! and the board's `onetaskgraph.origin` field untouched.
570//!
571//! # What a session of requests costs, and where the report is
572//!
573//! This source records **every** request it sends into [`accounting::Accounting`], at
574//! `send_once` — the one place a request leaves this crate, which is why a read path added
575//! later is counted without anybody remembering to count it. That is the whole of what this
576//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
577//! session's spend is arrived at, and what it deliberately does not know are set out.
578//!
579//! What one whole session of the live journey costs, counted that way against this crate's
580//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
581//! reduction it came out of, and with what it does and does not say about rate-limit points.
582//!
583//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
584//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
585//! code path — no environment variable, no feature, no build configuration — because an
586//! instrument nobody switches on measures nothing, and
587//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
588//! source's counts the whole session rather than this source's share. The credentialed lane
589//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
590//! passed or failed.
591//!
592//! **A live session refuses to start unless the account can afford it.** Before it does any
593//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
594//! GitHub documents as not counting against the REST rate limit and which answers both of
595//! its budgets at once — and starts only if, for each of them, what remains minus this
596//! session's estimated cost is still at least
597//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
598//! allowance. A session that cannot **declines**: it did not run, so it is
599//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
600//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
601//! waiting for the budget to come back. The estimate is derived offline from
602//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
603//! which is also where the published rule that model rests on is cited; the accounting
604//! above records the gate's own read like any other request, and
605//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
606//!
607//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
608//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
609//! text, which is what lets it run on every platform and on a pull request from a fork with
610//! no credential — and that is what actually stops a regression merging. But an offline
611//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
612//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
613//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
614//! number of nodes this query may return"* and whose `cost` is what that document would
615//! spend, both for a document **without executing it**, and the lane asks it for every query
616//! document this source sends, under the largest bindings this source sends, and fails when
617//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
618//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
619//! one; the offline pins still cover it. It records what those calls reported about the
620//! account's own allowance, because whether asking is free is a thing to observe rather than
621//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
622//! and `cost` is metered against an hourly allowance the accounting above reads off a
623//! credentialed run's own response headers.
624//!
625//! **GitHub has two rate limiters and this source is refused by both, so nothing here
626//! treats them as one thing.** The primary budget is the hourly allowance `gh api
627//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
628//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
629//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
630//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
631//! one part of.
632#![deny(missing_docs)]
633
634use std::collections::BTreeMap;
635use std::sync::{Arc, Mutex};
636use std::time::Duration;
637
638use chrono::{DateTime, Utc};
639use onetaskgraph_plugin_api::{
640    Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
641    ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
642    DependencySupport, Designator, Direction, Document, DocumentQuery, DocumentVersionCoverage,
643    Health, HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
644    MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
645    ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
646    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
647    StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
648    TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
649    WaitCause, WriteJournal, WriteSupport, system_clock,
650};
651use reqwest::{Client, StatusCode, Url};
652use schemars::{Schema, schema_for};
653use secrecy::{ExposeSecret, SecretString};
654use serde::{Deserialize, Serialize};
655use serde_json::{Value, json};
656
657pub mod accounting;
658mod assets;
659mod batch;
660pub mod change_token;
661mod expected;
662pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
663mod pacing;
664mod projection;
665mod relations;
666mod visibility;
667
668use accounting::Accounting;
669
670/// The registry name for this plugin.
671pub const KIND: &str = "github-projects";
672/// GitHub's maximum connection page size.
673pub const MAX_PAGE_SIZE: u32 = 100;
674/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
675/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
676/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
677pub const SEARCH_PAGE_SIZE: u32 = 20;
678/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
679/// its comments: the largest batch the node-count model prices at one point.
680///
681/// Each aliased item is resolved once, and what GitHub charges for it is the connections
682/// under it — its labels, its page of board memberships, the field values of each of those
683/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
684/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
685/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
686/// one point and fails if one item more would still be priced at one.
687pub const DETAIL_BATCH: usize = 24;
688/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
689///
690/// Each reference asks GitHub for one connection — a page of its board memberships, each with
691/// its `Status` value read by name rather than through a connection of field values — so fifty
692/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
693/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
694/// issues per source. `tests/point_cost.rs` prices the document at one point.
695pub const REFERENCE_BATCH: usize = 50;
696/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
697/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
698/// point.
699///
700/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
701/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
702/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
703/// if one item more would still be priced at one.
704pub const COPY_DETAIL_BATCH: usize = 21;
705/// How many mutations one aliased request of a copy's creates, closes and updates carries:
706/// [`graphql::CREATE_ISSUES`], [`graphql::CLOSE_ISSUES`] and [`graphql::UPDATE_ISSUES`].
707///
708/// Eleven is what GitHub was seen to accept in one request — the timing spike
709/// (`board-copy-speed-2026-10-18-spike-copy-timing-report`) sent eleven aliased mutations of four
710/// kinds, three `createIssue`, three `addProjectV2ItemById`, three field updates, an
711/// `addSubIssue` and an `addBlockedBy`, and every one ran — and no more was established.
712pub const MUTATION_BATCH: usize = 11;
713
714/// The most relation writes — `addSubIssue`, `removeSubIssue`, `addRelatesTo`,
715/// `removeRelatesTo` — one request carries: a 60-alias `addRelatesTo` answered HTTP 504 after
716/// landing 58 of its 60 (`spike-relations`), and 30 were accepted every time.
717pub const RELATION_BATCH: usize = 30;
718/// How many aliased writes one request of a copy's board field values and blocked-by edges
719/// carries: [`graphql::WRITE_FIELD_VALUES`], [`graphql::ADD_BLOCKED_BYS`] and, for a request
720/// holding both kinds, [`graphql::WRITE_FIELDS_AND_EDGES`].
721///
722/// Nineteen is the most aliases the feasibility spike
723/// (`board-copy-gaps-2026-10-24-spike-feasibility-report`) sent in one mutation request and
724/// saw GitHub price at the one point every mutation request costs; no larger request was
725/// established, so a copy holding more is split.
726pub const WRITE_BATCH: usize = 19;
727/// How many issues one [`graphql::DELETE_ISSUES`] request deletes: one request of eleven
728/// aliased `deleteIssue` met `RESOURCE_LIMITS_EXCEEDED` from the eighth alias on, and batches
729/// of five succeeded (the feasibility spike's undo-batching run).
730pub const DELETE_BATCH: usize = 5;
731/// How many origins one [`graphql::ORIGIN_LOOKUPS`] request asks about: the nine records of a
732/// plan and the project's document, and no more, since each carries a page of the board's field
733/// filter and one of the issue search.
734pub const ORIGIN_BATCH: usize = 10;
735/// How many searches one [`graphql::SEARCH_BATCH`] request asks: what `task find` asks of a
736/// board in one call, each search an aliased `search` field of its own.
737pub const SEARCH_BATCH: usize = 20;
738/// How many origins one [`graphql::SEARCH_BATCH`] request looks up beside its searches, each by
739/// the board's own field filter in an aliased `items` field of its own and by one of the
740/// request's searches.
741pub const SEARCH_BATCH_ORIGINS: usize = 5;
742/// The rows each search of a [`graphql::SEARCH_BATCH`] request asks for on its first page.
743///
744/// A batched find asks narrowed questions that each expect a handful of answers, and every
745/// search slot is priced at its page size: three rows a slot keep a first round of
746/// [`SEARCH_BATCH`] searches at three points, where a page of [`SEARCH_PAGE_SIZE`] each would
747/// cost twenty. A search with more to answer is walked on at [`SEARCH_PAGE_SIZE`] rows a page.
748pub const SEARCH_BATCH_FIRST_PAGE: u32 = 3;
749
750/// The rows each resumed page of one [`graphql::SEARCH_BATCH`] round asks for, when `resumed`
751/// of its slots — searches and origin item listings alike — resume from a cursor.
752///
753/// Every resumed page of a round shares one [`SEARCH_PAGE_SIZE`] of rows, rather than each
754/// asking that many: a round in which all twenty searches walked on at twenty rows a slot cost
755/// 22 points in one request, while sharing holds every round to what its first pages cost and
756/// one page more. A search with more to answer is walked on over more rounds, each page still
757/// read, so what a walk answers is unchanged; a round of one resumed search asks the page a
758/// search alone asks.
759#[must_use]
760pub fn search_batch_resumed_page(resumed: usize) -> u32 {
761    let resumed = u32::try_from(resumed.max(1)).unwrap_or(u32::MAX);
762    (SEARCH_PAGE_SIZE / resumed).max(1)
763}
764
765/// The page sizes of the most expensive [`graphql::SEARCH_BATCH`] round this source sends:
766/// every slot on its first page, at [`SEARCH_BATCH_FIRST_PAGE`] rows, but one resumed slot
767/// asking all of [`search_batch_resumed_page`]'s shared rows.
768///
769/// `tests/point_cost.rs` holds every round shape [`search_batch_resumed_page`] allows to cost
770/// no more than this, and the credentialed lane reconciles GitHub's own price of the document
771/// under it. [`largest_page_sizes`] still binds every slot at [`SEARCH_PAGE_SIZE`], an upper
772/// bound on this, so the offline worst case covers more than any round sends.
773#[must_use]
774pub fn search_batch_largest_round() -> Variables {
775    let mut sizes = largest_page_sizes();
776    for slot in 0..SEARCH_BATCH {
777        sizes.insert(format!("first{slot}"), SEARCH_BATCH_FIRST_PAGE);
778    }
779    for slot in 0..SEARCH_BATCH_ORIGINS {
780        sizes.insert(format!("itemsFirst{slot}"), SEARCH_BATCH_FIRST_PAGE);
781    }
782    sizes.insert("first0".to_owned(), search_batch_resumed_page(1));
783    sizes
784}
785/// How many comments one [`graphql::ADD_COMMENTS`] request adds, and how many issue updates one
786/// [`graphql::WRITE_TASKS`] request carries: [`MUTATION_BATCH`], the most aliased mutations
787/// GitHub was seen to accept in one request.
788pub const COMMENT_BATCH: usize = MUTATION_BATCH;
789/// How many board field values one [`graphql::WRITE_TASKS`] request carries, beside
790/// [`MUTATION_BATCH`] field clears: [`WRITE_BATCH`].
791pub const TASK_FIELD_BATCH: usize = WRITE_BATCH;
792
793/// The most nodes any one document this source sends may be asked to return.
794///
795/// GitHub's own published per-query ceiling, taken from
796/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
797/// workspace cannot hold a stale copy of somebody else's number. A query above it is
798/// **refused before it is executed**, whoever is asking and whatever board they are
799/// asking about — so this is a bound on the documents rather than a budget that runs out.
800///
801/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
802/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
803/// everything the credential does — two numbers against two limits, and this constant
804/// bounds only the first. The second is computed offline too, per document:
805/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
806/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
807/// lane. There is no constant like this one to hold a price under, because points are an
808/// hourly allowance rather than a per-call bound.
809///
810/// Neither is a session's price. What `session-cost.md` records of a whole session is its
811/// **requests** and its **worst-case nodes**; what a whole session spends in points is
812/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
813/// [`accounting`]. The module section on the three ways this source reaches an item says how
814/// the count is arrived at, and which of the page sizes below decide it.
815pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
816
817/// Nested connection size for the connections that hang off one item.
818///
819/// It multiplies through every document that reaches an item under a page — the count
820/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
821/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
822/// every document under these constants and fails naming any that reaches the limit, so
823/// raising this is caught there rather than by GitHub.
824const NESTED_PAGE_SIZE: u32 = 50;
825/// How many of one issue's board memberships are read when an issue is reached directly.
826///
827/// An issue reached through a search or through its own node id carries its board half in
828/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
829/// under a page of issues, so every point of it multiplies through the whole document and
830/// is paid for whether or not any issue is on a second board — which is why it is
831/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
832///
833/// **Three, because what a page misses is now recovered rather than refused**, and the
834/// recovery is what the value is chosen against. An issue whose entry for this board sits
835/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
836/// that page's own cursor — so the value trades a bound every read pays for a request only
837/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
838/// boards would pay that request *per issue*, which is order N against the one page per
839/// hundred issues a read costs today. At three it is only reached by an issue on four or
840/// more boards at once, which keeps the recovery path exceptional rather than routine for
841/// a plausible deployment.
842const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
843/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
844/// its two connections for.
845///
846/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
847/// second is a duplicate a copy already takes the first of. Both connections are walked to
848/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
849/// never what the answer is. It is small because every point of it is paid on every lookup,
850/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
851/// worst-case nodes than the one whole-board read they replaced.
852const ORIGIN_PAGE_SIZE: u32 = 3;
853/// How many carriers of each origin the batched [`graphql::ORIGIN_LOOKUPS`] asks each of its two
854/// connections for: one, the one carrier an origin names.
855///
856/// A second carrier still shows: the page then says there is more, and that origin is left to
857/// its own [`graphql::ORIGIN_LOOKUP`], which walks every page at `ORIGIN_PAGE_SIZE` — so a
858/// duplicate is found exactly as before, by the lookup that reads all of them. The feasibility
859/// spike (`board-copy-gaps-2026-10-24-spike-feasibility-report`) priced the batch of ten
860/// origins at two points with a first page of three and at one with a first page of one.
861const ORIGIN_LOOKUPS_PAGE_SIZE: u32 = 1;
862
863/// How many of one project's sub-issues one [`graphql::SUB_ISSUES`] page asks for: the most
864/// one point buys, since every row carries six connections GitHub charges for once per row.
865/// `session-cost.md` derives it; `tests/point_cost.rs` prices the document at one point and
866/// fails if one row more would still be priced at one.
867pub const MEMBER_PAGE_SIZE: u32 = 23;
868
869pub use github_graphql_node_count::{NodeCountError, Variables};
870
871/// The largest value this source can bind to each page-size variable its documents name.
872///
873/// Every `first:` in [`graphql`] reads one of these seven, and each is capped at the
874/// constant above it wherever a caller's own limit could reach it — `$first` at
875/// [`MAX_PAGE_SIZE`], `$commentFirst` at [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
876/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`, `$lookupFirst` at
877/// `ORIGIN_LOOKUPS_PAGE_SIZE`, `$memberFirst` at [`MEMBER_PAGE_SIZE`]. So this is the worst case a caller can drive this source to,
878/// not one configuration of it, which is what makes a bound computed under it a bound on
879/// every read.
880pub fn largest_page_sizes() -> Variables {
881    let mut sizes = Variables::from([
882        ("first".to_owned(), MAX_PAGE_SIZE),
883        ("commentFirst".to_owned(), MAX_PAGE_SIZE),
884        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
885        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
886        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
887        ("lookupFirst".to_owned(), ORIGIN_LOOKUPS_PAGE_SIZE),
888        ("memberFirst".to_owned(), MEMBER_PAGE_SIZE),
889    ]);
890    // Each slot of the batched search pages at most what one search does.
891    for slot in 0..SEARCH_BATCH {
892        sizes.insert(format!("first{slot}"), SEARCH_PAGE_SIZE);
893    }
894    for slot in 0..SEARCH_BATCH_ORIGINS {
895        sizes.insert(format!("itemsFirst{slot}"), SEARCH_PAGE_SIZE);
896    }
897    sizes
898}
899
900/// The most nodes `document` could be asked to return, by GitHub's published rules.
901///
902/// Computed offline from the document's own text under [`largest_page_sizes`] — no
903/// network, no credential and no schema — by
904/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
905/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
906/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
907///
908/// # Errors
909///
910/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
911/// no single operation, or binds a page size this source does not name — each of which is
912/// a defect in the document rather than a number.
913pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
914    node_count(document, &largest_page_sizes())
915}
916
917/// The most rate-limit points one call of `document` could spend, by GitHub's published
918/// rules.
919///
920/// Computed offline from the document's own text under [`largest_page_sizes`] — no
921/// network, no credential and no schema — by
922/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
923/// This is `cost`, metered **per hour** against the allowance one credential shares across
924/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
925/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
926/// under, so what `tests/point_cost.rs` does with it is pin every document in
927/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
928/// figures against GitHub's own reported `cost`.
929///
930/// # Errors
931///
932/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
933/// no single operation, or binds a page size this source does not name — each of which is
934/// a defect in the document rather than a number.
935pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
936    github_graphql_node_count::point_cost(document, &largest_page_sizes())
937}
938
939/// The rate-limit points one call of `document` spends under `variables`, by GitHub's published
940/// rules: [`worst_case_point_cost`] is this under [`largest_page_sizes`], and a request this
941/// source sent is priced by it under the page sizes that request really bound — what GitHub
942/// charges the request, since it charges every connection at the page size asked for.
943///
944/// # Errors
945///
946/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
947/// no single operation, or binds a page size `variables` does not name.
948pub fn point_cost(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
949    github_graphql_node_count::point_cost(document, variables)
950}
951
952/// The most nodes `document` could be asked to return under `variables`.
953///
954/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
955/// [`accounting`] is this under the bindings one request really sent — one spelling of the
956/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
957/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
958///
959/// # Errors
960///
961/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
962/// no single operation, or binds a page size `variables` does not name.
963pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
964    github_graphql_node_count::node_count(document, variables)
965}
966
967/// `variables` with the page sizes and flags a token twin's inline issue selection binds,
968/// at a read's page sizes.
969fn token_variables(mut variables: Value) -> Value {
970    if let Some(object) = variables.as_object_mut() {
971        object.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
972        object.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
973        object.insert("duplicates".to_owned(), json!(true));
974    }
975    variables
976}
977
978/// The input members a mutation names the issue or board item it writes by.
979const WRITTEN_NAMES: [&str; 4] = ["id", "issueId", "subjectId", "itemId"];
980
981tokio::task_local! {
982    /// The source a targeted update is running on, by its address, while it runs: what has its
983    /// mutations sent as the twins selecting the whole issue, so the update answers the version
984    /// its own last response shows (see `GitHubProjectsSource::written_tokens`).
985    static SPELLING: usize;
986}
987
988/// The issue-title prefix that makes a board issue a document.
989///
990/// A GitHub Projects board has no document type — it holds issues — so the discriminator
991/// is the title, and this is the whole of it: an issue whose title begins with these bytes
992/// is a document and every other issue is the task or project the sub-issue rule makes it.
993///
994/// It is spelled **once**, here, and read rather than restated everywhere else — including
995/// by the shared journeys, which take it from this constant so a board fixture cannot
996/// drift from what this source reads. `docs/metadata.md` records the two consequences that
997/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
998/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
999/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
1000pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
1001
1002/// The most characters GitHub stores in one issue's body: a longer one is refused as
1003/// `body is too long (maximum is 65536 characters)`.
1004pub const BODY_LIMIT: u64 = 65_536;
1005
1006/// The address a new upload's URL is spelled at, as GitHub serves a user attachment: what the
1007/// size of a body is measured with before an asset of it is uploaded, a UUID long.
1008const PREDICTED_ATTACHMENT_URL: &str =
1009    "https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000";
1010
1011/// Exact GraphQL query documents issued by this plugin.
1012///
1013/// Keeping the production documents here lets the pinned-schema test validate the same
1014/// bytes that are sent to GitHub, rather than a test-only copy which could drift
1015/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
1016/// field, and its guarded caller always supplies the complete existing option set with ids.
1017pub mod graphql {
1018    /// The board half of one item: the field values every document here reads it from.
1019    ///
1020    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
1021    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
1022    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
1023    /// *the same value*, because
1024    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
1025    /// one path. Three spellings of it is what would drift, so there is one.
1026    ///
1027    /// The `Status` option and this source's own origin text field are the whole of it. It
1028    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
1029    /// content, so it holds nothing the content's own `labels` do not already say, and it
1030    /// would sit a label connection two page sizes deep.
1031    macro_rules! board_item_values {
1032        () => {
1033            r#"fieldValues(first:$nestedFirst){nodes{
1034          ... on ProjectV2ItemFieldSingleSelectValue{name field{
1035            ... on ProjectV2SingleSelectField{id name options{id name}}
1036          }}
1037          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
1038        }pageInfo{hasNextPage}}"#
1039        };
1040    }
1041
1042    /// The variable every document reading an issue declares for the relationship events its
1043    /// change token counts, with its value as the default, so no caller binds it.
1044    ///
1045    /// A variable rather than a literal list because every argument a production document
1046    /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
1047    /// its default to the pinned enum. Its default is the six event types GitHub records a
1048    /// relationship added or removed with: a dependency on either end, as `blockedBy` and
1049    /// `blocking`, so one a person makes from the far end moves this end's count too; and a
1050    /// project membership on the task, as its parent, which GitHub records there whichever
1051    /// issue the move was made from. A sub-issue's events on its parent are not counted: a
1052    /// project carries no task token, and a copy writes nothing of a task its own sub-issues
1053    /// decide. [`super::change_token`] says why the count is in the token.
1054    macro_rules! relationship_events_variable {
1055        () => {
1056            ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
1057        };
1058    }
1059
1060    /// Everything this source reads about one issue, wherever it reaches that issue.
1061    ///
1062    /// A macro rather than a constant so the three documents below can `concat!` it: one
1063    /// spelling of these fields is what makes an issue read through the board-scoped
1064    /// search, through its own node id, and through its project's sub-issue relationship
1065    /// resolve to *the same* item, which is the whole of what
1066    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
1067    ///
1068    /// `projectItems` is what carries the board half of an issue: the board item's own id
1069    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
1070    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
1071    /// issue rather than on the board, which is what makes the cost of a read proportional
1072    /// to what was asked for instead of to the board's size.
1073    ///
1074    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
1075    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
1076    /// not on that page: a page here is where the search for the entry starts rather than
1077    /// where it ends.
1078    ///
1079    /// It does **not** select the board's `Labels` field value, and that is the whole of
1080    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
1081    /// a label connection there sits under `fieldValues` under `projectItems` under a page
1082    /// of issues, spending `$nestedFirst` twice down one path, and took
1083    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
1084    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
1085    /// above, and that connection is where every label this source reports comes from. No
1086    /// document in this module selects the board field any longer, [`BOARD`] included; the
1087    /// module documentation records why nothing it could have held is lost.
1088    macro_rules! board_issue {
1089        () => {
1090            concat!(
1091                r#" fragment BoardIssue on Issue{"#,
1092                board_issue_fields!(),
1093                "}"
1094            )
1095        };
1096    }
1097
1098    /// What [`board_issue!`] selects of an issue, written out where a document selects it
1099    /// inline rather than through the fragment.
1100    macro_rules! board_issue_fields {
1101        () => {
1102            concat!(
1103                r#"__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
1104      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
1105      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1106        "#,
1107                board_item_values!(),
1108                r#"}pageInfo{hasNextPage endCursor}}"#
1109            )
1110        };
1111    }
1112
1113    /// One page of a repository's issues, newest first, each with when it was created and its
1114    /// body: what a retried copy reads to find the issue its earlier attempt created, by the
1115    /// create key in that body's metadata slot — never through search, whose index lags.
1116    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}}}}";
1117    /// Every issue of one board, found by a search scoped to that board.
1118    ///
1119    /// This is how the projects a board holds are listed, and it selects no `items`
1120    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
1121    /// container walked page by page, so nothing nested inside a board item is paid for.
1122    /// Which of the issues it returns is a project is then read off `parent` — GitHub
1123    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
1124    /// discriminator has to be applied to the field, which is a scalar on the issue and
1125    /// costs nothing.
1126    pub const SEARCH_ISSUES: &str = concat!(
1127        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1128        relationship_events_variable!(),
1129        r#"){
1130      search(query:$search,type:$type,first:$first,after:$after){
1131        pageInfo{hasNextPage endCursor}
1132        nodes{__typename ...BoardIssue}
1133      }
1134    }"#,
1135        board_issue!()
1136    );
1137
1138    /// What a dependency read selects of each far end: enough to say which kind of item it
1139    /// is, its body included for the kind marker.
1140    macro_rules! related_issue {
1141        () => {
1142            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
1143        };
1144    }
1145
1146    /// One issue by its own node id, which is what a qualified id names here — with what a
1147    /// write of it needs and the issue does not carry in `board_issue!`: the field
1148    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
1149    ///
1150    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
1151    /// answers a write made moments ago with the value from before it, and resolving a node
1152    /// id does not.
1153    ///
1154    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
1155    /// reads it by its own id, and with them that one read answers everything the write
1156    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
1157    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
1158    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
1159    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
1160    /// they sit under one item, and this read is still one point.
1161    pub const ISSUE: &str = concat!(
1162        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1163        relationship_events_variable!(),
1164        r#"){
1165      node(id:$id){__typename ...BoardIssue ... on Issue{
1166        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1167          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1168          ... on ProjectV2Field{__typename id name dataType}
1169        }pageInfo{hasNextPage}}}}}
1170        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1171      }}
1172    }"#,
1173        board_issue!(),
1174        related_issue!()
1175    );
1176
1177    /// One document by its own node id, as [`ISSUE`] reads one, with the project it is filed
1178    /// under and that project's members beside it: the project issue whole, and a page of its
1179    /// sub-issues, each as [`SUB_ISSUES`] carries one but with no page of what blocks it.
1180    ///
1181    /// What a guarded write of a document's metadata reads, and what reading a document by its
1182    /// id is: a write to a board the boundary screens is held to who may read the document,
1183    /// which is the project's and every member's classification as much as its own, and here
1184    /// the one request that holds the version the write is compared against answers all of it.
1185    /// Its members carry no edges, which nothing classifying them reads.
1186    pub const DOCUMENT_IN_PROJECT: &str = concat!(
1187        r#"query($id:ID!,$first:Int!,$memberFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1188        relationship_events_variable!(),
1189        r#"){
1190      node(id:$id){__typename ...BoardIssue ... on Issue{
1191        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1192          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1193          ... on ProjectV2Field{__typename id name dataType}
1194        }pageInfo{hasNextPage}}}}}
1195        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1196        project:parent{__typename ...BoardIssue
1197          subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}
1198        }
1199      }}
1200    }"#,
1201        board_issue!(),
1202        related_issue!()
1203    );
1204
1205    /// One project's tasks: the sub-issues of the issue that project is, each with a page of
1206    /// what blocks it.
1207    ///
1208    /// Paged at `$memberFirst` — [`MEMBER_PAGE_SIZE`](super::MEMBER_PAGE_SIZE), the most rows
1209    /// one point buys — rather than at `$first`, because every row carries six connections and
1210    /// a hundred-row page cost six points to list a project of ten.
1211    ///
1212    /// The work this costs is the project's own size. Nothing about it grows as the board
1213    /// gains projects, or as those projects gain tasks.
1214    ///
1215    /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1216    /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1217    /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1218    /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1219    /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1220    /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1221    /// It adds a connection under each issue of the page — one rate-limit point per page,
1222    /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1223    /// answer questions that never read an edge.
1224    ///
1225    /// **The project issue itself rides here too**, with the page of what blocks it, because
1226    /// the reader of a project's tasks reads the project next: a copy writing the project reads
1227    /// it and its edges before it writes them. Carried on the request that already names the
1228    /// project, the first page answers that read and the dependency read after it, at the
1229    /// state GitHub answered in this command, rather than spending one more read on an item
1230    /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1231    /// copy's read of a task this listing carried is answered by it rather than sent again.
1232    /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1233    pub const SUB_ISSUES: &str = concat!(
1234        r#"query($id:ID!,$memberFirst:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1235        relationship_events_variable!(),
1236        r#"){
1237      node(id:$id){__typename ...BoardIssue
1238        ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1239          subIssues(first:$memberFirst,after:$after){
1240          pageInfo{hasNextPage endCursor}
1241          nodes{__typename ...BoardIssue ... on Issue{
1242            blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1243          }}
1244        }}}
1245    }"#,
1246        board_issue!(),
1247        related_issue!()
1248    );
1249
1250    /// Everything this source reads about one issue comment, wherever it reaches one.
1251    ///
1252    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1253    /// and a comment just edited are handed to one mapper, so they are selected by one
1254    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1255    /// longer exists, and `login` is the one member every kind of actor carries; `__typename`
1256    /// says whether it is a `Bot`, whose `login` GitHub's own pages spell with `[bot]` after it.
1257    macro_rules! issue_comment {
1258        () => {
1259            "id author{__typename login} createdAt updatedAt body url"
1260        };
1261    }
1262
1263    /// What a read of the board's own `items` selects of each item's content.
1264    ///
1265    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1266    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1267    /// content by one spelling.
1268    macro_rules! board_item_content {
1269        () => { board_item_content!("") };
1270        ($comments:expr) => {
1271            concat!(r#" content{
1272        ... 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#"}
1273        ... on PullRequest{__typename id}
1274        ... on DraftIssue{__typename id title body createdAt updatedAt}
1275      }"#)
1276        };
1277    }
1278
1279    macro_rules! board_listing {
1280        ($variables:expr, $comments:expr) => { concat!(
1281        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1282        relationship_events_variable!(), $variables,
1283        r#"){
1284      owner:repositoryOwner(login:$owner){
1285        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1286      }
1287    } fragment Board on ProjectV2 { id title
1288      fields(first:$nestedFirst){nodes{
1289        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1290        ... on ProjectV2Field{__typename id name dataType}
1291      }pageInfo{hasNextPage}}
1292      items(first:$first,after:$after){nodes{id updatedAt "#,
1293        board_item_values!(),
1294        board_item_content!($comments),
1295        r#"} pageInfo{hasNextPage endCursor}}
1296    }"#
1297    ) };
1298    }
1299
1300    /// Reads the board's fields and one page of its items.
1301    pub const BOARD: &str = board_listing!("", "");
1302
1303    /// A board page carrying each issue's first comment page in the same request.
1304    pub const BOARD_WITH_COMMENTS: &str = concat!(
1305        board_listing!(
1306            ",$commentFirst:Int!",
1307            concat!(
1308                "comments(first:$commentFirst){nodes{",
1309                issue_comment!(),
1310                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}"
1311            )
1312        ),
1313        related_issue!()
1314    );
1315
1316    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1317    /// the board.
1318    ///
1319    /// **`originItems`** is the board's own items narrowed by its own field filter —
1320    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1321    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1322    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1323    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1324    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1325    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1326    /// fresh `addProjectV2ItemById` the way that connection does.
1327    ///
1328    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1329    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1330    /// indexes that comment, and the index catches up with a write in a second or two rather
1331    /// than in minutes, so it finds a carrier another process wrote that the first read is
1332    /// still behind on.
1333    ///
1334    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1335    /// — and resumes from its own cursor; a connection already walked to its end is resumed
1336    /// from its last cursor, which answers an empty page. Every candidate either read returns
1337    /// is confirmed against its own origin field before it is reported, so a token match of
1338    /// the search or anything else the filter admits never is.
1339    ///
1340    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1341    /// own whole reads counts this one among them.
1342    pub const ORIGIN_LOOKUP: &str = concat!(
1343        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1344        relationship_events_variable!(),
1345        r#"){
1346      originItems:repositoryOwner(login:$owner){
1347        ... on ProjectV2Owner{projectV2(number:$number){
1348          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1349        board_item_values!(),
1350        board_item_content!(),
1351        r#"} pageInfo{hasNextPage endCursor}}
1352        }}
1353      }
1354      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1355        pageInfo{hasNextPage endCursor}
1356        nodes{__typename ...BoardIssue}
1357      }
1358    }"#,
1359        board_issue!()
1360    );
1361
1362    /// Every closed item of the board, by the board's own field filter — what
1363    /// `task status reconcile` reads, and nothing else of the board.
1364    ///
1365    /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1366    /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1367    /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1368    /// closed, with a reason and a card that contradict each other, from the item node it
1369    /// already carries, so a filter that also let open items through would cost reads and
1370    /// write nothing wrong.
1371    ///
1372    /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1373    /// option writes that follow need no read of the board. The root is aliased `closedItems`
1374    /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1375    /// among them.
1376    pub const CLOSED_ITEMS: &str = concat!(
1377        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1378        relationship_events_variable!(),
1379        r#"){
1380      closedItems:repositoryOwner(login:$owner){
1381        ... on ProjectV2Owner{projectV2(number:$number){id
1382          fields(first:$nestedFirst){nodes{
1383            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1384            ... on ProjectV2Field{__typename id name dataType}
1385          }pageInfo{hasNextPage}}
1386          items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1387        board_item_values!(),
1388        board_item_content!(),
1389        r#"} pageInfo{hasNextPage endCursor}}
1390        }}
1391      }
1392    }"#
1393    );
1394
1395    /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1396    macro_rules! reference_alias {
1397        ($n:literal) => {
1398            concat!(
1399                "\n      r",
1400                stringify!($n),
1401                ":repository(owner:$owner",
1402                stringify!($n),
1403                ",name:$name",
1404                stringify!($n),
1405                "){issue(number:$number",
1406                stringify!($n),
1407                "){...HeldIssue}}"
1408            )
1409        };
1410    }
1411
1412    /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1413    /// number — the one read `task status complete` makes of a source, and nothing else of
1414    /// the board: no `items`, no search and no project read.
1415    ///
1416    /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1417    /// references one point.** It selects no labels, and of each board membership only the
1418    /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1419    /// connection, its page of memberships, where the whole fragment asks for five. What a
1420    /// completion writes is a card and, at most, the issue's state, so the labels and the other
1421    /// field values it leaves out are never what it needs; an item read this way is held for
1422    /// that write alone and never answers another read. An issue whose entry for this board is
1423    /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1424    /// every other read of an issue recovers one.
1425    ///
1426    /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1427    /// fewer references binds the slots it has none for to its last one.
1428    pub const ISSUES_BY_REFERENCE: &str = concat!(
1429        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!"#,
1430        relationship_events_variable!(),
1431        r#"){"#,
1432        reference_alias!(0),
1433        reference_alias!(1),
1434        reference_alias!(2),
1435        reference_alias!(3),
1436        reference_alias!(4),
1437        reference_alias!(5),
1438        reference_alias!(6),
1439        reference_alias!(7),
1440        reference_alias!(8),
1441        reference_alias!(9),
1442        reference_alias!(10),
1443        reference_alias!(11),
1444        reference_alias!(12),
1445        reference_alias!(13),
1446        reference_alias!(14),
1447        reference_alias!(15),
1448        reference_alias!(16),
1449        reference_alias!(17),
1450        reference_alias!(18),
1451        reference_alias!(19),
1452        reference_alias!(20),
1453        reference_alias!(21),
1454        reference_alias!(22),
1455        reference_alias!(23),
1456        reference_alias!(24),
1457        reference_alias!(25),
1458        reference_alias!(26),
1459        reference_alias!(27),
1460        reference_alias!(28),
1461        reference_alias!(29),
1462        reference_alias!(30),
1463        reference_alias!(31),
1464        reference_alias!(32),
1465        reference_alias!(33),
1466        reference_alias!(34),
1467        reference_alias!(35),
1468        reference_alias!(36),
1469        reference_alias!(37),
1470        reference_alias!(38),
1471        reference_alias!(39),
1472        reference_alias!(40),
1473        reference_alias!(41),
1474        reference_alias!(42),
1475        reference_alias!(43),
1476        reference_alias!(44),
1477        reference_alias!(45),
1478        reference_alias!(46),
1479        reference_alias!(47),
1480        reference_alias!(48),
1481        reference_alias!(49),
1482        "\n    }",
1483        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}
1484      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1485        status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1486          ... on ProjectV2SingleSelectField{id name options{id name}}
1487        }}}
1488      }pageInfo{hasNextPage endCursor}}}"#
1489    );
1490
1491    /// The board's own id and field definitions, and not one of its items.
1492    ///
1493    /// What a write needs of the board when the item it writes does not say: the id a field
1494    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1495    /// origin fields. It selects no `items`, so what it costs is the board's field list
1496    /// however many items the board holds — and it decides nothing about which items those
1497    /// are, which is the question a read of one item by its own id answers instead.
1498    ///
1499    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1500    /// board's item reads by their root counts this one among them.
1501    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1502      boardFields:repositoryOwner(login:$owner){
1503        ... on ProjectV2Owner{projectV2(number:$number){id
1504          fields(first:$nestedFirst){nodes{
1505            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1506            ... on ProjectV2Field{__typename id name dataType}
1507          }pageInfo{hasNextPage}}
1508        }}
1509      }
1510    }"#;
1511
1512    /// One board draft by its own node id, with the board item it sits in.
1513    ///
1514    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1515    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1516    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1517    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1518    /// board listing hands it to, and nothing has to list the board to find one.
1519    pub const DRAFT: &str = concat!(
1520        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1521      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1522        projectV2Items(first:$boardItems){nodes{id project{id number}
1523        "#,
1524        board_item_values!(),
1525        r#"}pageInfo{hasNextPage endCursor}}}}
1526    }"#
1527    );
1528
1529    /// One issue's board memberships alone, walked past the page a read of it carried.
1530    ///
1531    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1532    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1533    /// boards than that page holds may have this board's entry past its end. This asks that
1534    /// one issue for its memberships and nothing else — the caller already holds the issue —
1535    /// so an answer of "this board does not hold it" is only ever given about a connection
1536    /// read to exhaustion.
1537    ///
1538    /// It selects the board item's id, its project number and the same
1539    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1540    /// very same resolver: an issue recovered this way reports the same title, the same
1541    /// status, the same labels and the same qualified id as one whose entry was on the
1542    /// page.
1543    ///
1544    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1545    /// multiplies through it and the membership connection can be walked at
1546    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1547    /// further request for any issue a person really keeps.
1548    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1549        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1550      node(id:$id){
1551        ... on Issue{projectItems(first:$first,after:$after){
1552          nodes{id updatedAt project{id number}
1553        "#,
1554        board_item_values!(),
1555        r#"}
1556          pageInfo{hasNextPage endCursor}}}
1557      }
1558    }"#
1559    );
1560    /// Whether the board's Project is public — half of what decides whether a write here can
1561    /// be read by anybody. Needs the `read:project` scope.
1562    pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1563    /// Resolves the configured repository's node id, which creating an issue requires.
1564    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1565    /// What creating an issue needs and has not read yet: the board's own id and field
1566    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1567    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1568    ///
1569    /// Sent at the point a create knows which repository it is for, when neither half is
1570    /// already known to this process; a create needing only one of them sends that one's own
1571    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1572    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1573    /// status.
1574    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1575      boardFields:repositoryOwner(login:$owner){
1576        ... on ProjectV2Owner{projectV2(number:$number){id
1577          fields(first:$nestedFirst){nodes{
1578            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1579            ... on ProjectV2Field{__typename id name dataType}
1580          }pageInfo{hasNextPage}}
1581        }}
1582      }
1583      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1584    }"#;
1585    /// Reads both dependency directions for one issue, with each far end's own kind — and
1586    /// the issue's own body, which is where an edge to another source is recorded, so that
1587    /// half of a dependency read needs no second read of the issue or of the board.
1588    pub const ISSUE_DEPENDENCIES: &str = concat!(
1589        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1590      ... on Issue{body
1591        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1592        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1593      }}}"#,
1594        related_issue!()
1595    );
1596    /// What one item depends on: [`ISSUE_DEPENDENCIES`] narrowed to `blockedBy`, so a
1597    /// dependency read asks GitHub for the one direction it answers. The issue's `body` rides
1598    /// along because its metadata slot is where an edge to another source is recorded — and a
1599    /// board draft's `body` too, with the one board item GitHub links a draft to and, aliased
1600    /// `board`, this board's own id, so a draft's recorded edges are read off this same answer
1601    /// and only when that item is this board's: no draft read and no board read follow it.
1602    ///
1603    /// The draft's board item is asked for under a root of its own, aliased `draft`, rather
1604    /// than beside its `body` under `node`. GitHub counts the type-conditional fragments of
1605    /// one selection as the largest of them — `blockedBy`'s 100 alone, where this workspace's
1606    /// calculation adds the draft's 3 — while separate roots are summed by both, so this is
1607    /// the spelling whose node count GitHub and the calculation agree on. Both price it at
1608    /// one point; the credentialed lane's reconciliation is what holds the two to that.
1609    pub const ISSUE_DEPENDS_ON: &str = concat!(
1610        r#"query($id:ID!,$first:Int!,$after:String,$owner:String!,$number:Int!,$boardItems:Int!){
1611      node(id:$id){__typename
1612      ... on Issue{body
1613        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1614      }
1615      ... on DraftIssue{id body}}
1616      draft:node(id:$id){... on DraftIssue{projectV2Items(first:$boardItems){nodes{project{id number}}pageInfo{hasNextPage}}}}
1617      board:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id}}}}"#,
1618        related_issue!()
1619    );
1620    /// What depends on one issue: [`ISSUE_DEPENDENCIES`] with its `blocking` alone. No `body`:
1621    /// the reverse of a recorded edge is derived from the far end, never written down here.
1622    pub const ISSUE_DEPENDED_ON_BY: &str = concat!(
1623        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1624      ... on Issue{
1625        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1626      }}}"#,
1627        related_issue!()
1628    );
1629    /// Creates one issue. Outside a copy it is created on no board, and [`ADD_TO_BOARD`] files
1630    /// it; inside one, its input names the board in `projectV2Ids` and its project in
1631    /// `parentIssueId`, so this one request files it under both — see
1632    /// [`GitHubProjectsSource::create_filed_issue`](super::GitHubProjectsSource).
1633    pub const CREATE_ISSUE: &str =
1634        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1635    /// Puts an existing issue on the configured board.
1636    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1637    /// Updates an issue's visible fields and its open or closed state in one call.
1638    pub const UPDATE_ISSUE: &str =
1639        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1640    /// Updates an existing draft's user-visible fields.
1641    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1642    /// Updates a text or single-select value on one project item.
1643    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}}}}}}}}"#;
1644    /// Writes up to six board fields and clears up to three in one ordered mutation: every
1645    /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1646    /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1647    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}}}"#;
1648    /// Clears one project item's value of one field, which is what a `none` priority is.
1649    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}}}}}}}}"#;
1650    /// Creates one field: a single-select one with its options, or a text one for a projected
1651    /// metadata value. Only the guarded field setup may use this document, and only for a
1652    /// field the board lacks.
1653    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1654    /// Replaces a single-select field's options. Only the guarded field setup — the
1655    /// `status-options` and `fields` operations — may use this document, because GitHub
1656    /// treats the input as the complete option list.
1657    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1658    /// A fresh snapshot of the Status field and every board item's assignment.
1659    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}}}}}}"#;
1660    /// Files one issue under another as a sub-issue, which is what project membership is.
1661    pub const ADD_SUB_ISSUE: &str =
1662        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1663    /// Takes one issue back out of its parent.
1664    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1665    /// Adds GitHub's native issue blocked-by relationship.
1666    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1667    /// One origin of [`ORIGIN_LOOKUPS`]: [`ORIGIN_LOOKUP`]'s two connections for the origin the
1668    /// `$filter<n>` and `$search<n>` of slot `<n>` name, sent only while `$ask<n>` holds.
1669    macro_rules! origin_lookup_alias {
1670        ($n:literal) => {
1671            concat!(
1672                "\n      o",
1673                stringify!($n),
1674                ":repositoryOwner(login:$owner) @include(if:$ask",
1675                stringify!($n),
1676                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$lookupFirst,query:$filter",
1677                stringify!($n),
1678                "){nodes{id updatedAt ",
1679                board_item_values!(),
1680                board_item_content!(),
1681                "} pageInfo{hasNextPage endCursor}}}}}\n      s",
1682                stringify!($n),
1683                ":search(query:$search",
1684                stringify!($n),
1685                ",type:$type,first:$lookupFirst) @include(if:$ask",
1686                stringify!($n),
1687                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}"
1688            )
1689        };
1690    }
1691
1692    /// [`ORIGIN_LOOKUPS`], with `$extra_variables` declared after its own and `$extra_fields`
1693    /// selected beside them: one spelling of the batched lookup for the documents that send it.
1694    macro_rules! origin_lookups {
1695        ($extra_variables:literal, $extra_fields:literal) => {
1696            concat!(
1697                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!"#,
1698                relationship_events_variable!(),
1699                $extra_variables,
1700                r#"){
1701      boardFields:repositoryOwner(login:$owner){
1702        ... on ProjectV2Owner{projectV2(number:$number){id
1703          fields(first:$nestedFirst){nodes{
1704            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1705            ... on ProjectV2Field{__typename id name dataType}
1706          }pageInfo{hasNextPage}}
1707        }}
1708      }
1709      repository(owner:$repositoryOwner,name:$repositoryName){id databaseId nameWithOwner}"#,
1710                $extra_fields,
1711                origin_lookup_alias!(0),
1712                origin_lookup_alias!(1),
1713                origin_lookup_alias!(2),
1714                origin_lookup_alias!(3),
1715                origin_lookup_alias!(4),
1716                origin_lookup_alias!(5),
1717                origin_lookup_alias!(6),
1718                origin_lookup_alias!(7),
1719                origin_lookup_alias!(8),
1720                origin_lookup_alias!(9),
1721                "\n    }",
1722                board_issue!()
1723            )
1724        };
1725    }
1726
1727    /// The first page of [`ORIGIN_LOOKUP`] for up to [`ORIGIN_BATCH`](super::ORIGIN_BATCH)
1728    /// origins a copy is about to look for, and everything creating an item then reads —
1729    /// the board's own id and field definitions, as [`CREATION_CONTEXT`] reads them, and the node
1730    /// id and the numeric id of the repository an issue is created in — in one request.
1731    ///
1732    /// What a copy reads before it writes anything, so that finding none of its records on the
1733    /// board and creating every one costs this read and the writes. An origin whose page says
1734    /// there is more is left to its own [`ORIGIN_LOOKUP`]. The repository's numeric id is what an
1735    /// image upload names, so a copy carrying assets needs no `GET /repos/{owner}/{repo}` either.
1736    pub const ORIGIN_LOOKUPS: &str = origin_lookups!("", "");
1737
1738    /// [`ORIGIN_LOOKUPS`], with a page of the members of the project `$project` beside it, each
1739    /// trimmed to what classifying it reads — its id, its parent, its body and its repository,
1740    /// and no connection under it.
1741    ///
1742    /// What a document copy sends where the public boundary screens its write: the document is
1743    /// held to who may read its project, which is its members' classification as much as the
1744    /// project's own, and this request carries both the project — among the origins it asks —
1745    /// and its members. GitHub's own dry run prices it at the one point [`ORIGIN_LOOKUPS`]
1746    /// costs alone (`docs/measurements/board-copy-gaps-2026-10-24/coalesced-members-dry-run`).
1747    pub const ORIGIN_LOOKUPS_WITH_MEMBERS: &str = origin_lookups!(
1748        ",$project:ID!,$memberFirst:Int!",
1749        "\n      members:node(id:$project){... on Issue{subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{id body parent{id} repository{nameWithOwner}}}}}"
1750    );
1751
1752    /// One slot `<n>` of a copy's aliased writes: `$<prefix><n>` is its input and `$send<n>`
1753    /// whether it is sent.
1754    macro_rules! write_slot {
1755        ($prefix:literal, $field:literal, $selection:literal, $n:literal) => {
1756            concat!(
1757                " ",
1758                $prefix,
1759                stringify!($n),
1760                ":",
1761                $field,
1762                "(input:$",
1763                $prefix,
1764                stringify!($n),
1765                ") @include(if:$send",
1766                stringify!($n),
1767                "){",
1768                $selection,
1769                "}"
1770            )
1771        };
1772    }
1773
1774    /// The variables of a copy's aliased writes: eleven inputs of `$type` named `$<prefix><n>`,
1775    /// and eleven `$send<n>`.
1776    macro_rules! write_variables {
1777        ($prefix:literal, $type:literal) => {
1778            concat!(
1779                "mutation(",
1780                "$",
1781                $prefix,
1782                "0:",
1783                $type,
1784                "!,$",
1785                $prefix,
1786                "1:",
1787                $type,
1788                "!,$",
1789                $prefix,
1790                "2:",
1791                $type,
1792                "!,$",
1793                $prefix,
1794                "3:",
1795                $type,
1796                "!,$",
1797                $prefix,
1798                "4:",
1799                $type,
1800                "!,$",
1801                $prefix,
1802                "5:",
1803                $type,
1804                "!,$",
1805                $prefix,
1806                "6:",
1807                $type,
1808                "!,$",
1809                $prefix,
1810                "7:",
1811                $type,
1812                "!,$",
1813                $prefix,
1814                "8:",
1815                $type,
1816                "!,$",
1817                $prefix,
1818                "9:",
1819                $type,
1820                "!,$",
1821                $prefix,
1822                "10:",
1823                $type,
1824                "!,",
1825                "$send0:Boolean!,$send1:Boolean!,$send2:Boolean!,$send3:Boolean!,$send4:Boolean!,",
1826                "$send5:Boolean!,$send6:Boolean!,$send7:Boolean!,$send8:Boolean!,$send9:Boolean!,",
1827                "$send10:Boolean!){"
1828            )
1829        };
1830    }
1831
1832    /// A copy's aliased writes of one kind: [`MUTATION_BATCH`](super::MUTATION_BATCH) slots of
1833    /// `$field`, each sent only while its `$send<n>` holds.
1834    macro_rules! write_batch {
1835        ($prefix:literal, $type:literal, $field:literal, $selection:literal) => {
1836            concat!(
1837                write_variables!($prefix, $type),
1838                write_slot!($prefix, $field, $selection, 0),
1839                write_slot!($prefix, $field, $selection, 1),
1840                write_slot!($prefix, $field, $selection, 2),
1841                write_slot!($prefix, $field, $selection, 3),
1842                write_slot!($prefix, $field, $selection, 4),
1843                write_slot!($prefix, $field, $selection, 5),
1844                write_slot!($prefix, $field, $selection, 6),
1845                write_slot!($prefix, $field, $selection, 7),
1846                write_slot!($prefix, $field, $selection, 8),
1847                write_slot!($prefix, $field, $selection, 9),
1848                write_slot!($prefix, $field, $selection, 10),
1849                "}"
1850            )
1851        };
1852    }
1853
1854    /// Creates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues in one request, each filed on
1855    /// the board and under its project by the same `createIssue` — its `projectV2Ids` and
1856    /// `parentIssueId` — as [`CREATE_ISSUE`] creates one.
1857    pub const CREATE_ISSUES: &str = write_batch!(
1858        "create",
1859        "CreateIssueInput",
1860        "createIssue",
1861        "issue{id number url}"
1862    );
1863
1864    /// One issue of [`CREATED_BOARD_ITEMS`]: the board items the issue `$id<n>` names is on, with
1865    /// the `Status` value of each, read only while `$read<n>` holds.
1866    macro_rules! created_item_alias {
1867        ($n:literal) => {
1868            concat!(
1869                "\n      i",
1870                stringify!($n),
1871                ":node(id:$id",
1872                stringify!($n),
1873                ") @include(if:$read",
1874                stringify!($n),
1875                "){... on Issue{id projectItems(first:$boardItems){nodes{id project{id} status:fieldValueByName(name:$statusName){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}"
1876            )
1877        };
1878    }
1879
1880    /// The board item of each issue a copy created, up to
1881    /// [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH) at a time, with its `Status` as the board
1882    /// set it: what `createIssue`'s `projectV2Ids` files the issue as, which GitHub adds after
1883    /// answering the create, so its id comes from a read made once every create has been sent.
1884    pub const CREATED_BOARD_ITEMS: &str = concat!(
1885        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!){"#,
1886        created_item_alias!(0),
1887        created_item_alias!(1),
1888        created_item_alias!(2),
1889        created_item_alias!(3),
1890        created_item_alias!(4),
1891        created_item_alias!(5),
1892        created_item_alias!(6),
1893        created_item_alias!(7),
1894        created_item_alias!(8),
1895        created_item_alias!(9),
1896        created_item_alias!(10),
1897        created_item_alias!(11),
1898        created_item_alias!(12),
1899        created_item_alias!(13),
1900        created_item_alias!(14),
1901        created_item_alias!(15),
1902        created_item_alias!(16),
1903        created_item_alias!(17),
1904        created_item_alias!(18),
1905        created_item_alias!(19),
1906        created_item_alias!(20),
1907        "\n    }"
1908    );
1909
1910    /// The variables and the aliases of [`WRITE_BATCH`](super::WRITE_BATCH) slots: `$<prefix><n>`
1911    /// is a slot's input and `$<send><n>` whether it is sent, for the slot numbers given.
1912    macro_rules! wide_variables {
1913        ($prefix:literal, $type:literal, $send:literal; $($n:literal)+) => {
1914            concat!($("$", $prefix, stringify!($n), ":", $type, "!,$", $send, stringify!($n), ":Boolean!,",)+)
1915        };
1916    }
1917    macro_rules! wide_slots {
1918        ($prefix:literal, $field:literal, $selection:literal, $send:literal; $($n:literal)+) => {
1919            concat!($(
1920                " ", $prefix, stringify!($n), ":", $field, "(input:$", $prefix, stringify!($n),
1921                ") @include(if:$", $send, stringify!($n), "){", $selection, "}",
1922            )+)
1923        };
1924    }
1925
1926    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) board field values a copy writes on the items it
1927    /// created, each on its own item, in one ordered request.
1928    pub const WRITE_FIELD_VALUES: &str = concat!(
1929        "mutation(",
1930        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1931        "){",
1932        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),
1933        "}"
1934    );
1935
1936    /// A copy's board field values and its blocked-by edges together, up to
1937    /// [`WRITE_BATCH`](super::WRITE_BATCH) of the two kinds between them, in one ordered request:
1938    /// every field value its `$sendField<n>` sends, then every edge its `$sendBlock<n>` sends.
1939    /// What a copy sends when one request of its writes holds both kinds; one holding a single
1940    /// kind is [`WRITE_FIELD_VALUES`] or [`ADD_BLOCKED_BYS`].
1941    pub const WRITE_FIELDS_AND_EDGES: &str = concat!(
1942        "mutation(",
1943        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1944        wide_variables!("block", "AddBlockedByInput", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1945        "){",
1946        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),
1947        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),
1948        "}"
1949    );
1950
1951    /// Closes up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues a copy created at a closing
1952    /// status, each with its own `stateInput` and nothing else, once its option is selected.
1953    pub const CLOSE_ISSUES: &str =
1954        write_batch!("close", "UpdateIssueInput", "updateIssue", "issue{id}");
1955
1956    /// Updates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) existing issues a copy rewrites,
1957    /// each with the members its own [`UPDATE_ISSUE`] would have sent, in one request.
1958    pub const UPDATE_ISSUES: &str =
1959        write_batch!("update", "UpdateIssueInput", "updateIssue", "issue{id}");
1960
1961    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) native blocked-by relationships a copy adds to the
1962    /// issues it created, in one request.
1963    pub const ADD_BLOCKED_BYS: &str = concat!(
1964        "mutation(",
1965        wide_variables!("block", "AddBlockedByInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1966        "){",
1967        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),
1968        "}"
1969    );
1970
1971    /// Removes one native issue blocked-by relationship.
1972    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1973    /// Deletes one issue, which takes its board item with it.
1974    ///
1975    /// The engine sends this in one situation only: undoing a copy that could not finish,
1976    /// over the items that same copy created. Deleting the issue removes the board item
1977    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1978    pub const DELETE_ISSUE: &str =
1979        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1980    /// Deletes up to [`DELETE_BATCH`](super::DELETE_BATCH) issues in one request, each as
1981    /// [`DELETE_ISSUE`] deletes one: what a write that created several issues and was refused
1982    /// part way sends to take them back.
1983    pub const DELETE_ISSUES: &str = concat!(
1984        "mutation(",
1985        wide_variables!("delete", "DeleteIssueInput", "send"; 0 1 2 3 4),
1986        "){",
1987        wide_slots!("delete", "deleteIssue", "repository{id}", "send"; 0 1 2 3 4),
1988        "}"
1989    );
1990
1991    /// One task's comments: a page of its issue's own `comments` connection.
1992    ///
1993    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1994    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1995    /// list every time somebody edited it; left unordered the connection answers in the order
1996    /// the comments were written, which is the order GitHub documents for the same collection
1997    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1998    /// node count and the caller's own page size is pushed straight down.
1999    pub const ISSUE_COMMENTS: &str = concat!(
2000        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
2001        issue_comment!(),
2002        r#"}pageInfo{hasNextPage endCursor}}}}}"#
2003    );
2004    /// One issue by its own node id, with a page of its comments: what `task show` and a
2005    /// comment listing read, in one request.
2006    ///
2007    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
2008    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
2009    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
2010    /// connection there would multiply through both of those documents' price, and neither
2011    /// needs one.
2012    pub const ISSUE_DETAIL: &str = concat!(
2013        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2014        relationship_events_variable!(),
2015        r#"){
2016      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
2017        issue_comment!(),
2018        r#"}pageInfo{hasNextPage endCursor}}}}
2019    }"#,
2020        board_issue!()
2021    );
2022
2023    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
2024    /// page of its comments when `$comments` asks for them.
2025    macro_rules! issue_details_alias {
2026        ($n:literal) => {
2027            concat!(
2028                "\n      i",
2029                stringify!($n),
2030                ":node(id:$id",
2031                stringify!($n),
2032                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
2033                issue_comment!(),
2034                "}pageInfo{hasNextPage endCursor}}}}"
2035            )
2036        };
2037    }
2038
2039    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
2040    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
2041    ///
2042    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
2043    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
2044    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
2045    /// neither — so every connection under it would be priced at nothing and the pin in
2046    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
2047    /// one-item read the model already prices, so the batch costs what its aliases cost.
2048    ///
2049    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
2050    /// slots it has no item for to the last item it does, and reads that item again; the
2051    /// price is the document's, whatever its variables, so a short batch costs what a full
2052    /// one does and nothing more.
2053    pub const ISSUE_DETAILS: &str = concat!(
2054        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!"#,
2055        relationship_events_variable!(),
2056        r#"){"#,
2057        issue_details_alias!(0),
2058        issue_details_alias!(1),
2059        issue_details_alias!(2),
2060        issue_details_alias!(3),
2061        issue_details_alias!(4),
2062        issue_details_alias!(5),
2063        issue_details_alias!(6),
2064        issue_details_alias!(7),
2065        issue_details_alias!(8),
2066        issue_details_alias!(9),
2067        issue_details_alias!(10),
2068        issue_details_alias!(11),
2069        issue_details_alias!(12),
2070        issue_details_alias!(13),
2071        issue_details_alias!(14),
2072        issue_details_alias!(15),
2073        issue_details_alias!(16),
2074        issue_details_alias!(17),
2075        issue_details_alias!(18),
2076        issue_details_alias!(19),
2077        issue_details_alias!(20),
2078        issue_details_alias!(21),
2079        issue_details_alias!(22),
2080        issue_details_alias!(23),
2081        "\n    }",
2082        board_issue!()
2083    );
2084
2085    /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
2086    /// page of its comments and the issues it is blocked by.
2087    macro_rules! issue_copy_details_alias {
2088        ($n:literal) => {
2089            concat!(
2090                "\n      i",
2091                stringify!($n),
2092                ":node(id:$id",
2093                stringify!($n),
2094                "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
2095                issue_comment!(),
2096                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
2097            )
2098        };
2099    }
2100
2101    /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
2102    /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
2103    /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
2104    /// at a time, so a copy of a changed task costs this read and nothing else.
2105    ///
2106    /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
2107    /// what `task show-many` is priced by and which reads no edge.
2108    ///
2109    /// `children`, read only while `$children` holds, is which issues the issue `$project` has
2110    /// as sub-issues — each one's id and title, the page `$nestedFirst` allows — so that a
2111    /// project copy, which reads its project and every task at once, knows from this same read
2112    /// which tasks the project holds at the destination, and its orphan check is answered by it
2113    /// rather than by a listing of the project.
2114    pub const ISSUE_COPY_DETAILS: &str = concat!(
2115        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!"#,
2116        relationship_events_variable!(),
2117        r#"){"#,
2118        issue_copy_details_alias!(0),
2119        issue_copy_details_alias!(1),
2120        issue_copy_details_alias!(2),
2121        issue_copy_details_alias!(3),
2122        issue_copy_details_alias!(4),
2123        issue_copy_details_alias!(5),
2124        issue_copy_details_alias!(6),
2125        issue_copy_details_alias!(7),
2126        issue_copy_details_alias!(8),
2127        issue_copy_details_alias!(9),
2128        issue_copy_details_alias!(10),
2129        issue_copy_details_alias!(11),
2130        issue_copy_details_alias!(12),
2131        issue_copy_details_alias!(13),
2132        issue_copy_details_alias!(14),
2133        issue_copy_details_alias!(15),
2134        issue_copy_details_alias!(16),
2135        issue_copy_details_alias!(17),
2136        issue_copy_details_alias!(18),
2137        issue_copy_details_alias!(19),
2138        issue_copy_details_alias!(20),
2139        "\n      children:node(id:$project) @include(if:$children){... on Issue{id subIssues(first:$nestedFirst){nodes{id title} pageInfo{hasNextPage}}}}",
2140        "\n    }",
2141        board_issue!(),
2142        related_issue!()
2143    );
2144
2145    /// Which issue one comment is on, read before that comment is edited or removed.
2146    ///
2147    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
2148    /// comment id given against the wrong task would change a comment on another issue.
2149    pub const COMMENT_ISSUE: &str =
2150        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
2151    /// Adds one comment to an issue, signed as the account the token belongs to.
2152    pub const ADD_COMMENT: &str = concat!(
2153        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
2154        issue_comment!(),
2155        r#"}}}}"#
2156    );
2157    /// Replaces the body of one issue comment.
2158    pub const UPDATE_COMMENT: &str = concat!(
2159        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
2160        issue_comment!(),
2161        r#"}}}"#
2162    );
2163    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
2164    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
2165
2166    /// The variables of the search slots `<n>` of [`SEARCH_BATCH`]: its search, where it
2167    /// resumes, its page size and whether it is asked.
2168    macro_rules! search_batch_variables {
2169        ($($n:literal)+) => {
2170            concat!($(
2171                ",$search", stringify!($n), ":String!,$after", stringify!($n),
2172                ":String,$first", stringify!($n), ":Int!,$ask", stringify!($n), ":Boolean!",
2173            )+)
2174        };
2175    }
2176    /// The search slots `<n>` of [`SEARCH_BATCH`]: [`SEARCH_ISSUES`]'s one connection, aliased
2177    /// `s<n>`, sent only while `$ask<n>` holds.
2178    macro_rules! search_batch_slots {
2179        ($($n:literal)+) => {
2180            concat!($(
2181                "\n      s", stringify!($n), ":search(query:$search", stringify!($n),
2182                ",type:$type,first:$first", stringify!($n), ",after:$after", stringify!($n),
2183                ") @include(if:$ask", stringify!($n),
2184                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}",
2185            )+)
2186        };
2187    }
2188    /// The variables of the origin slots `<n>` of [`SEARCH_BATCH`]: the board's field filter
2189    /// naming the origin, where its items resume, their page size and whether it is asked.
2190    macro_rules! origin_batch_variables {
2191        ($($n:literal)+) => {
2192            concat!($(
2193                ",$filter", stringify!($n), ":String!,$itemsAfter", stringify!($n),
2194                ":String,$itemsFirst", stringify!($n), ":Int!,$origin", stringify!($n),
2195                ":Boolean!",
2196            )+)
2197        };
2198    }
2199    /// The origin slots `<n>` of [`SEARCH_BATCH`]: [`ORIGIN_LOOKUP`]'s board half, the board's
2200    /// own items through its field filter, aliased `o<n>`, sent only while `$origin<n>` holds.
2201    macro_rules! origin_batch_slots {
2202        ($($n:literal)+) => {
2203            concat!($(
2204                "\n      o", stringify!($n), ":repositoryOwner(login:$owner) @include(if:$origin",
2205                stringify!($n),
2206                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$itemsFirst",
2207                stringify!($n), ",after:$itemsAfter", stringify!($n), ",query:$filter",
2208                stringify!($n), "){nodes{id updatedAt ", board_item_values!(),
2209                board_item_content!(), "} pageInfo{hasNextPage endCursor}}}}}",
2210            )+)
2211        };
2212    }
2213
2214    /// Up to [`SEARCH_BATCH`](super::SEARCH_BATCH) board-scoped issue searches in one request,
2215    /// each [`SEARCH_ISSUES`]'s connection in an aliased `s<n>` field with its own query, page
2216    /// size and cursor, and beside them up to
2217    /// [`SEARCH_BATCH_ORIGINS`](super::SEARCH_BATCH_ORIGINS) origin lookups' board halves —
2218    /// [`ORIGIN_LOOKUP`]'s `items` filtered by the origin field — in aliased `o<n>` fields, each
2219    /// origin's issue search in a search slot of its own.
2220    ///
2221    /// What `task find` asks a board, every search a page at a time: one request a round,
2222    /// rather than one per search. A slot not asked is left out by `@include`, its page size
2223    /// bound to one; GitHub answers an error about one slot at that slot's path, and the other
2224    /// slots are answered beside it.
2225    pub const SEARCH_BATCH: &str = concat!(
2226        r#"query($owner:String!,$number:Int!,$type:SearchType!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2227        relationship_events_variable!(),
2228        search_batch_variables!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2229        origin_batch_variables!(0 1 2 3 4),
2230        "){",
2231        search_batch_slots!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2232        origin_batch_slots!(0 1 2 3 4),
2233        "\n    }",
2234        board_issue!()
2235    );
2236
2237    /// The comment slots `<n>` of [`ADD_COMMENTS`]: [`ADD_COMMENT`]'s mutation, aliased
2238    /// `comment<n>`, sent only while `$send<n>` holds.
2239    macro_rules! comment_slots {
2240        ($($n:literal)+) => {
2241            concat!($(
2242                " comment", stringify!($n), ":addComment(input:$comment", stringify!($n),
2243                ") @include(if:$send", stringify!($n), "){subject{id} commentEdge{node{",
2244                issue_comment!(), "}}}",
2245            )+)
2246        };
2247    }
2248
2249    /// Adds up to [`COMMENT_BATCH`](super::COMMENT_BATCH) comments in one request, each as
2250    /// [`ADD_COMMENT`] adds one, in order: what `task comment add-many` sends. GitHub runs each
2251    /// alias on its own and answers one it refused at its own path, beside the others.
2252    pub const ADD_COMMENTS: &str = concat!(
2253        "mutation(",
2254        wide_variables!("comment", "AddCommentInput", "send"; 0 1 2 3 4 5 6 7 8 9 10),
2255        "){",
2256        comment_slots!(0 1 2 3 4 5 6 7 8 9 10),
2257        "}"
2258    );
2259
2260    /// Many tasks' targeted updates, each as [`UPDATE_FIELDS`] and [`UPDATE_ISSUE`] write one:
2261    /// up to [`TASK_FIELD_BATCH`](super::TASK_FIELD_BATCH) board field values in `field<n>`,
2262    /// [`MUTATION_BATCH`](super::MUTATION_BATCH) field clears in `clear<n>`, and as many
2263    /// `updateIssue`s in `update<n>`, each sent only while its own `send…<n>` holds.
2264    ///
2265    /// What `task update-many` sends, in two requests at most: every item's field values and
2266    /// clears first, then every issue update of an item whose fields all landed — the order one
2267    /// item's update keeps, so a refusal part way leaves an item's body as it stood.
2268    ///
2269    /// Each `update<n>` selects the issue it updated as a read of it does, so its response spells
2270    /// the change token the task holds after its batch's writes — the version `update-many`
2271    /// answers — with no read after them. Its page sizes are bound by `$nestedFirst` and
2272    /// `$boardItems`, as every read's are.
2273    pub const WRITE_TASKS: &str = concat!(
2274        "mutation(",
2275        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2276        wide_variables!("clear", "ClearProjectV2ItemFieldValueInput", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2277        wide_variables!("update", "UpdateIssueInput", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2278        r#"$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2279        relationship_events_variable!(),
2280        "){",
2281        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),
2282        wide_slots!("clear", "clearProjectV2ItemFieldValue", "projectV2Item{id}", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2283        wide_slots!("update", "updateIssue", "issue{id ...BoardIssue}", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2284        "}",
2285        board_issue!()
2286    );
2287
2288    /// One alias of [`ISSUE_RELATIONS`]: the item a batch's `$id<n>` names, as a read of it by
2289    /// its own id reads it, with a page of its sub-issues' ids.
2290    macro_rules! issue_relations_alias {
2291        ($n:literal) => {
2292            concat!(
2293                "\n      i",
2294                stringify!($n),
2295                ":node(id:$id",
2296                stringify!($n),
2297                "){__typename ...BoardIssue ... on Issue{subIssues(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2298            )
2299        };
2300    }
2301
2302    /// [`ISSUE_DETAILS`] with each issue's sub-issues in place of its comments: what `task
2303    /// relations` and a link write read of [`DETAIL_BATCH`](super::DETAIL_BATCH) tasks at once
2304    /// on a source reading sub-issues as children — each task whole, its parent and the ids of up
2305    /// to `$first` of its children.
2306    pub const ISSUE_RELATIONS: &str = concat!(
2307        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!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2308        relationship_events_variable!(),
2309        r#"){"#,
2310        issue_relations_alias!(0),
2311        issue_relations_alias!(1),
2312        issue_relations_alias!(2),
2313        issue_relations_alias!(3),
2314        issue_relations_alias!(4),
2315        issue_relations_alias!(5),
2316        issue_relations_alias!(6),
2317        issue_relations_alias!(7),
2318        issue_relations_alias!(8),
2319        issue_relations_alias!(9),
2320        issue_relations_alias!(10),
2321        issue_relations_alias!(11),
2322        issue_relations_alias!(12),
2323        issue_relations_alias!(13),
2324        issue_relations_alias!(14),
2325        issue_relations_alias!(15),
2326        issue_relations_alias!(16),
2327        issue_relations_alias!(17),
2328        issue_relations_alias!(18),
2329        issue_relations_alias!(19),
2330        issue_relations_alias!(20),
2331        issue_relations_alias!(21),
2332        issue_relations_alias!(22),
2333        issue_relations_alias!(23),
2334        "\n    }",
2335        board_issue!()
2336    );
2337
2338    /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) parent writes in one request:
2339    /// `parent<n>` an `addSubIssue` (a re-parent carrying `replaceParent: true`) and `unparent<n>`
2340    /// a `removeSubIssue`, each sent only while its own `send…<n>` holds. GitHub runs each alias
2341    /// on its own and answers one it refused — a cycle, a depth or children limit — at its own
2342    /// path, beside the others.
2343    pub const SET_PARENTS: &str = concat!(
2344        "mutation(",
2345        wide_variables!("parent", "AddSubIssueInput", "sendParent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2346        wide_variables!("unparent", "RemoveSubIssueInput", "sendUnparent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2347        "){",
2348        wide_slots!("parent", "addSubIssue", "issue{id} subIssue{id}", "sendParent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2349        wide_slots!("unparent", "removeSubIssue", "issue{id} subIssue{id}", "sendUnparent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2350        "}"
2351    );
2352
2353    /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) `relates-to` relations added in one
2354    /// request, each `relate<n>` sent only while `$send<n>` holds: what a link of a type a
2355    /// source mirrors natively writes beside its metadata.
2356    pub const ADD_RELATES_TO: &str = concat!(
2357        "mutation(",
2358        wide_variables!("relate", "AddRelatesToInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2359        "){",
2360        wide_slots!("relate", "addRelatesTo", "issue{id} relatedIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2361        "}"
2362    );
2363
2364    /// [`ADD_RELATES_TO`]'s removal: up to [`RELATION_BATCH`](super::RELATION_BATCH)
2365    /// `relates-to` relations removed in one request.
2366    pub const REMOVE_RELATES_TO: &str = concat!(
2367        "mutation(",
2368        wide_variables!("unrelate", "RemoveRelatesToInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2369        "){",
2370        wide_slots!("unrelate", "removeRelatesTo", "issue{id} relatedIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2371        "}"
2372    );
2373
2374    /// One alias of [`RELATES_TO_READS`]: the issue `$id<n>` names and the ids of up to
2375    /// `$first` of the issues it relates to.
2376    macro_rules! relates_to_alias {
2377        ($n:literal) => {
2378            concat!(
2379                "\n      r",
2380                stringify!($n),
2381                ":node(id:$id",
2382                stringify!($n),
2383                "){... on Issue{id relatesTo(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2384            )
2385        };
2386    }
2387
2388    /// What [`DETAIL_BATCH`](super::DETAIL_BATCH) issues relate to, one alias each: read
2389    /// after a relation write whose answer never arrived, so each relation is answered from what
2390    /// GitHub holds rather than sent again blindly.
2391    pub const RELATES_TO_READS: &str = concat!(
2392        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!){"#,
2393        relates_to_alias!(0),
2394        relates_to_alias!(1),
2395        relates_to_alias!(2),
2396        relates_to_alias!(3),
2397        relates_to_alias!(4),
2398        relates_to_alias!(5),
2399        relates_to_alias!(6),
2400        relates_to_alias!(7),
2401        relates_to_alias!(8),
2402        relates_to_alias!(9),
2403        relates_to_alias!(10),
2404        relates_to_alias!(11),
2405        relates_to_alias!(12),
2406        relates_to_alias!(13),
2407        relates_to_alias!(14),
2408        relates_to_alias!(15),
2409        relates_to_alias!(16),
2410        relates_to_alias!(17),
2411        relates_to_alias!(18),
2412        relates_to_alias!(19),
2413        relates_to_alias!(20),
2414        relates_to_alias!(21),
2415        relates_to_alias!(22),
2416        relates_to_alias!(23),
2417        "\n    }"
2418    );
2419
2420    /// What a mutation of a write the store's cache expects selects of the issue it wrote:
2421    /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
2422    /// change token's every part and the first page of its comments.
2423    ///
2424    /// Sent **only** while that write runs, in place of the document named beside each
2425    /// below, so every other write's documents, prices and request counts are what they
2426    /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
2427    /// mutation's response is stamped seconds after the change it made, so against GitHub
2428    /// itself a response rarely determines a version; the cache is then left unserveable
2429    /// for the task, and its next read refreshes it.
2430    macro_rules! whole_issue {
2431        () => {
2432            concat!(
2433                r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
2434                issue_comment!(),
2435                r#"}pageInfo{hasNextPage endCursor}}}"#
2436            )
2437        };
2438    }
2439
2440    /// The variables every document of `whole_issue!` declares beside its own.
2441    macro_rules! whole_variables {
2442        () => {
2443            concat!(
2444                r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2445                relationship_events_variable!()
2446            )
2447        };
2448    }
2449
2450    /// [`ADD_COMMENT`], selecting the whole issue commented on.
2451    pub const ADD_COMMENT_WHOLE: &str = concat!(
2452        r#"mutation($input:AddCommentInput!"#,
2453        whole_variables!(),
2454        r#"){addComment(input:$input){subject{id "#,
2455        whole_issue!(),
2456        r#"} commentEdge{node{"#,
2457        issue_comment!(),
2458        r#"}}}}"#,
2459        board_issue!()
2460    );
2461    /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
2462    pub const UPDATE_COMMENT_WHOLE: &str = concat!(
2463        r#"mutation($input:UpdateIssueCommentInput!"#,
2464        whole_variables!(),
2465        r#"){updateIssueComment(input:$input){issueComment{"#,
2466        issue_comment!(),
2467        r#" issue{"#,
2468        whole_issue!(),
2469        r#"}}}}"#,
2470        board_issue!()
2471    );
2472    /// [`UPDATE_ISSUE`], selecting the whole issue updated.
2473    pub const UPDATE_ISSUE_WHOLE: &str = concat!(
2474        r#"mutation($input:UpdateIssueInput!"#,
2475        whole_variables!(),
2476        r#"){updateIssue(input:$input){issue{id "#,
2477        whole_issue!(),
2478        r#"}}}"#,
2479        board_issue!()
2480    );
2481    /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
2482    pub const UPDATE_FIELD_WHOLE: &str = concat!(
2483        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2484        whole_variables!(),
2485        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2486        whole_issue!(),
2487        r#"}}}}"#,
2488        board_issue!()
2489    );
2490    /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
2491    pub const CLEAR_FIELD_WHOLE: &str = concat!(
2492        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2493        whole_variables!(),
2494        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2495        whole_issue!(),
2496        r#"}}}}"#,
2497        board_issue!()
2498    );
2499
2500    /// The variables every document selecting [`board_issue_fields!`] inline declares beside
2501    /// its own.
2502    macro_rules! token_variables {
2503        () => {
2504            concat!(
2505                r#",$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2506                relationship_events_variable!()
2507            )
2508        };
2509    }
2510
2511    /// [`UPDATE_ISSUE`], selecting what a read of the issue updated selects — every part of its
2512    /// change token, and no comment — inline, so its root fields are the plain document's.
2513    ///
2514    /// What a targeted update sends in the plain document's place, so the version it answers
2515    /// is the one its own last response spells (`GitHubProjectsSource::written_tokens`). It
2516    /// costs the one point the plain document does.
2517    pub const UPDATE_ISSUE_TOKEN: &str = concat!(
2518        r#"mutation($input:UpdateIssueInput!"#,
2519        token_variables!(),
2520        r#"){updateIssue(input:$input){issue{"#,
2521        board_issue_fields!(),
2522        r#"}}}"#
2523    );
2524    /// [`UPDATE_FIELD`], selecting the issue behind the board item written as
2525    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2526    pub const UPDATE_FIELD_TOKEN: &str = concat!(
2527        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2528        token_variables!(),
2529        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2530        board_issue_fields!(),
2531        r#"}}}}}"#
2532    );
2533    /// [`CLEAR_FIELD`], selecting the issue behind the board item cleared as
2534    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2535    pub const CLEAR_FIELD_TOKEN: &str = concat!(
2536        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2537        token_variables!(),
2538        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2539        board_issue_fields!(),
2540        r#"}}}}}"#
2541    );
2542
2543    /// The document a targeted update sends in `document`'s place, when it has one: the twin
2544    /// whose answer spells the change token of the issue written.
2545    #[must_use]
2546    pub fn token_twin(document: &str) -> Option<&'static str> {
2547        [
2548            (UPDATE_ISSUE, UPDATE_ISSUE_TOKEN),
2549            (UPDATE_FIELD, UPDATE_FIELD_TOKEN),
2550            (CLEAR_FIELD, CLEAR_FIELD_TOKEN),
2551        ]
2552        .into_iter()
2553        .find(|(plain, _)| *plain == document)
2554        .map(|(_, token)| token)
2555    }
2556
2557    /// The document sent in `document`'s place while a write the store's cache expects runs,
2558    /// when it has one.
2559    #[must_use]
2560    pub fn whole_twin(document: &str) -> Option<&'static str> {
2561        [
2562            (ADD_COMMENT, ADD_COMMENT_WHOLE),
2563            (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
2564            (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
2565            (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
2566            (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
2567        ]
2568        .into_iter()
2569        .find(|(plain, _)| *plain == document)
2570        .map(|(_, whole)| whole)
2571    }
2572
2573    /// Every document above, with what this source is doing when it sends one.
2574    ///
2575    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
2576    /// name the call that was refused, and a `match` with a catch-all arm would answer a
2577    /// document added later with "talking to GitHub" and never say so.
2578    ///
2579    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
2580    /// const` here that this list omits, so the two cannot part — which is the same guard
2581    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
2582    pub const DOCUMENTS: [(&str, &str); 68] = [
2583        (
2584            ISSUE_RELATIONS,
2585            "reading several issues with their parents and sub-issues",
2586        ),
2587        (SET_PARENTS, "writing several issues' parents"),
2588        (ADD_RELATES_TO, "adding several relates-to relations"),
2589        (REMOVE_RELATES_TO, "removing several relates-to relations"),
2590        (
2591            RELATES_TO_READS,
2592            "reading what several issues relate to after a relation write went unanswered",
2593        ),
2594        (
2595            ISSUES_BY_CREATION,
2596            "listing a repository's issues newest first for a create key",
2597        ),
2598        (SEARCH_ISSUES, "searching this board's issues"),
2599        (ISSUE, "reading one issue"),
2600        (
2601            DOCUMENT_IN_PROJECT,
2602            "reading one document with its project and the project's members",
2603        ),
2604        (
2605            ISSUE_BOARD_ITEMS,
2606            "reading one issue's board memberships past the page it came with",
2607        ),
2608        (SUB_ISSUES, "reading a project's tasks"),
2609        (BOARD, "reading the board"),
2610        (
2611            BOARD_WITH_COMMENTS,
2612            "reading a board page with first comments",
2613        ),
2614        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
2615        (CLOSED_ITEMS, "reading the board's closed items"),
2616        (
2617            ISSUES_BY_REFERENCE,
2618            "reading a batch of issues by their references",
2619        ),
2620        (BOARD_FIELDS, "reading the board's fields"),
2621        (DRAFT, "reading one draft"),
2622        (REPOSITORY, "reading the destination repository"),
2623        (
2624            PROJECT_VISIBILITY,
2625            "reading whether the board's project is public",
2626        ),
2627        (
2628            CREATION_CONTEXT,
2629            "reading the board's fields and the destination repository",
2630        ),
2631        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
2632        (ISSUE_DEPENDS_ON, "reading what an item depends on"),
2633        (ISSUE_DEPENDED_ON_BY, "reading what depends on an issue"),
2634        (CREATE_ISSUE, "creating an issue"),
2635        (
2636            ORIGIN_LOOKUPS,
2637            "looking up the items copied from several origins, and what creating one reads",
2638        ),
2639        (
2640            ORIGIN_LOOKUPS_WITH_MEMBERS,
2641            "looking up the items copied from several origins, with a project's members",
2642        ),
2643        (CREATE_ISSUES, "creating several issues together"),
2644        (
2645            CREATED_BOARD_ITEMS,
2646            "reading the board items of the issues a copy created",
2647        ),
2648        (
2649            WRITE_FIELD_VALUES,
2650            "writing board fields of the items a copy created",
2651        ),
2652        (
2653            WRITE_FIELDS_AND_EDGES,
2654            "writing board fields and dependencies of the items a copy created",
2655        ),
2656        (CLOSE_ISSUES, "closing the issues a copy created"),
2657        (UPDATE_ISSUES, "updating several issues together"),
2658        (ADD_BLOCKED_BYS, "recording several dependencies together"),
2659        (ADD_TO_BOARD, "adding an issue to the board"),
2660        (UPDATE_ISSUE, "updating an issue"),
2661        (UPDATE_DRAFT, "updating a draft item"),
2662        (UPDATE_FIELD, "writing a board field"),
2663        (UPDATE_FIELDS, "writing board fields together"),
2664        (CLEAR_FIELD, "clearing a board field"),
2665        (
2666            CREATE_FIELD,
2667            "creating a board single-select field with its options",
2668        ),
2669        (
2670            STATUS_OPTIONS_SNAPSHOT,
2671            "snapshotting board Status options and assignments",
2672        ),
2673        (
2674            STATUS_OPTIONS_UPDATE,
2675            "safely replacing the board Status option list",
2676        ),
2677        (ADD_SUB_ISSUE, "filing an issue under its project"),
2678        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
2679        (ADD_BLOCKED_BY, "recording a dependency"),
2680        (REMOVE_BLOCKED_BY, "removing a dependency"),
2681        (DELETE_ISSUE, "deleting an issue"),
2682        (DELETE_ISSUES, "deleting several issues together"),
2683        (ISSUE_COMMENTS, "reading a task's comments"),
2684        (ISSUE_DETAIL, "reading one issue with its comments"),
2685        (
2686            ISSUE_DETAILS,
2687            "reading a batch of issues with their comments",
2688        ),
2689        (
2690            ISSUE_COPY_DETAILS,
2691            "reading a batch of issues with their comments and dependencies for a copy",
2692        ),
2693        (COMMENT_ISSUE, "reading which issue a comment is on"),
2694        (ADD_COMMENT, "adding a comment"),
2695        (UPDATE_COMMENT, "editing a comment"),
2696        (DELETE_COMMENT, "deleting a comment"),
2697        (
2698            SEARCH_BATCH,
2699            "searching this board's issues several ways at once",
2700        ),
2701        (ADD_COMMENTS, "adding several comments together"),
2702        (WRITE_TASKS, "updating several tasks together"),
2703        (
2704            ADD_COMMENT_WHOLE,
2705            "adding a comment, answered with the whole issue",
2706        ),
2707        (
2708            UPDATE_COMMENT_WHOLE,
2709            "editing a comment, answered with the whole issue",
2710        ),
2711        (
2712            UPDATE_ISSUE_WHOLE,
2713            "updating an issue, answered with it whole",
2714        ),
2715        (
2716            UPDATE_FIELD_WHOLE,
2717            "writing a board field, answered with the whole issue",
2718        ),
2719        (
2720            CLEAR_FIELD_WHOLE,
2721            "clearing a board field, answered with the whole issue",
2722        ),
2723        (
2724            UPDATE_ISSUE_TOKEN,
2725            "updating an issue, answered with its version",
2726        ),
2727        (
2728            UPDATE_FIELD_TOKEN,
2729            "writing a board field, answered with the issue's version",
2730        ),
2731        (
2732            CLEAR_FIELD_TOKEN,
2733            "clearing a board field, answered with the issue's version",
2734        ),
2735    ];
2736}
2737
2738/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
2739/// under, the variable carrying its input, and the boolean variable that includes it.
2740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2741pub struct FieldSlot {
2742    /// The key of the answer's `data` this field's payload is under.
2743    pub alias: &'static str,
2744    /// The variable carrying this field's input.
2745    pub variable: &'static str,
2746    /// The boolean variable whose `true` sends this field.
2747    pub include: &'static str,
2748}
2749
2750/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
2751pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
2752    FieldSlot {
2753        alias: "updateProjectV2ItemFieldValue",
2754        variable: "input",
2755        include: "writeFirst",
2756    },
2757    FieldSlot {
2758        alias: "second",
2759        variable: "second",
2760        include: "writeSecond",
2761    },
2762    FieldSlot {
2763        alias: "third",
2764        variable: "third",
2765        include: "writeThird",
2766    },
2767    FieldSlot {
2768        alias: "fourth",
2769        variable: "fourth",
2770        include: "writeFourth",
2771    },
2772    FieldSlot {
2773        alias: "fifth",
2774        variable: "fifth",
2775        include: "writeFifth",
2776    },
2777    FieldSlot {
2778        alias: "sixth",
2779        variable: "sixth",
2780        include: "writeSixth",
2781    },
2782];
2783
2784/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
2785pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
2786    FieldSlot {
2787        alias: "cleared",
2788        variable: "clear",
2789        include: "writeClear",
2790    },
2791    FieldSlot {
2792        alias: "clearedSecond",
2793        variable: "clearSecond",
2794        include: "writeClearSecond",
2795    },
2796    FieldSlot {
2797        alias: "clearedThird",
2798        variable: "clearThird",
2799        include: "writeClearThird",
2800    },
2801];
2802
2803/// Which of GitHub's two rate limiters refused a request.
2804///
2805/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
2806/// secondary one — so an operator told the wrong one takes the wrong next step, which is
2807/// the whole reason this is carried rather than collapsed into "rate limited".
2808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2809enum Limiter {
2810    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
2811    Primary,
2812    /// The burst limiter over content-generating requests, which nothing reports.
2813    Secondary,
2814}
2815
2816/// The wordings GitHub answers a secondary rate limit with.
2817///
2818/// It sends them under a forbidden status, under a too-many-requests status, and inside
2819/// the `errors` of a *successful* response, which is why the text is what this matches on
2820/// rather than the status. `abuse detection` is the wording GitHub used before the
2821/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
2822/// what a burst of content creation is refused with.
2823///
2824/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
2825/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
2826/// when, and the drift gate reconciles the two lists both ways. Public for that gate
2827/// alone — a caller has no use for it, and matching on a refusal is this source's job.
2828pub const SECONDARY_WORDINGS: [&str; 5] = [
2829    "secondary rate limit",
2830    "temporarily blocked from content creation",
2831    "abuse detection",
2832    "submitted too quickly",
2833    "exceeded a secondary",
2834];
2835
2836/// The wordings GitHub answers an exhausted primary budget with.
2837///
2838/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
2839/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
2840/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
2841/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
2842/// two phrases is a substring of it, so without it that answer read as a refusal that will
2843/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
2844/// one reason.
2845pub const PRIMARY_WORDINGS: [&str; 4] = [
2846    "api rate limit exceeded",
2847    "api rate limit already exceeded",
2848    "rate limit exceeded",
2849    "rate_limited",
2850];
2851
2852/// The wordings GitHub answers a write that collided with another change to the same
2853/// ordering with, in the `errors` of an HTTP 200: "Your attempt to move this item created a
2854/// temporary conflict. Please try again." The write did not take effect and GitHub asks for it
2855/// again, so it is retried on the same growing schedule a rate limit without a hint is, within
2856/// the same retry budget — except a `createIssue`, whose answer cannot say the issue was not
2857/// made, and which is therefore reported rather than replayed into a duplicate.
2858///
2859/// Observed rather than documented, so it is pinned exactly as [`SECONDARY_WORDINGS`] is:
2860/// `tests/fixtures/rate-limits.json` records where it was read under `conflict.observed`, and
2861/// the same drift gate reconciles the two both ways. Public for that gate alone.
2862pub const CONFLICT_WORDINGS: [&str; 1] = ["created a temporary conflict"];
2863
2864/// What a response *says about itself*, which is the only place a refusal can be read.
2865///
2866/// Deliberately not the whole response body. A board is a place people write about their
2867/// own work, and a task on it titled "the secondary rate limit" would, matched across the
2868/// raw text, turn a perfectly good answer into a refusal this source then waited out and
2869/// reported. So the item data is never read: what is read is GitHub's own REST-style
2870/// `message` envelope, which is what a forbidden status carries, and the `message` and
2871/// `type` of each GraphQL error, which is where a *successful* response says it.
2872///
2873/// A body that is not JSON at all has nothing structured to read, so only a failing
2874/// response's own text is taken — a successful response that is not JSON is malformed
2875/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
2876fn refusal_wording(status: StatusCode, body: &str) -> String {
2877    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
2878        return if status.is_success() {
2879            String::new()
2880        } else {
2881            body.to_owned()
2882        };
2883    };
2884    let mut said: Vec<&str> = parsed
2885        .get("message")
2886        .and_then(Value::as_str)
2887        .into_iter()
2888        .collect();
2889    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
2890        for error in errors {
2891            said.extend(
2892                ["message", "type"]
2893                    .into_iter()
2894                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
2895            );
2896        }
2897    }
2898    said.join("; ")
2899}
2900
2901impl Limiter {
2902    /// Which limiter refused this response, or `None` when none of them did.
2903    ///
2904    /// The wording is read first and the status only decides what carries none of it,
2905    /// because GitHub answers a secondary limit with a forbidden status far more often
2906    /// than with too-many-requests — while a forbidden status saying nothing about a limit
2907    /// really is a credential this token lacks.
2908    ///
2909    /// A response is a refusal because of its status or its own wording. A spent budget
2910    /// only ever explains one; it never turns an answer into a refusal.
2911    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
2912        let normalized = refusal_wording(status, body).to_ascii_lowercase();
2913        if SECONDARY_WORDINGS
2914            .iter()
2915            .any(|wording| normalized.contains(wording))
2916        {
2917            return Some(Self::Secondary);
2918        }
2919        if status == StatusCode::TOO_MANY_REQUESTS {
2920            return Some(Self::Primary);
2921        }
2922        // An exhausted budget *explains* a response that failed; it does not make one that
2923        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
2924        // request the budget allowed as well as on the ones it then refuses, so reading
2925        // the header alone threw away a good answer — and, once refusals were retried,
2926        // replayed a request that had already taken effect.
2927        if !status.is_success() && budget_exhausted {
2928            return Some(Self::Primary);
2929        }
2930        // A successful response saying it: GitHub reports a GraphQL rate limit in the
2931        // `errors` of an HTTP 200, where nothing about the status says so at all.
2932        if status.is_success()
2933            && PRIMARY_WORDINGS
2934                .iter()
2935                .any(|wording| normalized.contains(wording))
2936        {
2937            return Some(Self::Primary);
2938        }
2939        None
2940    }
2941
2942    /// What this limiter is called where an operator can look it up.
2943    const fn name(self) -> &'static str {
2944        match self {
2945            Self::Primary => "GitHub's primary API rate limit",
2946            Self::Secondary => "GitHub's secondary rate limit",
2947        }
2948    }
2949
2950    /// What the endpoint an operator would go and check says about this limiter.
2951    const fn where_to_look(self) -> &'static str {
2952        match self {
2953            Self::Primary => {
2954                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
2955                 comes back."
2956            }
2957            Self::Secondary => {
2958                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
2959                 primary budget and does not report this one, so budget showing there says \
2960                 nothing about this refusal, and every further attempt extends it."
2961            }
2962        }
2963    }
2964
2965    /// The next step this limiter actually calls for.
2966    const fn what_to_do(self) -> &'static str {
2967        match self {
2968            Self::Primary => {
2969                "wait for the reset `gh api rate_limit` reports, then run the command again."
2970            }
2971            Self::Secondary => {
2972                "leave this board alone for a few minutes, then run the command again — or \
2973                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
2974            }
2975        }
2976    }
2977}
2978
2979/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
2980#[derive(Debug, Clone, Copy)]
2981struct Limited {
2982    limiter: Limiter,
2983    hint: Option<u64>,
2984    /// Which header the hint came from: `retry-after`, or an exhausted primary budget's
2985    /// `x-ratelimit-reset`.
2986    hinted_by: Hint,
2987}
2988
2989/// Where a refusal's hint came from, which is what its wait is named after.
2990#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2991enum Hint {
2992    /// `retry-after`, or no hint at all.
2993    RetryAfter,
2994    /// An exhausted primary budget's `x-ratelimit-reset`.
2995    PrimaryReset,
2996}
2997
2998impl Limited {
2999    /// The wait this refusal is answered with, when the backing-off schedule is at `backoff`,
3000    /// and the cause it is named by: the hint, raised to the schedule's own next wait —
3001    /// GitHub really does send `retry-after: 0`, and retrying at once is the one move that
3002    /// extends a secondary limit — or the schedule alone when there is none.
3003    fn wait(self, backoff: Duration) -> (Duration, WaitCause) {
3004        match (self.hint, self.hinted_by) {
3005            (Some(hint), Hint::RetryAfter) => (
3006                Duration::from_secs(hint).max(backoff),
3007                pacing::cause::RETRY_AFTER,
3008            ),
3009            (Some(hint), Hint::PrimaryReset) => (
3010                Duration::from_secs(hint).max(backoff),
3011                pacing::cause::PRIMARY_RESET,
3012            ),
3013            (None, _) => (backoff, pacing::cause::BACKOFF),
3014        }
3015    }
3016
3017    /// What the caller is told once this source has waited as long as it may.
3018    ///
3019    /// Both limiters report as [`SourceError::RateLimited`], because that is what
3020    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
3021    /// about *which* limiter it was makes it a different kind of failure. What differs is
3022    /// the operator's next step, and that is what the message carries — a secondary
3023    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
3024    /// budget looks fine, and then back to retry the very burst that was refused.
3025    fn exhausted(
3026        self,
3027        doing: &str,
3028        waits: u32,
3029        waited: Duration,
3030        needed: Duration,
3031        budget: Duration,
3032    ) -> SourceError {
3033        SourceError::RateLimited {
3034            retry_after_seconds: self.hint,
3035            message: Some(format!(
3036                "{} refused this source while {doing}; it waited {} out over {} and was refused \
3037                 again, and the next wait of {} would take it past the {} one call may spend \
3038                 waiting. {} next: {}",
3039                self.limiter.name(),
3040                plural(waits, "refusal"),
3041                seconds(waited),
3042                seconds(needed),
3043                seconds(budget),
3044                self.limiter.where_to_look(),
3045                self.limiter.what_to_do(),
3046            )),
3047        }
3048    }
3049}
3050
3051/// One HTTP attempt's result, with what its response said about the rate limit.
3052///
3053/// The two travel together so the record and the outcome are written from the same place:
3054/// what a response said about the budget is only readable while that response is in hand,
3055/// and what the attempt *meant* is only decidable once its body has been read.
3056struct Attempted {
3057    result: Result<Value, Attempt>,
3058    limits: accounting::RateLimit,
3059    /// GitHub's own reported cost for this call, for a document that asked for it.
3060    reported_cost: Option<u64>,
3061    response_outcome: accounting::ResponseOutcome,
3062}
3063
3064/// One attempt's outcome: an error to report, or a rate limit to wait out.
3065enum Attempt {
3066    Failed(SourceError),
3067    Limited(Limited),
3068    /// GitHub refused a write as a temporary conflict and asked for it again — see
3069    /// [`CONFLICT_WORDINGS`]. Carries what GitHub said, for the diagnostic once retrying ends.
3070    Conflicted(String),
3071}
3072
3073fn plural(count: u32, thing: &str) -> String {
3074    if count == 1 {
3075        format!("{count} {thing}")
3076    } else {
3077        format!("{count} {thing}s")
3078    }
3079}
3080
3081fn seconds(duration: Duration) -> String {
3082    format!("{:.1}s", duration.as_secs_f64())
3083}
3084
3085/// The member [`stamp_served`] writes onto every node of a response that says when it last
3086/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
3087const SERVED_AT: &str = "onetaskgraphServedAt";
3088
3089/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
3090/// node's time later knows the server's clock on the response that read it — which is what
3091/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
3092fn stamp_served(data: &mut Value, served: &str) {
3093    match data {
3094        Value::Object(members) => {
3095            if members.contains_key("updatedAt") {
3096                members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
3097            }
3098            for member in members.values_mut() {
3099                stamp_served(member, served);
3100            }
3101        }
3102        Value::Array(items) => {
3103            for item in items {
3104                stamp_served(item, served);
3105            }
3106        }
3107        _ => {}
3108    }
3109}
3110
3111/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
3112///
3113/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
3114/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
3115/// header, and neither is what makes a response a refusal — so the whole cost of one this
3116/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
3117/// instead. Refusing the response over the header would turn a readable refusal into an
3118/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
3119fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
3120    value
3121        .and_then(|value| value.to_str().ok())
3122        .and_then(|value| value.trim().parse::<u64>().ok())
3123}
3124
3125/// A transport failure with every cause beneath it, outermost first.
3126///
3127/// The client's own text for a request it could not complete is `error sending request for
3128/// url (…)`, and the socket error that says why — a refused or reset connection, an exhausted
3129/// port range, a connection closed before its answer — is only in the chain of sources below
3130/// it. Those differ in what to do next, so all of them are kept.
3131pub(crate) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String {
3132    let mut text = error.to_string();
3133    let mut cause = error.source();
3134    while let Some(next) = cause {
3135        let said = next.to_string();
3136        if !text.ends_with(&said) {
3137            text = format!("{text}: {said}");
3138        }
3139        cause = next.source();
3140    }
3141    text
3142}
3143
3144/// Every mutation this source sends creates content — an issue, a board item, a field of
3145/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
3146/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
3147/// and what the keyword says are the same set. That is what makes the keyword a sound test
3148/// rather than a convenient one: counting an edit or a removal the limiter might not have
3149/// counted costs a wait, and not counting one it did count costs the next fifty minutes.
3150pub(crate) fn is_mutation(query: &str) -> bool {
3151    query.trim_start().starts_with("mutation")
3152}
3153
3154/// What this source was doing, for a diagnostic that has to say so.
3155///
3156/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
3157/// a document added without a description is caught by that list's own gate instead of
3158/// falling through to the vague arm below.
3159fn operation_description(query: &str) -> &'static str {
3160    graphql::DOCUMENTS
3161        .iter()
3162        .find(|(document, _)| *document == query)
3163        .map_or("talking to GitHub", |(_, doing)| *doing)
3164}
3165
3166/// GitHub's published ceiling on content-generating requests in any one minute.
3167///
3168/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
3169/// GitHub's number rather than this source's. This source paces at it over its window: a
3170/// content-creating request goes out at once while the minute's creations leave room for it,
3171/// and waits until they do otherwise — see the `pacing` module.
3172pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
3173/// The same ceiling as GitHub publishes it for any one hour, paced over its window as the
3174/// per-minute one is. Pinned beside its sibling so the gate would notice either one moving.
3175pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
3176/// GitHub's published ceiling on secondary points in any one minute: what every GraphQL
3177/// request is charged against, a mutation [`SECONDARY_POINTS_PER_MUTATION`] and a query
3178/// [`SECONDARY_POINTS_PER_QUERY`]. Pinned and paced as [`CONTENT_CREATION_PER_MINUTE`] is.
3179pub const SECONDARY_POINTS_PER_MINUTE: u64 = 2_000;
3180/// The secondary points one GraphQL request carrying a mutation is charged.
3181pub const SECONDARY_POINTS_PER_MUTATION: u64 = 5;
3182/// The secondary points one GraphQL request carrying no mutation is charged.
3183pub const SECONDARY_POINTS_PER_QUERY: u64 = 1;
3184/// The fastest *steady* spacing of content-creating mutations that cannot exceed
3185/// [`CONTENT_CREATION_PER_MINUTE`], in milliseconds: 60000/80 is 750.
3186///
3187/// It is no longer the default. This source paces by GitHub's windows instead, so a copy's
3188/// few dozen writes go out as fast as GitHub answers them while the minute has room, rather
3189/// than 750 ms apart — which was most of a plan copy's wall time. An installation that wants
3190/// mutations spaced as well says so through `pacing.min_mutation_interval_ms`, and this is the
3191/// value that holds a long sustained burst under the per-minute bound by spacing alone.
3192pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
3193/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
3194///
3195/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
3196/// own advice for a secondary limit — wait, and wait longer each time — without spending
3197/// the first minute of a transient refusal doing nothing.
3198pub const RETRY_BACKOFF_MS: u64 = 1_000;
3199/// Total time one call may spend waiting out rate limits before it reports a failure.
3200///
3201/// Two minutes is long enough to ride out the refusals a paced copy still collects and
3202/// short enough that a command an operator is watching returns. The bound is what makes
3203/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
3204/// the limiter, not in a process nobody can tell from a wedged one.
3205pub const RETRY_BUDGET_MS: u64 = 120_000;
3206
3207fn default_token_env() -> String {
3208    "GH_PROJECTS_TOKEN".to_owned()
3209}
3210fn default_endpoint() -> String {
3211    "https://api.github.com/graphql".to_owned()
3212}
3213
3214/// The name of a `Status` single-select option on the board.
3215///
3216/// Validated on the way in rather than checked later, so a blank option name — which
3217/// nothing on a board can be — is a state this type cannot hold.
3218#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3219#[serde(try_from = "String")]
3220#[schemars(extend("minLength" = 1))]
3221pub struct ColumnName(String);
3222
3223impl ColumnName {
3224    /// The option name, as the board spells it.
3225    fn as_str(&self) -> &str {
3226        &self.0
3227    }
3228}
3229
3230impl TryFrom<String> for ColumnName {
3231    type Error = String;
3232
3233    fn try_from(name: String) -> Result<Self, Self::Error> {
3234        if name.trim().is_empty() {
3235            return Err("a status_mapping option name cannot be blank".to_owned());
3236        }
3237        Ok(Self(name))
3238    }
3239}
3240
3241/// The two closed states this product can mean.
3242///
3243/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
3244/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
3245/// product never decides on a person's behalf that two issues are the same work.
3246#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
3247#[serde(rename_all = "kebab-case")]
3248pub enum ClosedState {
3249    /// `COMPLETED` — precisely done.
3250    Completed,
3251    /// `NOT_PLANNED` — precisely cancelled.
3252    NotPlanned,
3253}
3254
3255impl ClosedState {
3256    const fn reason(self) -> &'static str {
3257        match self {
3258            Self::Completed => "COMPLETED",
3259            Self::NotPlanned => "NOT_PLANNED",
3260        }
3261    }
3262}
3263
3264/// Configuration for one GitHub Projects v2 board.
3265///
3266/// It serializes to a document this same type reads back as itself, so a caller writing a
3267/// configuration — a consumer filing onto a board it configures — can build one here rather
3268/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
3269#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3270#[serde(default, deny_unknown_fields)]
3271pub struct GitHubProjectsConfig {
3272    /// Login of the user or organization which owns the board.
3273    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
3274    /// The project number shown in the board's GitHub URL.
3275    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
3276    // 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.
3277    /// `owner/name` of the repository this source creates an issue in when the item's own
3278    /// `repositories` field does not decide it.
3279    ///
3280    /// An item naming exactly one repository is created there — on a source declared
3281    /// `visibility: private`, only when that repository reads private, and otherwise as an
3282    /// item naming none is; a task or a document naming none or several is created in its
3283    /// parent project's repository; and a project, or a task or document with no parent,
3284    /// naming none or several is created here. A board
3285    /// has no repository of its own and `createIssue` requires one, so a write without
3286    /// this is refused naming the field. Reads never need it.
3287    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
3288    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
3289    /// GitHub logins whose repositories this source may create an issue in.
3290    ///
3291    /// Absent or empty, the default, restricts nothing. Otherwise a create whose issue the
3292    /// rule `repository` describes would file in a repository owned by anyone else is refused
3293    /// before anything of it is written — by `task copy`, `task copy --create`, a member
3294    /// create of a project copy and a dry run of each — with the error kind `creation-owner`,
3295    /// naming the repository and these owners. Logins compare case-insensitively, as GitHub
3296    /// compares them. An update of an issue that already exists is never refused by it.
3297    /// `task locate` reports such a refusal as `creates_in.refused` rather than failing.
3298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3299    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3300    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3301    #[schemars(!skip_serializing_if)]
3302    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.
3303    /// Environment variable containing a fine-grained token with Projects and Issues
3304    /// read/write plus Pull requests read-only access for every repository represented on
3305    /// the board.
3306    #[serde(default = "default_token_env")]
3307    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
3308    /// GraphQL endpoint. GitHub Enterprise installations may override it.
3309    #[serde(default = "default_endpoint")]
3310    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
3311    /// Per-instance mapping from a status category to the option of the board's one
3312    /// `Status` field it lands on, for a task and for a project.
3313    ///
3314    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
3315    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
3316    /// where a kind left out leaves the category unmapped for that kind. A category this
3317    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
3318    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
3319    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
3320    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
3321    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
3322    /// either kind. No two categories may name one option for the same kind, ignoring case.
3323    /// `unknown` may name one existing option; every unknown word then lands on it and
3324    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
3325    /// each unknown word because it never creates board options.
3326    #[serde(default)]
3327    pub status_mapping: StatusMapping,
3328    /// Per-instance mapping from a task's priority to an option of this board's
3329    /// single-select field named `Priority`.
3330    ///
3331    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
3332    /// other priority is refused before it reaches this board. Present, each of `urgent`,
3333    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
3334    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
3335    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
3336    /// no two levels may name one option. Reads and writes never create the field or an
3337    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
3338    /// the board lacks is refused pointing there.
3339    #[serde(default)]
3340    pub priority_mapping: Option<PriorityMappingConfig>,
3341    /// Caller metadata values this source also writes to board text fields, so the board can
3342    /// filter on them.
3343    ///
3344    /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
3345    /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
3346    /// value. On every item write — create, update and copy, of a task, a project or a
3347    /// document — a string found there is written to the field, and nothing is sent when the
3348    /// field already holds it; nothing there, or `null`, clears the field when it holds a
3349    /// value; any other JSON type is refused before any mutation. The metadata comment in the
3350    /// issue body stays the one home of the value, and every read reports metadata from it.
3351    /// Reads and writes never create the field: `onetaskgraph sources fields <source>
3352    /// --apply` does, as a text field, and a write to a board lacking it, or holding a
3353    /// non-text field of that name, is refused pointing there.
3354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3355    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3356    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3357    #[schemars(!skip_serializing_if)]
3358    pub metadata_fields: Vec<MetadataFieldConfig>,
3359    /// How fast this source writes, and how long it waits out a rate-limit refusal.
3360    ///
3361    /// Every field keeps its shipped default when it is absent, and the defaults are
3362    /// GitHub's own published limits rather than taste. See [`Pacing`].
3363    #[serde(default)]
3364    pub pacing: PacingConfig,
3365    /// What an issue's sub-issues mean on this board.
3366    ///
3367    /// `project`, the default, is this source's meaning since it was first written: an issue
3368    /// with sub-issues reads as a project and its sub-issues as that project's tasks. `parent`
3369    /// reads every issue as a task and a sub-issue as a child under the parent relation, which
3370    /// `task parent set-many` writes and `task relations` reads; a project write to such a
3371    /// source is refused naming this setting. The two meanings answer differently for the same
3372    /// issues, so a `parent` source needs a board of its own: two sources naming one board with
3373    /// different `sub_issues` are refused when the configuration is read.
3374    #[serde(default, skip_serializing_if = "SubIssues::is_project")]
3375    pub sub_issues: SubIssues,
3376    /// The link types this source also writes as GitHub's own `relates-to` relation, beside the
3377    /// store's metadata, so they show in the issue's Relationships panel: each type name mapped
3378    /// to `{"native": "relates-to"}`. A type not named here is kept in metadata alone. Any other
3379    /// word — `blocks`, `blocked-by`, `duplicate`, `similar` among them — is refused when the
3380    /// source is built.
3381    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3382    // Kept in the schema as `"default": {}` although a serialized configuration leaves an empty
3383    // map out, so both SDKs model an absent map as an empty one rather than as `null`.
3384    #[schemars(!skip_serializing_if)]
3385    pub link_types: BTreeMap<String, onetaskgraph_plugin_api::LinkTypeConfig>,
3386}
3387
3388/// What an issue's sub-issues mean on a GitHub Projects board: see
3389/// [`GitHubProjectsConfig::sub_issues`].
3390#[derive(
3391    Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
3392)]
3393#[serde(rename_all = "kebab-case")]
3394pub enum SubIssues {
3395    /// An issue with sub-issues is a project, and its sub-issues are its tasks.
3396    #[default]
3397    Project,
3398    /// Every issue is a task, and a sub-issue is a child under the parent relation.
3399    Parent,
3400}
3401
3402impl SubIssues {
3403    /// Whether this is the default meaning, which a serialized configuration leaves out.
3404    #[must_use]
3405    pub const fn is_project(&self) -> bool {
3406        matches!(self, Self::Project)
3407    }
3408}
3409
3410/// Which option of the board's `Priority` field each priority lands on.
3411///
3412/// One member per level rather than a map, so a key that is not a level is refused where
3413/// the configuration is read, naming the levels there are. `none` is not a member: it is no
3414/// value in the field, not an option of it.
3415#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3416#[serde(default, deny_unknown_fields)]
3417pub struct PriorityMappingConfig {
3418    /// The option `urgent` lands on; `Urgent` when absent.
3419    pub urgent: Option<PriorityOptionName>,
3420    /// The option `high` lands on; `High` when absent.
3421    pub high: Option<PriorityOptionName>,
3422    /// The option `medium` lands on; `Medium` when absent.
3423    pub medium: Option<PriorityOptionName>,
3424    /// The option `low` lands on; `Low` when absent.
3425    pub low: Option<PriorityOptionName>,
3426}
3427
3428/// The name of an option of the board's `Priority` single-select field.
3429///
3430/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
3431/// blank name.
3432#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3433#[serde(try_from = "String")]
3434#[schemars(extend("minLength" = 1))]
3435pub struct PriorityOptionName(String);
3436
3437impl PriorityOptionName {
3438    /// The option name, as the board spells it.
3439    fn as_str(&self) -> &str {
3440        &self.0
3441    }
3442}
3443
3444impl TryFrom<String> for PriorityOptionName {
3445    type Error = String;
3446
3447    fn try_from(name: String) -> Result<Self, Self::Error> {
3448        if name.trim().is_empty() {
3449            return Err("a priority_mapping option name cannot be blank".to_owned());
3450        }
3451        Ok(Self(name))
3452    }
3453}
3454
3455/// The name of the board field a priority is held in.
3456pub const PRIORITY_FIELD: &str = "Priority";
3457
3458/// The four priorities a board option can hold, in the order a new `Priority` field lists
3459/// them. `none` is not among them: it is the field holding no value.
3460///
3461/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
3462/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
3463/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
3464/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
3465pub const PRIORITY_LEVELS: [Priority; 4] = [
3466    Priority::Urgent,
3467    Priority::High,
3468    Priority::Medium,
3469    Priority::Low,
3470];
3471
3472/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
3473/// see that list for what this pins.
3474#[must_use]
3475pub const fn level_position(priority: Priority) -> Option<usize> {
3476    match priority {
3477        Priority::None => None,
3478        Priority::Urgent => Some(0),
3479        Priority::High => Some(1),
3480        Priority::Medium => Some(2),
3481        Priority::Low => Some(3),
3482    }
3483}
3484
3485/// This instance's complete priority-to-option mapping, read in both directions.
3486///
3487/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
3488/// two levels name one option.
3489#[derive(Debug, Clone)]
3490struct PriorityMapping {
3491    options: [PriorityOptionName; 4],
3492}
3493
3494impl PriorityMapping {
3495    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3496        let shipped = |name: &str| PriorityOptionName(name.to_owned());
3497        let mapping = Self {
3498            options: [
3499                config.urgent.unwrap_or_else(|| shipped("Urgent")),
3500                config.high.unwrap_or_else(|| shipped("High")),
3501                config.medium.unwrap_or_else(|| shipped("Medium")),
3502                config.low.unwrap_or_else(|| shipped("Low")),
3503            ],
3504        };
3505        for (index, option) in mapping.options.iter().enumerate() {
3506            if let Some(earlier) = mapping.options[..index]
3507                .iter()
3508                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
3509            {
3510                return Err(SourceError::Config {
3511                    message: format!(
3512                        "priority_mapping of source {instance} sends both {} and {} to the board \
3513                         option {:?}; one option cannot read back as two priorities",
3514                        PRIORITY_LEVELS[earlier],
3515                        PRIORITY_LEVELS[index],
3516                        option.as_str()
3517                    ),
3518                });
3519            }
3520        }
3521        Ok(mapping)
3522    }
3523
3524    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
3525    fn option(&self, priority: Priority) -> Option<&str> {
3526        level_position(priority).map(|index| self.options[index].as_str())
3527    }
3528
3529    /// The priority a board option name reports, or `None` when nothing maps to it.
3530    fn priority_of(&self, option: &str) -> Option<Priority> {
3531        self.options
3532            .iter()
3533            .position(|name| name.as_str().eq_ignore_ascii_case(option))
3534            .map(|index| PRIORITY_LEVELS[index])
3535    }
3536
3537    /// Every mapped option name, in the order a new `Priority` field lists them.
3538    fn names(&self) -> impl Iterator<Item = &str> {
3539        self.options.iter().map(PriorityOptionName::as_str)
3540    }
3541}
3542
3543/// One metadata value this source projects onto a board text field.
3544#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3545#[serde(deny_unknown_fields)]
3546pub struct MetadataFieldConfig {
3547    /// The name of the board text field the value is written to.
3548    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.
3549    /// The top-level metadata key the value is read from. A key under the reserved
3550    /// `onetaskgraph.` prefix is refused.
3551    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.
3552    /// Object keys walked inside that key's value, outermost first. Absent or empty, the
3553    /// key's own value is the one projected. Left out of a serialized entry when empty.
3554    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3555    // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
3556    #[schemars(!skip_serializing_if)]
3557    pub path: Vec<String>,
3558}
3559
3560/// The board fields no metadata value may be projected onto, compared ignoring case: the
3561/// three this source writes itself, and those GitHub owns on every board.
3562// 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.
3563const UNPROJECTABLE_FIELDS: [&str; 13] = [
3564    STATUS_FIELD,
3565    PRIORITY_FIELD,
3566    ORIGIN_FIELD,
3567    "Title",
3568    "Assignees",
3569    "Labels",
3570    "Linked pull requests",
3571    "Milestone",
3572    "Repository",
3573    "Reviewers",
3574    "Parent issue",
3575    "Sub-issues progress",
3576    "Type",
3577];
3578
3579/// One validated [`MetadataFieldConfig`] entry.
3580#[derive(Debug, Clone)]
3581struct MetadataField {
3582    field: ProjectedField,
3583    key: ProjectedKey,
3584    path: Vec<String>,
3585}
3586
3587/// The name of a board text field a metadata value is projected onto: never blank, never one
3588/// this source or GitHub writes, and never one another entry of the same source names.
3589#[derive(Debug, Clone, PartialEq, Eq)]
3590struct ProjectedField(String);
3591
3592/// The top-level metadata key a projected value is read from: never blank, and never under
3593/// the namespace this product reserves.
3594#[derive(Debug, Clone, PartialEq, Eq)]
3595struct ProjectedKey(String);
3596
3597impl std::ops::Deref for ProjectedField {
3598    type Target = str;
3599    fn deref(&self) -> &str {
3600        &self.0
3601    }
3602}
3603
3604impl std::ops::Deref for ProjectedKey {
3605    type Target = str;
3606    fn deref(&self) -> &str {
3607        &self.0
3608    }
3609}
3610
3611/// What one item's metadata says one projected field should hold.
3612#[derive(Debug, Clone, PartialEq, Eq)]
3613enum Projected {
3614    /// A string to hold.
3615    Text(String),
3616    /// Nothing: the key or a step of the path is absent, or the value is `null`.
3617    Nothing,
3618}
3619
3620impl MetadataField {
3621    /// Validate every entry of one instance's `metadata_fields`, refusing the first that
3622    /// cannot stand, naming the source and the entry.
3623    fn resolve(
3624        configured: Vec<MetadataFieldConfig>,
3625        instance: &SourceName,
3626    ) -> Result<Vec<Self>, SourceError> {
3627        let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
3628        for (index, entry) in configured.into_iter().enumerate() {
3629            let refuse = |why: String| SourceError::Config {
3630                message: format!(
3631                    "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
3632                    entry.field, entry.key
3633                ),
3634            };
3635            if entry.field.trim().is_empty() {
3636                return Err(refuse("names a blank field".to_owned()));
3637            }
3638            if entry.key.trim().is_empty() {
3639                return Err(refuse("names a blank key".to_owned()));
3640            }
3641            if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
3642                return Err(refuse(format!(
3643                    "names a key under the reserved \"{}.\" prefix, which this product keeps \
3644                     for its own keys; next: project a caller key instead",
3645                    MetadataKey::RESERVED_NAMESPACE
3646                )));
3647            }
3648            if let Some(owned) = UNPROJECTABLE_FIELDS
3649                .iter()
3650                .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
3651            {
3652                return Err(refuse(format!(
3653                    "names the board field {owned:?}, which this source or GitHub already \
3654                     writes; next: name a text field of your own"
3655                )));
3656            }
3657            if let Some(earlier) = resolved
3658                .iter()
3659                .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
3660            {
3661                return Err(refuse(format!(
3662                    "names the same board field as metadata_fields[{earlier}]; one field \
3663                     cannot hold two values"
3664                )));
3665            }
3666            resolved.push(Self {
3667                field: ProjectedField(entry.field),
3668                key: ProjectedKey(entry.key),
3669                path: entry.path,
3670            });
3671        }
3672        Ok(resolved)
3673    }
3674
3675    /// The path as a refusal spells it: `[]` for the key's own value.
3676    fn spelled_path(&self) -> String {
3677        serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
3678    }
3679
3680    /// What `metadata` says this field should hold, or the refusal for a value no text field
3681    /// can hold.
3682    fn projected(
3683        &self,
3684        metadata: &BTreeMap<String, Value>,
3685        instance: &SourceName,
3686    ) -> Result<Projected, SourceError> {
3687        let mut value = metadata.get(&*self.key);
3688        for step in &self.path {
3689            value = value.and_then(|held| held.get(step.as_str()));
3690        }
3691        let found = match value {
3692            None | Some(Value::Null) => return Ok(Projected::Nothing),
3693            Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
3694            Some(Value::Bool(_)) => "a boolean",
3695            Some(Value::Number(_)) => "a number",
3696            Some(Value::Array(_)) => "an array",
3697            Some(Value::Object(_)) => "an object",
3698        };
3699        Err(SourceError::Refused {
3700            message: format!(
3701                "source {instance} projects metadata key {:?} at path {} onto its board text \
3702                 field {:?}, and this item holds {found} there; only a string, null or nothing \
3703                 can be written to a text field; next: store a string there, or remove the \
3704                 value",
3705                &*self.key,
3706                self.spelled_path(),
3707                &*self.field
3708            ),
3709        })
3710    }
3711}
3712
3713/// What one item's `Priority` field says, read through this instance's mapping.
3714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3715enum HeldPriority {
3716    /// A priority this source reports: an option the mapping names, or no value (`none`).
3717    Read(Priority),
3718    /// An option the mapping does not name, which is never read as a level or as `none`.
3719    Unmapped(String),
3720}
3721
3722/// How fast this source writes, and how long it waits out a rate-limit refusal.
3723///
3724/// Configurable because a GitHub Enterprise installation sets its own limits and an
3725/// operator who has already been refused may want to go slower still — not because the
3726/// defaults are guesses.
3727#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3728#[serde(default, deny_unknown_fields)]
3729pub struct PacingConfig {
3730    /// Shortest interval between two content-creating mutations, in milliseconds.
3731    ///
3732    /// Zero sends them as fast as they are asked for, which is what a fixture server on
3733    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
3734    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.
3735    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
3736    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
3737    /// zero while there is a budget to spend, because a schedule of zero-length waits
3738    /// consumes none of it and so never ends.
3739    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.
3740    /// Total time one call may spend waiting out rate limits, in milliseconds.
3741    ///
3742    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
3743    /// the bound is what makes this a wait rather than a hang.
3744    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.
3745}
3746
3747/// The largest any pacing setting may be, in milliseconds.
3748///
3749/// One hour. GitHub's own harshest published bound on content-generating requests works
3750/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
3751/// anything a real limit asks for, and past it the settings stop describing pacing at all:
3752/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
3753/// and an interval beyond it is a command that never sends its second mutation. It also
3754/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
3755/// what a `Duration` can hold on every platform.
3756pub const MAX_PACING_MS: u64 = 3_600_000;
3757
3758/// [`PacingConfig`] with every default resolved and every value checked, which is what the
3759/// source holds.
3760///
3761/// Unset, `min_mutation_interval_ms` spaces nothing, and every request is paced by GitHub's
3762/// secondary windows alone — see the `pacing` module; set, it spaces content-creating mutations
3763/// on top of those windows, [`MIN_MUTATION_INTERVAL_MS`] being the spacing that holds a sustained
3764/// burst under the per-minute bound by spacing alone; and zero sends every request as fast as it
3765/// is asked for, windows included.
3766#[derive(Debug, Clone, Copy)]
3767struct Pacing {
3768    /// Whether requests are held for GitHub's secondary windows: always, unless
3769    /// `min_mutation_interval_ms` is set to exactly zero, which has always asked for no
3770    /// pacing at all — what a fixture server on loopback wants and no board on github.com does.
3771    windows: bool,
3772    min_mutation_interval: Duration,
3773    retry_backoff: Duration,
3774    retry_budget: Duration,
3775}
3776
3777impl Pacing {
3778    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
3779    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3780        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
3781            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
3782                message: format!(
3783                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
3784                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
3785                     GitHub's own harshest published limit"
3786                ),
3787            }),
3788            Some(value) => Ok(Duration::from_millis(value)),
3789            None => Ok(Duration::from_millis(default)),
3790        };
3791        let retry_backoff = bounded(
3792            config.retry_backoff_ms,
3793            RETRY_BACKOFF_MS,
3794            "retry_backoff_ms",
3795        )?;
3796        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
3797        if retry_backoff.is_zero() && !retry_budget.is_zero() {
3798            return Err(SourceError::Config {
3799                message: format!(
3800                    "pacing.retry_backoff_ms of source {instance} is 0 while \
3801                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
3802                     none of that budget, so it would retry a refusal forever. Set a backoff of \
3803                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
3804                     waiting at all",
3805                    retry_budget.as_millis()
3806                ),
3807            });
3808        }
3809        Ok(Self {
3810            windows: config.min_mutation_interval_ms != Some(0),
3811            min_mutation_interval: bounded(
3812                config.min_mutation_interval_ms,
3813                0,
3814                "min_mutation_interval_ms",
3815            )?,
3816            retry_backoff,
3817            retry_budget,
3818        })
3819    }
3820}
3821
3822/// Factory for [`GitHubProjectsSource`].
3823#[derive(Debug, Clone, Copy, Default)]
3824pub struct Plugin;
3825
3826impl SourcePlugin for Plugin {
3827    fn kind(&self) -> &'static str {
3828        KIND
3829    }
3830    /// The endpoint, the owner and the board's number: what names one board.
3831    fn remote_identity(&self, config: &Value) -> Option<String> {
3832        let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
3833        Some(format!(
3834            "{}\n{}\n{}",
3835            config.endpoint, config.owner, config.project_number
3836        ))
3837    }
3838    fn config_schema(&self) -> Schema {
3839        schema_for!(GitHubProjectsConfig)
3840    }
3841    fn build(
3842        &self,
3843        name: &SourceName,
3844        config: &Value,
3845        secrets: &dyn SecretResolver,
3846    ) -> Result<Box<dyn TaskSource>, SourceError> {
3847        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
3848    }
3849
3850    fn build_with_clock(
3851        &self,
3852        name: &SourceName,
3853        config: &Value,
3854        secrets: &dyn SecretResolver,
3855        clock: SharedClock,
3856    ) -> Result<Box<dyn TaskSource>, SourceError> {
3857        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
3858    }
3859}
3860
3861/// The client `cell` holds, building it with `build` the first time it is asked for.
3862///
3863/// A failed build is the request's failure, with the message the source's construction gave it
3864/// before clients were deferred.
3865// 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`.
3866fn built(
3867    cell: &std::sync::OnceLock<Client>,
3868    build: impl FnOnce() -> Result<Client, SourceError>,
3869) -> Result<&Client, SourceError> {
3870    if let Some(client) = cell.get() {
3871        return Ok(client);
3872    }
3873    let client = build()?;
3874    Ok(cell.get_or_init(|| client))
3875}
3876
3877impl Plugin {
3878    /// Build a source recording every request it sends into an accounting the caller holds.
3879    ///
3880    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
3881    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
3882    /// session total rather than two — see [`accounting`] and
3883    /// [`GitHubProjectsSource::recording_into`].
3884    ///
3885    /// # Errors
3886    ///
3887    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
3888    /// [`SourceError::Config`] for configuration this plugin cannot use and
3889    /// [`SourceError::Auth`] for a credential it cannot find.
3890    pub fn build_recording_into(
3891        &self,
3892        name: &SourceName,
3893        config: &Value,
3894        secrets: &dyn SecretResolver,
3895        ledger: Arc<Accounting>,
3896    ) -> Result<Box<dyn TaskSource>, SourceError> {
3897        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
3898    }
3899
3900    fn build_recording_with_clock(
3901        &self,
3902        name: &SourceName,
3903        config: &Value,
3904        secrets: &dyn SecretResolver,
3905        ledger: Arc<Accounting>,
3906        clock: SharedClock,
3907    ) -> Result<Box<dyn TaskSource>, SourceError> {
3908        let config: GitHubProjectsConfig =
3909            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
3910                message: format!("source {name}: {e}"),
3911            })?;
3912        let prefix = format!("source {name}: ");
3913        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
3914            .map_err(|error| match error {
3915                // The shared `StatusMapping::distinct` names the source itself.
3916                SourceError::Config { message } if message.starts_with(&prefix) => {
3917                    SourceError::Config { message }
3918                }
3919                SourceError::Config { message } => SourceError::Config {
3920                    message: format!("{prefix}{message}"),
3921                },
3922                SourceError::Auth { message } => SourceError::Auth {
3923                    message: format!("source {name}: {message}"),
3924                },
3925                other => other,
3926            })?;
3927        source.clock = clock;
3928        Ok(Box::new(source))
3929    }
3930}
3931
3932/// Where a status category lands on this board, once configuration is resolved.
3933#[derive(Debug, Clone, PartialEq, Eq)]
3934enum StatusTarget {
3935    /// Not usable against this instance for this kind, and why.
3936    Disabled(UnmappedStatus),
3937    /// The board's `Status` option of this name.
3938    Column(ColumnName),
3939    /// A closed issue, with both its board option and the reason that says which closed it means.
3940    // 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.
3941    Terminal(ColumnName, ClosedState),
3942}
3943
3944/// Every status category, in the order the vocabulary declares them.
3945///
3946/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
3947/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
3948/// added to the shared vocabulary fails to compile until it is named there, and this
3949/// crate's suite reconciles this list against that enum's own derived schema, which is
3950/// generated from the variants rather than written beside them. The schema is what
3951/// catches a list left one short — a list checking only the positions it already holds
3952/// would pass while every mapping indexed by the new position panicked.
3953pub const CATEGORIES: [StatusCategory; 8] = [
3954    StatusCategory::Draft,
3955    StatusCategory::Backlog,
3956    StatusCategory::Todo,
3957    StatusCategory::Queued,
3958    StatusCategory::InProgress,
3959    StatusCategory::Done,
3960    StatusCategory::Cancelled,
3961    StatusCategory::Unknown,
3962];
3963
3964/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
3965#[must_use]
3966pub const fn category_position(category: StatusCategory) -> usize {
3967    match category {
3968        StatusCategory::Draft => 0,
3969        StatusCategory::Backlog => 1,
3970        StatusCategory::Todo => 2,
3971        StatusCategory::Queued => 3,
3972        StatusCategory::InProgress => 4,
3973        StatusCategory::Done => 5,
3974        StatusCategory::Cancelled => 6,
3975        StatusCategory::Unknown => 7,
3976    }
3977}
3978
3979/// The spelling a status category is configured and reported under.
3980fn category_name(category: StatusCategory) -> &'static str {
3981    match category {
3982        StatusCategory::Draft => "draft",
3983        StatusCategory::Backlog => "backlog",
3984        StatusCategory::Todo => "todo",
3985        StatusCategory::Queued => "queued",
3986        StatusCategory::InProgress => "in-progress",
3987        StatusCategory::Done => "done",
3988        StatusCategory::Cancelled => "cancelled",
3989        StatusCategory::Unknown => "unknown",
3990    }
3991}
3992
3993/// A shipped default's option name.
3994///
3995/// The literals below are this file's own and non-blank, and they are validated by the
3996/// one constructor a configured name goes through rather than beside it.
3997fn shipped_column(name: &'static str) -> ColumnName {
3998    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
3999}
4000
4001/// The shipped default for one category this instance's `status_mapping` does not mention,
4002/// for either kind.
4003fn shipped_default(category: StatusCategory) -> StatusTarget {
4004    match category {
4005        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
4006        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
4007        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
4008        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
4009        StatusCategory::Done => {
4010            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
4011        }
4012        StatusCategory::Cancelled => {
4013            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
4014        }
4015        StatusCategory::Draft | StatusCategory::Unknown => {
4016            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
4017        }
4018    }
4019}
4020
4021/// The two kinds a status is written and read for, each with its own half of the mapping.
4022const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
4023
4024/// This instance's complete category-to-target mapping for each kind, read in both
4025/// directions.
4026///
4027/// One target per category per kind, held at that category's own [`category_position`], so
4028/// a category missing from the mapping, named twice in it, or filed out of order is a state
4029/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
4030/// targets are options of the board's one `Status` field.
4031#[derive(Debug, Clone)]
4032struct BoardStatuses {
4033    tasks: [StatusTarget; CATEGORIES.len()],
4034    projects: [StatusTarget; CATEGORIES.len()],
4035}
4036
4037impl BoardStatuses {
4038    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
4039    /// would read back from one option.
4040    ///
4041    /// A category the mapping does not mention keeps its shipped default for both kinds; one
4042    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
4043    /// omits unmapped rather than defaulted.
4044    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
4045        let resolve_kind =
4046            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
4047                // `CATEGORIES[position] == category` for every category — the crate's suite
4048                // asserts it — so mapping the list in order fills each category's own slot.
4049                let mut targets = CATEGORIES.map(shipped_default);
4050                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
4051                    if !configured.mentions(category) {
4052                        continue;
4053                    }
4054                    *slot = match configured.name_for(category, kind) {
4055                        Err(why) => StatusTarget::Disabled(why),
4056                        Ok(name) => {
4057                            let option = ColumnName::try_from(name.as_str().to_owned())
4058                                .map_err(|message| SourceError::Config { message })?;
4059                            match category {
4060                                StatusCategory::Done => {
4061                                    StatusTarget::Terminal(option, ClosedState::Completed)
4062                                }
4063                                StatusCategory::Cancelled => {
4064                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
4065                                }
4066                                _ => StatusTarget::Column(option),
4067                            }
4068                        }
4069                    };
4070                }
4071                StatusMapping::distinct(
4072                    instance,
4073                    kind,
4074                    CATEGORIES
4075                        .iter()
4076                        .zip(&targets)
4077                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
4078                )?;
4079                Ok(targets)
4080            };
4081        Ok(Self {
4082            tasks: resolve_kind(ItemKind::Task)?,
4083            projects: resolve_kind(ItemKind::Project)?,
4084        })
4085    }
4086
4087    /// Every category's target for `kind`, in category order.
4088    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
4089        match kind {
4090            ItemKind::Task => &self.tasks,
4091            ItemKind::Project => &self.projects,
4092        }
4093    }
4094
4095    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
4096        &self.targets(kind)[category_position(category)]
4097    }
4098
4099    /// The category a board option name reports for `kind`, or `None` when nothing of that
4100    /// kind maps to it.
4101    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
4102        CATEGORIES.into_iter().find(|category| {
4103            self.target(kind, *category)
4104                .option()
4105                .is_some_and(|name| name.eq_ignore_ascii_case(option))
4106        })
4107    }
4108
4109    /// Every option name either kind maps a category to, each once ignoring case, in
4110    /// category order with a task's name before a project's — what the guarded setup asks
4111    /// the `Status` field to hold.
4112    fn wanted(&self) -> Vec<String> {
4113        let mut wanted: Vec<String> = Vec::new();
4114        for category in CATEGORIES {
4115            for kind in STATUS_KINDS {
4116                if let Some(name) = self.target(kind, category).option()
4117                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
4118                {
4119                    wanted.push(name.to_owned());
4120                }
4121            }
4122        }
4123        wanted
4124    }
4125
4126    /// The status an item of `kind` reports, from the three things a read of it says: its
4127    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
4128    ///
4129    /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
4130    /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
4131    /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
4132    /// produce, so it is read permissively rather than refused — reads are faithful, and
4133    /// refusals belong on writes.
4134    ///
4135    /// **Its name follows the closure too, unless the option agrees with it.** The option's
4136    /// own name is kept when this kind's mapping reads that option as the same category, or
4137    /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
4138    /// named `Shipped`. An option the mapping reads as another category contradicts the
4139    /// closure, and so does no option at all: the name is then the option the mapping gives
4140    /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
4141    /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
4142    /// it. An `unknown` closure keeps the option, or `Closed` without one.
4143    ///
4144    /// An open item's option reads through its own kind's mapping, and an option that mapping
4145    /// does not name reads as `Unknown` under its own name.
4146    ///
4147    /// One function of those three rather than of a response, so a narrow status write can
4148    /// answer what a re-read would report by applying it to the state it has just written.
4149    fn status(
4150        &self,
4151        kind: ItemKind,
4152        option: Option<&str>,
4153        closed: bool,
4154        reason: Option<&str>,
4155    ) -> Status {
4156        if closed {
4157            let category = closed_category(reason);
4158            let agrees = option.filter(|option| {
4159                category == StatusCategory::Unknown
4160                    || self
4161                        .category_of(kind, option)
4162                        .is_none_or(|read| read == category)
4163            });
4164            let name = agrees.or_else(|| self.target(kind, category).option());
4165            let fallback = match category {
4166                StatusCategory::Done => "Done",
4167                StatusCategory::Cancelled => "Cancelled",
4168                _ => "Closed",
4169            };
4170            return Status {
4171                category,
4172                name: name.or(option).unwrap_or(fallback).to_owned(),
4173            };
4174        }
4175        let name = option.unwrap_or("Open").to_owned();
4176        Status {
4177            category: self
4178                .category_of(kind, &name)
4179                .unwrap_or(StatusCategory::Unknown),
4180            name,
4181        }
4182    }
4183
4184    /// The option a closed item's `Status` has to hold for its card to agree with its
4185    /// closure, and the category that closure reads as — or `None` when the item is open,
4186    /// its closure reads `unknown`, or its option already agrees.
4187    ///
4188    /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
4189    /// in line, each to the one option the mapping gives its category — an item with no
4190    /// option, or at one the mapping does not name, included.
4191    fn contradicted(
4192        &self,
4193        kind: ItemKind,
4194        option: Option<&str>,
4195        closed: bool,
4196        reason: Option<&str>,
4197    ) -> Option<(ClosedAs, &str)> {
4198        if !closed {
4199            return None;
4200        }
4201        let closed_as = match closed_category(reason) {
4202            StatusCategory::Done => ClosedAs::Done,
4203            StatusCategory::Cancelled => ClosedAs::Cancelled,
4204            _ => return None,
4205        };
4206        let wanted = self.target(kind, closed_as.category()).option()?;
4207        if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
4208            return None;
4209        }
4210        Some((closed_as, wanted))
4211    }
4212}
4213
4214/// The category a closed issue reads as, from the reason it was closed with.
4215///
4216/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
4217/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
4218/// is one this source cannot read a category from.
4219fn closed_category(reason: Option<&str>) -> StatusCategory {
4220    match reason {
4221        None | Some("COMPLETED") => StatusCategory::Done,
4222        Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
4223        Some(_) => StatusCategory::Unknown,
4224    }
4225}
4226
4227impl BoardStatuses {
4228    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
4229    /// case; a kind lacking none is left out.
4230    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
4231        STATUS_KINDS
4232            .into_iter()
4233            .filter_map(|kind| {
4234                let missing: Vec<String> = self
4235                    .targets(kind)
4236                    .iter()
4237                    .filter_map(StatusTarget::option)
4238                    .filter(|wanted| {
4239                        !existing
4240                            .iter()
4241                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4242                    })
4243                    .map(str::to_owned)
4244                    .collect();
4245                (!missing.is_empty()).then_some(KindMissing { kind, missing })
4246            })
4247            .collect()
4248    }
4249}
4250
4251impl StatusTarget {
4252    /// The board option this target selects, or `None` for an unmapped one.
4253    fn option(&self) -> Option<&str> {
4254        match self {
4255            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
4256            Self::Disabled(_) => None,
4257        }
4258    }
4259}
4260
4261// 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.
4262/// One repository this source can create an issue in, as `owner/name`.
4263///
4264/// Every `createIssue` this source sends names one of these: the item's own single
4265/// `repositories` entry — on a source declared private, only when it reads private — else
4266/// its parent project issue's repository, else the configured
4267/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
4268/// that choice and says what it refuses before `createIssue`.
4269// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
4270#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
4271#[serde(try_from = "String", into = "String")]
4272struct RepositoryTarget {
4273    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4274    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4275}
4276
4277impl RepositoryTarget {
4278    fn parse(value: &str) -> Result<Self, SourceError> {
4279        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
4280            message: format!(
4281                "repository must be spelled owner/name; {value:?} names no repository"
4282            ),
4283        })?;
4284        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
4285            return Err(SourceError::Config {
4286                message: format!(
4287                    "repository must be spelled owner/name with a GitHub login and one \
4288                     repository name; {value:?} is not"
4289                ),
4290            });
4291        }
4292        Ok(Self {
4293            owner: owner.to_owned(),
4294            name: name.to_owned(),
4295        })
4296    }
4297
4298    /// The one host whose repositories this source creates issues in, spelled once: it is
4299    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
4300    const HOST: &str = "github.com";
4301
4302    fn origin(&self) -> String {
4303        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
4304    }
4305
4306    /// The repository a normalized origin names, or why it is none this source can create
4307    /// an issue in: another host, or more or fewer than `owner/name` under this one.
4308    fn from_origin(origin: &Repository) -> Result<Self, String> {
4309        let not_here = || {
4310            format!(
4311                "{} is not a {}/owner/name repository",
4312                origin.as_str(),
4313                Self::HOST
4314            )
4315        };
4316        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
4317        if host != Self::HOST {
4318            return Err(not_here());
4319        }
4320        Self::parse(rest).map_err(|_| not_here())
4321    }
4322
4323    fn slug(&self) -> String {
4324        format!("{}/{}", self.owner, self.name)
4325    }
4326}
4327
4328/// Read back from a kept context as the `owner/name` it was written as, refused unless
4329/// [`RepositoryTarget::parse`] takes it.
4330impl TryFrom<String> for RepositoryTarget {
4331    type Error = String;
4332
4333    fn try_from(slug: String) -> Result<Self, Self::Error> {
4334        Self::parse(&slug).map_err(|error| error.to_string())
4335    }
4336}
4337
4338impl From<RepositoryTarget> for String {
4339    fn from(target: RepositoryTarget) -> Self {
4340        target.slug()
4341    }
4342}
4343
4344/// How long GitHub's issue search may go on missing a comment after it was made or edited,
4345/// which a read of the comments made since an instant sets its watermark back by: four times the
4346/// thirty seconds the credentialed lane has watched it lag.
4347pub const COMMENT_SEARCH_LAG: std::time::Duration = std::time::Duration::from_secs(120);
4348
4349/// A source which reads GitHub afresh for every operation.
4350pub struct GitHubProjectsSource {
4351    /// This source's configured name, used both to tell a far end naming this source
4352    /// from one naming a system it knows nothing about, and to name the instance a
4353    /// status refusal is about.
4354    name: SourceName,
4355    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
4356    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
4357    repository: Option<RepositoryTarget>,
4358    /// The logins whose repositories a create may file in; empty restricts nothing.
4359    creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after each entry passes GitHub's login grammar.
4360    endpoint: Url,
4361    token: SecretString,
4362    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
4363    statuses: BoardStatuses,
4364    /// Where each priority lands on this board, or `None` when this instance holds none.
4365    priorities: Option<PriorityMapping>,
4366    /// The metadata values this instance projects onto board text fields, in configured order.
4367    metadata_fields: Vec<MetadataField>,
4368    /// What this board's sub-issues mean.
4369    sub_issues: SubIssues,
4370    /// The link types this source mirrors as `relates-to`.
4371    link_types: onetaskgraph_plugin_api::LinkTypes,
4372    /// Built on the first request rather than with the source, as [`Self::client`] says.
4373    client: std::sync::OnceLock<Client>,
4374    /// Built on the first attachment request, as [`Self::asset_client`] says.
4375    asset_client: std::sync::OnceLock<Client>,
4376    /// The markup the engine handed this source for its bodies' headings, applied to every
4377    /// visible body it composes and stripped from every one it reads back: see
4378    /// [`TaskSource::attach_body_markup`].
4379    body_markup: std::sync::OnceLock<onetaskgraph_plugin_api::SharedBodyMarkup>,
4380    /// Every item this source has created in this command, in the order it created them —
4381    /// dropped by [`TaskSource::end_command`].
4382    ///
4383    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
4384    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
4385    /// a copy resolving a dependency on an item it had just created refused it as not
4386    /// found. A board read is completed from this — an item remembered here and absent from
4387    /// the read is added back, because the board really does hold it and only the read is
4388    /// behind.
4389    ///
4390    /// It is not a cache of a user's work: nothing is remembered that this process did not
4391    /// itself just write, it lives and dies with the process, and it is never consulted for
4392    /// an item this source did not create.
4393    created: Mutex<Vec<Resolved>>,
4394    /// Every item that already existed and that this source has written in this command, as
4395    /// it wrote it — dropped by [`TaskSource::end_command`].
4396    ///
4397    /// The other half of [`Self::created`], held on the same terms and for the reason a
4398    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
4399    /// filter is an index behind a write this process made moments ago, so a query matching
4400    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
4401    /// is remembered that this process did not itself just write.
4402    updated: Mutex<Vec<Resolved>>,
4403    /// Every issue this source has added a comment to or edited a comment of in this command
4404    /// — dropped by [`TaskSource::end_command`].
4405    ///
4406    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
4407    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
4408    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
4409    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
4410    /// from before — until the index caught up. Each id here is a candidate of every such
4411    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
4412    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
4413    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
4414    /// process wrote is still found only once the index has it.
4415    commented: Mutex<Vec<NativeId>>,
4416    /// How fast this source writes, and how long it waits out a refusal.
4417    pacing: Pacing,
4418    /// When the last content-creating mutation finished, or the moment the furthest-out
4419    /// reserved slot releases the next one, whichever is later — so the one after it can be
4420    /// spaced from that, when `pacing.min_mutation_interval_ms` asks for a spacing. See
4421    /// [`GitHubProjectsSource::paced`] for why completion rather than release is what it is
4422    /// measured from.
4423    last_mutation: Mutex<Option<Duration>>,
4424    /// The `Date` GitHub stated on the last answer this source read: its own time, which the
4425    /// watermark of a comment read is set back from. See [`TaskSource::served_at`].
4426    last_served: Mutex<Option<DateTime<Utc>>>,
4427    /// GitHub's secondary windows as this source has charged them; see the `pacing` module.
4428    windows: Mutex<pacing::Windows>,
4429    /// The tasks a project holds, as a copy's batched read of the project and every one of them
4430    /// found them — see [`TaskSource::read_ahead_for_copy`] — for the length of one command, so
4431    /// a listing of that project's tasks is answered without a read of its own.
4432    task_children: Mutex<BTreeMap<NativeId, Vec<Resolved>>>,
4433    /// The web address of each issue this source created in the current command, as its
4434    /// `createIssue` answered it — what [`TaskSource::written_location`] answers, without a
4435    /// request.
4436    written: Mutex<BTreeMap<NativeId, String>>,
4437    /// What a copy has held back of the items it created, between
4438    /// [`TaskSource::begin_deferred_writes`] and [`TaskSource::finish_writes`] — `None` while
4439    /// nothing is being held back, when every write lands whole as it is made.
4440    deferred: Mutex<Option<Deferred>>,
4441    /// Each existing issue's update held back while writes are, in the order it was made: sent
4442    /// together by [`Self::send_held_updates`] before any other request goes out, so nothing
4443    /// this source sends or reads ever sees the board without them.
4444    held_updates: Mutex<Vec<HeldUpdate>>,
4445    /// The first page of comments a comment-activity read read of each candidate it confirmed,
4446    /// held for the one comment listing of that task that follows in this command — see
4447    /// `batch::hold_comment_pages`.
4448    held_comments: Mutex<BTreeMap<NativeId, Page<Comment>>>,
4449    /// The board field writes and issue updates a batch of targeted updates is holding back,
4450    /// while one runs — see `batch::TaskWrites`.
4451    task_writes: Mutex<Option<batch::TaskWrites>>,
4452    /// The change token each task a targeted update wrote in the current command holds after
4453    /// that write, by the task's id with its board item's id — spelled from the write's own
4454    /// last response, or from the read made right after it where no response showed the whole
4455    /// issue: what [`TaskSource::written_task`] answers as its `version`. Any later mutation
4456    /// naming the task or its board item drops its entry before it is sent, so an entry is
4457    /// never one write behind.
4458    /// A response that showed the issue whole but not every part of its token — a draft, or a
4459    /// page of labels or field values that did not end — records `None`, which a read would
4460    /// not improve on.
4461    written_tokens: Mutex<BTreeMap<NativeId, (String, Option<String>)>>,
4462    clock: SharedClock,
4463    /// Whether the configuration declares this source private, as
4464    /// [`TaskSource::declare_visibility`] told it: what decides where a new issue whose one
4465    /// named repository reads public is created — see [`Self::creation_rule`].
4466    declared_private: std::sync::atomic::AtomicBool,
4467    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
4468    /// The board as this process last read it, for the length of one command — dropped by
4469    /// [`TaskSource::end_command`].
4470    ///
4471    /// A copy of a project used to re-read the whole board, paged, before writing each of
4472    /// its items, which is by far the largest part of a copy's request count and none of
4473    /// its work. Nothing else changes this board while a command runs — this source's own
4474    /// writes are the only writer — so one read answers them all.
4475    ///
4476    /// It is not a store of a user's work and it is not the cache the no-persistence
4477    /// invariant forbids: it lives and dies with the process exactly as `created` does,
4478    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
4479    /// an item this command created and then depends on resolves whether or not GitHub's
4480    /// own eventually-consistent read has caught up. A write to an item already on the
4481    /// board updates the entry here too, so what this holds is the last read plus this
4482    /// process's own writes rather than a snapshot taken before them.
4483    board_cache: Mutex<Option<Board>>,
4484    /// Every issue this board's own search reported, for the length of one command — dropped
4485    /// by [`TaskSource::end_command`].
4486    ///
4487    /// The second half of a board read, and cached for the same reason and on the same
4488    /// terms as the first: it lives and dies with the process, nothing is written down, and
4489    /// a write this process makes updates the entry here exactly as it updates the one in
4490    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
4491    /// that lists this board's projects and its tasks pays for one search rather than two.
4492    search_cache: Mutex<Option<Vec<Resolved>>>,
4493    /// What each narrowed question GitHub was asked answered, keyed by that question, for
4494    /// the length of one command — dropped by [`TaskSource::end_command`].
4495    ///
4496    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
4497    /// and dies with the process, nothing is written down, a write this process makes
4498    /// updates the entry here as it updates the other two, and every answer is completed
4499    /// with this process's own writes each time it is given. A command that asks the same
4500    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
4501    /// write — pays for it once, which is what the whole-board read it replaced gave it.
4502    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
4503    search_next: Mutex<BTreeMap<String, Option<String>>>,
4504    /// Records already resolved in this command, reused by writes and for comment identity.
4505    /// Explicit item reads still reach GitHub. Nothing is persisted, and
4506    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
4507    /// its item as a person has since left it.
4508    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4509    /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
4510    /// answered as holding nothing — each taken by the next read of that item by id and by
4511    /// nothing else, dropped by any mutation naming it, and cleared by
4512    /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
4513    /// read-before-write, made in the same request as the others' rather than on its own.
4514    read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
4515    /// The origins [`TaskSource::read_ahead_origins_for_copy`] was asked about and has not sent
4516    /// yet: held for the [`TaskSource::read_ahead_for_copy`] that follows it, which sends them —
4517    /// beside the members of the one project it names, when it names one — or for the next
4518    /// request this source makes, whichever comes first.
4519    pending_origins: Mutex<Vec<String>>,
4520    /// Each project's members as a lookup carrying them read them in this command, trimmed to
4521    /// their classification and repositories, taken by [`TaskSource::member_classifications`]
4522    /// of that project and by nothing else, and cleared by [`TaskSource::end_command`].
4523    member_classes: Mutex<BTreeMap<NativeId, Vec<MemberClass>>>,
4524    /// Every item a listing of one project's members carried whole in this command — the
4525    /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
4526    /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
4527    /// it again. No other read is answered from it, so a read by id outside a copy's
4528    /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
4529    /// item, and cleared by [`TaskSource::end_command`].
4530    carried: Mutex<BTreeMap<NativeId, Resolved>>,
4531    /// Items an issue read by reference reached, held for the one status write each was read
4532    /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
4533    /// what such a write needs. Dropped by [`TaskSource::end_command`].
4534    held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4535    /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
4536    /// asked for under, with the project that selector named — for the length of one command,
4537    /// dropped by [`TaskSource::end_command`].
4538    ///
4539    /// A caller pages through a project's tasks one engine page at a time, and every page
4540    /// is cut from the whole list of them, so without this each page walked every
4541    /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
4542    /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
4543    /// dies with the process, nothing is written down, a write this process makes updates or
4544    /// removes the entry here as it does there, and every answer is completed with this
4545    /// process's own writes each time it is given.
4546    children_cache: Mutex<ProjectChildren>,
4547    /// The board's own id and field definitions as this process last read them on their
4548    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
4549    ///
4550    /// What a write needs of the board and its item does not say, read once per command
4551    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
4552    /// lives and dies with the process and nothing is written down. It holds no item and so
4553    /// can answer no question about one — see [`Self::board_fields`].
4554    fields_cache: Mutex<Option<BoardFields>>,
4555    /// Each destination repository's node id, resolved once per repository
4556    /// rather than per issue created.
4557    ///
4558    /// A repository's node id does not change, and re-reading it for every issue of a copy
4559    /// spent one request per item on an answer this source already had. It is a map rather
4560    /// than one entry because a copy files each item in the repository its own
4561    /// `repositories` field names, so a plan across five repositories asks GitHub five
4562    /// times and not once per item.
4563    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
4564    /// What every request this source sends is recorded into.
4565    ///
4566    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
4567    /// a request leaves this crate, so nothing has to be switched on for a session to be
4568    /// counted. It is shared rather than owned so a caller accounting for a whole session —
4569    /// its own schema verification, board lookups, residue sweep and cleanup beside this
4570    /// source's reads and writes — adds up one accounting instead of two. See
4571    /// [`accounting`] for what a record carries and what a session's spend is and is not.
4572    ledger: Arc<Accounting>,
4573    /// What [`expected`] holds for a write the store's cache expects: the journal while one is
4574    /// open, the issues primed from the cache, and the comments known on each.
4575    expected: expected::Held,
4576    /// What [`projection`] holds for a producer's projection: a kept placement awaiting its
4577    /// setup-context token, and where the last create was placed.
4578    projection: projection::Held,
4579}
4580
4581/// The board item a single field update's answer names, refused unless it is `item_id`.
4582fn field_update_item<'a>(data: &'a Value, item_id: &str) -> Result<&'a Value, SourceError> {
4583    let returned = data
4584        .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
4585        .ok_or_else(|| SourceError::Malformed {
4586            message: "GitHub field update returned no project item".into(),
4587        })?;
4588    if required_str(returned, "id")? != item_id {
4589        return Err(SourceError::Malformed {
4590            message: "GitHub field update returned the wrong project item".into(),
4591        });
4592    }
4593    Ok(returned)
4594}
4595
4596/// GitHub's closed single-select color vocabulary.
4597#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
4598#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
4599pub enum StatusOptionColor {
4600    /// Gray.
4601    Gray,
4602    /// Blue.
4603    Blue,
4604    /// Green.
4605    Green,
4606    /// Yellow.
4607    Yellow,
4608    /// Purple.
4609    Purple,
4610    /// Red.
4611    Red,
4612    /// Orange.
4613    Orange,
4614    /// Pink.
4615    Pink,
4616}
4617
4618/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
4619/// applies its additions.
4620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4621pub enum SetupMode {
4622    /// Read without mutation.
4623    Plan,
4624    /// Apply and verify.
4625    Apply,
4626}
4627
4628/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
4629/// against it goes on compiling.
4630pub type StatusOptionsMode = SetupMode;
4631
4632/// The explicit result of the requested operation.
4633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4634#[serde(rename_all = "kebab-case")]
4635pub enum StatusOptionsOutcome {
4636    /// A read-only plan.
4637    Planned,
4638    /// Apply found nothing missing.
4639    Unchanged,
4640    /// Additions were applied and verified.
4641    Applied,
4642}
4643
4644/// A GitHub single-select option's opaque GraphQL node identifier.
4645#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4646#[serde(transparent)]
4647pub struct StatusOptionId(#[schemars(length(min = 1))] String);
4648
4649impl TryFrom<String> for StatusOptionId {
4650    type Error = String;
4651
4652    fn try_from(id: String) -> Result<Self, Self::Error> {
4653        if id.trim().is_empty() {
4654            return Err("a GitHub Status option id cannot be blank".to_owned());
4655        }
4656        Ok(Self(id))
4657    }
4658}
4659
4660/// One existing or proposed option in a guarded Status-field update.
4661#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4662pub struct StatusOption {
4663    /// GitHub's stable id.
4664    pub id: StatusOptionId,
4665    /// The visible option name.
4666    pub name: ColumnName,
4667    /// GitHub's single-select color token.
4668    pub color: StatusOptionColor,
4669    /// The option description, including an empty one.
4670    pub description: String,
4671}
4672
4673/// One board item's Status assignment, retained as recovery data.
4674#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4675pub struct StatusAssignment {
4676    /// The project item id whose assignment this is.
4677    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
4678    // carried verbatim as operator recovery data; introducing a semantic type would claim
4679    // validation rules GitHub does not publish and no operation here interprets.
4680    pub item_id: String,
4681    /// The selected option, absent when the item has no status.
4682    #[serde(skip_serializing_if = "Option::is_none")]
4683    pub option: Option<AssignedStatusOption>,
4684}
4685
4686/// The inseparable id and name of an assigned option.
4687#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4688pub struct AssignedStatusOption {
4689    /// GitHub's stable id.
4690    pub id: StatusOptionId,
4691    /// The visible name.
4692    pub name: ColumnName,
4693}
4694
4695/// The plan and verified outcome of reconciling configured Status options.
4696#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4697pub struct StatusOptionsReport {
4698    /// The configured source name.
4699    pub source: SourceName,
4700    /// Configured option names absent before the operation.
4701    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
4702    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
4703    // serialized string here preserves the report's intentionally simple public contract.
4704    pub missing: Vec<String>,
4705    /// What the requested operation did.
4706    pub outcome: StatusOptionsOutcome,
4707    /// The complete option list observed before any mutation.
4708    pub existing: Vec<StatusOption>,
4709}
4710
4711#[derive(Debug, Clone, PartialEq, Eq)]
4712struct StatusSnapshot {
4713    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4714    // passed back as the mutation's project identity; a newtype could enforce no stronger
4715    // invariant because GitHub publishes no grammar for it.
4716    board_id: String,
4717    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4718    // passed back as the mutation's field identity; a newtype could enforce no stronger
4719    // invariant because GitHub publishes no grammar for it.
4720    field_id: String,
4721    options: Vec<StatusOption>,
4722    assignments: Vec<StatusAssignment>,
4723}
4724
4725/// The name of the board field a status is held in.
4726const STATUS_FIELD: &str = "Status";
4727
4728/// Every item's value of each field `report` names, as it stood before the setup wrote
4729/// anything — what a person puts back when the setup is refused part way.
4730fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
4731    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
4732        .fields
4733        .iter()
4734        .map(|field| (field.field.name(), before.assignments(field.field)))
4735        .collect();
4736    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
4737        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
4738    })
4739}
4740
4741/// One board field the guarded setup reads and writes — every one it reads, and the only
4742/// ones it writes.
4743#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
4744pub enum BoardField {
4745    /// The single-select `Status` field every instance's `status_mapping` resolves into.
4746    Status,
4747    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
4748    Priority,
4749}
4750
4751impl BoardField {
4752    /// The field's name on the board.
4753    #[must_use]
4754    pub const fn name(self) -> &'static str {
4755        match self {
4756            Self::Status => STATUS_FIELD,
4757            Self::Priority => PRIORITY_FIELD,
4758        }
4759    }
4760
4761    /// The field a board calls `name`, or `None` for one this setup does not own.
4762    fn named(name: &str) -> Option<Self> {
4763        [Self::Status, Self::Priority]
4764            .into_iter()
4765            .find(|field| field.name() == name)
4766    }
4767}
4768
4769/// What the guarded setup did to one field.
4770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4771#[serde(rename_all = "kebab-case")]
4772pub enum FieldOutcome {
4773    /// A read-only plan.
4774    Planned,
4775    /// Apply found the field there with every configured option.
4776    Unchanged,
4777    /// Missing options were added to the field that was there, and verified.
4778    Applied,
4779    /// The field was not there; it was created holding the configured options, and verified.
4780    Created,
4781}
4782
4783/// One field's plan, or its verified outcome.
4784#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4785pub struct FieldReport {
4786    /// Which field.
4787    pub field: BoardField,
4788    /// Whether the board had the field before the operation.
4789    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
4790    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
4791    // "outcome", "existing"}` — so folding one into the other would change a published JSON
4792    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
4793    // one constructor, and it derives `outcome` from `exists` in one match.
4794    pub exists: bool,
4795    /// Configured option names the field lacked before the operation — every one of them,
4796    /// in the order a new field lists them, when the field was not there at all.
4797    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4798    // mapping name and has therefore already passed its nonblank validation; the serialized
4799    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
4800    pub missing: Vec<String>,
4801    /// For the `Status` field, which item kind each missing name is configured for: one
4802    /// entry per kind `status_mapping` names a missing option for, task before project, each
4803    /// listing that kind's missing names in category order. A name both kinds use is in
4804    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
4805    /// `Priority`, which only a task holds.
4806    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4807    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4808    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
4809    #[schemars(!skip_serializing_if)]
4810    pub kinds: Vec<KindMissing>,
4811    /// What the requested operation did.
4812    pub outcome: FieldOutcome,
4813    /// The field's complete option list observed before any mutation; empty when the field
4814    /// was not there.
4815    pub existing: Vec<StatusOption>,
4816}
4817
4818/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
4819#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4820pub struct KindMissing {
4821    /// The kind these names are configured for.
4822    pub kind: ItemKind,
4823    /// The names that kind maps a category to and the field lacked, in category order.
4824    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4825    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
4826    // intentionally simple public contract.
4827    pub missing: Vec<String>,
4828}
4829
4830/// The plan and verified outcome of setting up every field a source's configuration names.
4831#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4832pub struct FieldsReport {
4833    /// The configured source name.
4834    pub source: SourceName,
4835    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
4836    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
4837    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
4838    // per field would change a published JSON shape. The states the list could hold and the
4839    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
4840    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
4841    pub fields: Vec<FieldReport>,
4842    /// Each board text field the source's `metadata_fields` projects a value onto, in
4843    /// configured order. Empty — and left out of the JSON — when it projects none.
4844    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4845    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4846    // both SDKs model an absent list as an empty one rather than as `null`.
4847    #[schemars(!skip_serializing_if)]
4848    pub metadata_fields: Vec<MetadataFieldReport>,
4849}
4850
4851/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
4852/// its GraphQL type when it has none — which is what a projected field's conflict reports.
4853///
4854/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
4855/// no conflict.
4856// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
4857// refuses exactly what `try_from` below does.
4858#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4859#[serde(transparent)]
4860pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
4861
4862/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
4863/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
4864/// neither SDK's validator can be relied on to support.
4865const NON_TEXT_FIELD_TYPE_PATTERN: &str =
4866    r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
4867
4868impl TryFrom<String> for NonTextFieldType {
4869    type Error = String;
4870
4871    fn try_from(kind: String) -> Result<Self, Self::Error> {
4872        if kind.is_empty() || kind.starts_with(char::is_whitespace) {
4873            return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
4874        }
4875        if kind == "TEXT" {
4876            return Err("a TEXT field is no conflict for a projected text field".to_owned());
4877        }
4878        Ok(Self(kind))
4879    }
4880}
4881
4882impl std::fmt::Display for NonTextFieldType {
4883    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4884        formatter.write_str(&self.0)
4885    }
4886}
4887
4888/// One projected metadata text field's plan, or its verified outcome.
4889///
4890/// The setup creates a missing one as a text field and never alters a field that is there:
4891/// a same-named field of another type is reported in `conflict` and left as it is.
4892#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4893pub struct MetadataFieldReport {
4894    /// The board field's name.
4895    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.
4896    /// The metadata key the value is read from.
4897    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
4898    /// The object keys walked inside that key's value; empty for the key's own value.
4899    pub path: Vec<String>,
4900    /// Whether the board had a text field of that name before the operation.
4901    // 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.
4902    pub exists: bool,
4903    /// The type of a field of that name that is not a text field, which the setup leaves as
4904    /// it is; absent — and left out of the JSON — when there is none.
4905    #[serde(default, skip_serializing_if = "Option::is_none")]
4906    pub conflict: Option<NonTextFieldType>,
4907    /// What the requested operation did: `planned`; `created` for a field that was not
4908    /// there; `unchanged` for one that was, and for a conflict, which is never changed.
4909    pub outcome: FieldOutcome,
4910}
4911
4912/// Which options one field is configured with, in the order a new field would list them.
4913struct FieldPlan {
4914    field: BoardField,
4915    wanted: Vec<String>,
4916}
4917
4918/// One single-select field as the guarded setup snapshots it.
4919#[derive(Debug, Clone, PartialEq, Eq)]
4920struct SnapshotField {
4921    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4922    // passed back as the mutation's field identity; a newtype could enforce no stronger
4923    // invariant because GitHub publishes no grammar for it.
4924    field_id: String,
4925    options: Vec<StatusOption>,
4926}
4927
4928/// Every single-select field of a board and every item's value of each.
4929#[derive(Debug, Clone, PartialEq, Eq)]
4930struct BoardSnapshot {
4931    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4932    // passed back as the mutation's project identity; a newtype could enforce no stronger
4933    // invariant because GitHub publishes no grammar for it.
4934    board_id: String,
4935    fields: BTreeMap<BoardField, SnapshotField>,
4936    /// Each board item's id, and its value of each field this setup owns that it holds one of.
4937    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
4938}
4939
4940impl BoardSnapshot {
4941    /// Every item's value of `field`, in board order — the recovery data a drift refusal
4942    /// carries.
4943    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
4944        self.items
4945            .iter()
4946            .map(|(item_id, values)| StatusAssignment {
4947                item_id: item_id.clone(),
4948                option: values.get(&field).cloned(),
4949            })
4950            .collect()
4951    }
4952}
4953
4954impl GitHubProjectsSource {
4955    /// `content` as this source sends it, with the markup the engine handed it applied.
4956    fn marked(&self, content: &str) -> String {
4957        match self.body_markup.get() {
4958            Some(markup) if !content.is_empty() => markup.written(content),
4959            _ => content.to_owned(),
4960        }
4961    }
4962
4963    /// The visible body and the metadata slot of `body` as [`metadata_body`] splits them,
4964    /// the visible body without the markup [`Self::marked`] applies.
4965    fn visible(
4966        &self,
4967        body: Option<String>,
4968    ) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
4969        let (visible, slot) = metadata_body(body)?;
4970        Ok((
4971            match (visible, self.body_markup.get()) {
4972                (Some(visible), Some(markup)) => {
4973                    Some(markup.read(&visible)).filter(|read| !read.is_empty())
4974                }
4975                (visible, _) => visible,
4976            },
4977            slot,
4978        ))
4979    }
4980
4981    /// Report missing configured Status options and, when `apply` is true, add them with
4982    /// a whole-list mutation that preserves every existing id and verifies the result.
4983    ///
4984    /// # Errors
4985    ///
4986    /// Refuses a board without a single-select `Status` field. A post-write difference in
4987    /// any pre-existing option id or item assignment is refused with the complete pre-write
4988    /// assignment snapshot in the diagnostic for recovery.
4989    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
4990    // successful mutation, both drift refusals, source selection, missing Status, casing,
4991    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
4992    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
4993    // responses from entering the defensive malformed-response branches below.
4994    pub async fn status_options(
4995        &self,
4996        mode: StatusOptionsMode,
4997    ) -> Result<StatusOptionsReport, SourceError> {
4998        let before = self.status_snapshot().await?;
4999        // A terminal category's option is as configured as an open one's: a terminal
5000        // write validates it before closing and refuses when the board lacks it. Both
5001        // kinds' names are options of the one field, so both are asked for.
5002        let missing = self
5003            .statuses
5004            .wanted()
5005            .into_iter()
5006            .filter(|wanted| {
5007                !before
5008                    .options
5009                    .iter()
5010                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5011            })
5012            .collect::<Vec<_>>();
5013        let report = StatusOptionsReport {
5014            source: self.name.clone(),
5015            missing: missing.clone(),
5016            outcome: match (mode, missing.is_empty()) {
5017                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
5018                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
5019                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
5020            },
5021            existing: before.options.clone(),
5022        };
5023        if mode == StatusOptionsMode::Plan || missing.is_empty() {
5024            return Ok(report);
5025        }
5026        let mut options = before
5027            .options
5028            .iter()
5029            .map(|option| {
5030                json!({
5031                    "id": option.id, "name": option.name, "color": option.color,
5032                    "description": option.description,
5033                })
5034            })
5035            .collect::<Vec<_>>();
5036        options.extend(missing.iter().map(|name| {
5037            json!({
5038                "name": name, "color": "GRAY", "description": ""
5039            })
5040        }));
5041        self.graphql(
5042            graphql::STATUS_OPTIONS_UPDATE,
5043            json!({"input": {
5044                "fieldId": before.field_id,
5045                "singleSelectOptions": options,
5046            }}),
5047        )
5048        .await?;
5049        let after = self.status_snapshot().await?;
5050        let options_preserved = before
5051            .options
5052            .iter()
5053            .all(|old| after.options.iter().any(|new| new == old));
5054        let additions_present = missing.iter().all(|wanted| {
5055            after
5056                .options
5057                .iter()
5058                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5059        });
5060        if !options_preserved || !additions_present || after.assignments != before.assignments {
5061            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
5062                SourceError::Malformed {
5063                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
5064                }
5065            })?;
5066            return Err(SourceError::Refused {
5067                message: format!(
5068                    "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}"
5069                ),
5070            });
5071        }
5072        Ok(report)
5073    }
5074
5075    /// A fresh snapshot of the Status field and every board item's assignment of it.
5076    ///
5077    /// # Errors
5078    ///
5079    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
5080    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
5081        // Status alone, as this operation has always read it: a `Priority` field is another
5082        // operation's, so nothing about it can refuse this one.
5083        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
5084        let field = board
5085            .fields
5086            .remove(&BoardField::Status)
5087            .ok_or_else(|| self.no_status_field())?;
5088        Ok(StatusSnapshot {
5089            assignments: board.assignments(BoardField::Status),
5090            board_id: board.board_id,
5091            field_id: field.field_id,
5092            options: field.options,
5093        })
5094    }
5095
5096    /// The refusal a board with no `Status` field is answered with by the guarded setup.
5097    fn no_status_field(&self) -> SourceError {
5098        SourceError::Refused {
5099            message: format!("source {} board has no Status field", self.name),
5100        }
5101    }
5102
5103    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
5104    // the real CLI loopback journey, including pagination. The individual malformed guards
5105    // are defensive validation of a schema-pinned third-party response, not separate user
5106    // journeys; drift and missing-field failures cover the operation's recovery behavior.
5107    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
5108    /// every board item's value of each, walked to the end of the board's items. A field not
5109    /// in `owned` is read past whatever it holds.
5110    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
5111        let mut after: Option<String> = None;
5112        let mut snapshot: Option<BoardSnapshot> = None;
5113        loop {
5114            let data = self
5115                .graphql(
5116                    graphql::STATUS_OPTIONS_SNAPSHOT,
5117                    json!({
5118                        "owner": self.owner, "number": self.project_number,
5119                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
5120                    }),
5121                )
5122                .await?;
5123            let board = data
5124                .pointer("/owner/projectV2")
5125                .filter(|board| board.is_object())
5126                .ok_or_else(|| SourceError::Refused {
5127                    message: format!(
5128                        "source {} has no accessible GitHub Projects board",
5129                        self.name
5130                    ),
5131                })?;
5132            if board
5133                .pointer("/fields/pageInfo/hasNextPage")
5134                .and_then(Value::as_bool)
5135                != Some(false)
5136            {
5137                return Err(SourceError::Malformed {
5138                    message:
5139                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
5140                            .into(),
5141                });
5142            }
5143            let mut fields = BTreeMap::new();
5144            // Only the fields this setup owns, by name: a node the single-select fragment did not
5145            // match carries no name, and a person's own single-select field — a `Size`, a
5146            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
5147            // `Status` or `Priority` field without its options is malformed, not absent.
5148            // 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.
5149            for (owned, field) in board
5150                .pointer("/fields/nodes")
5151                .and_then(Value::as_array)
5152                .ok_or_else(|| SourceError::Malformed {
5153                    message: "GitHub project fields.nodes is not an array".into(),
5154                })?
5155                .iter()
5156                .filter_map(|field| {
5157                    let named = BoardField::named(field.get("name")?.as_str()?)?;
5158                    owned.contains(&named).then_some((named, field))
5159                })
5160            {
5161                let options = field
5162                    .get("options")
5163                    .and_then(Value::as_array)
5164                    .ok_or_else(|| SourceError::Malformed {
5165                        message: "GitHub single-select field options is not an array".into(),
5166                    })?
5167                    .iter()
5168                    .map(|option| {
5169                        Ok(StatusOption {
5170                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
5171                                .map_err(|message| SourceError::Malformed { message })?,
5172                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
5173                                .map_err(|message| SourceError::Malformed {
5174                                    message: format!(
5175                                        "GitHub single-select option name is invalid: {message}"
5176                                    ),
5177                                })?,
5178                            color: serde_json::from_value(
5179                                option.get("color").cloned().unwrap_or(Value::Null),
5180                            )
5181                            .map_err(|error| {
5182                                SourceError::Malformed {
5183                                    message: format!(
5184                                        "GitHub single-select option color is invalid: {error}"
5185                                    ),
5186                                }
5187                            })?,
5188                            description: optional_str(option, "description")?
5189                                .unwrap_or_default()
5190                                .to_owned(),
5191                        })
5192                    })
5193                    .collect::<Result<Vec<_>, SourceError>>()?;
5194                let snapshot = SnapshotField {
5195                    field_id: required_nonblank_str(field, "id")?.to_owned(),
5196                    options,
5197                };
5198                // A board's field names are unique, so a second one is an answer that cannot
5199                // say which field the setup would act on — refused rather than one chosen.
5200                if fields.insert(owned, snapshot).is_some() {
5201                    return Err(SourceError::Malformed {
5202                        message: format!(
5203                            "GitHub answered two {} fields for this board",
5204                            owned.name()
5205                        ),
5206                    });
5207                }
5208            }
5209            let board_id = required_nonblank_str(board, "id")?.to_owned();
5210            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
5211                board_id,
5212                fields,
5213                items: Vec::new(),
5214            });
5215            let items = board
5216                .pointer("/items/nodes")
5217                .and_then(Value::as_array)
5218                .ok_or_else(|| SourceError::Malformed {
5219                    message: "GitHub project items.nodes is not an array".into(),
5220                })?;
5221            for item in items {
5222                let field_values =
5223                    item.get("fieldValues")
5224                        .ok_or_else(|| SourceError::Malformed {
5225                            message: "GitHub project item is missing fieldValues".into(),
5226                        })?;
5227                if field_values
5228                    .pointer("/pageInfo/hasNextPage")
5229                    .and_then(Value::as_bool)
5230                    != Some(false)
5231                {
5232                    return Err(SourceError::Malformed {
5233                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
5234                    });
5235                }
5236                let values = item
5237                    .pointer("/fieldValues/nodes")
5238                    .and_then(Value::as_array)
5239                    .ok_or_else(|| SourceError::Malformed {
5240                        message: "GitHub project item fieldValues.nodes is not an array".into(),
5241                    })?;
5242                let item_id = required_nonblank_str(item, "id")?;
5243                let mut assigned = BTreeMap::new();
5244                for value in values {
5245                    let Some(field) = value
5246                        .pointer("/field/name")
5247                        .and_then(Value::as_str)
5248                        .and_then(BoardField::named)
5249                        .filter(|field| owned.contains(field))
5250                    else {
5251                        continue;
5252                    };
5253                    let held = assigned.insert(
5254                        field,
5255                        AssignedStatusOption {
5256                            id: StatusOptionId::try_from(
5257                                required_str(value, "optionId")?.to_owned(),
5258                            )
5259                            .map_err(|message| SourceError::Malformed { message })?,
5260                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
5261                                .map_err(|message| SourceError::Malformed {
5262                                    message: format!(
5263                                        "GitHub assigned {} name is invalid: {message}",
5264                                        field.name()
5265                                    ),
5266                                })?,
5267                        },
5268                    );
5269                    // An item holds one value of a field, so a second one leaves no way to
5270                    // tell which it holds — and a verification or recovery built on either
5271                    // could restore the wrong one.
5272                    if held.is_some() {
5273                        return Err(SourceError::Malformed {
5274                            message: format!(
5275                                "GitHub answered two {} values for board item {item_id}",
5276                                field.name()
5277                            ),
5278                        });
5279                    }
5280                }
5281                current.items.push((item_id.to_owned(), assigned));
5282            }
5283            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
5284                message: "GitHub project is missing items".into(),
5285            })?;
5286            let has_next = page
5287                .pointer("/pageInfo/hasNextPage")
5288                .and_then(Value::as_bool)
5289                .ok_or_else(|| SourceError::Malformed {
5290                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
5291                })?;
5292            if !has_next {
5293                break;
5294            }
5295            let next =
5296                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
5297            validate_cursor_progress(after.as_deref(), next)?;
5298            after = Some(next.to_owned());
5299        }
5300        snapshot.ok_or_else(|| SourceError::Malformed {
5301            message: "GitHub returned no board field snapshot".into(),
5302        })
5303    }
5304    // llmlint: ignore-end[changed_behavior_has_e2e]
5305
5306    /// Report every board field this source's configuration names and, with
5307    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
5308    /// the `Priority` field when the board has none.
5309    ///
5310    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
5311    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
5312    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
5313    /// color and description: the whole option list goes back with every existing id, because
5314    /// a re-minted id clears every item's value.
5315    ///
5316    /// # Errors
5317    ///
5318    /// Refuses a board without a single-select `Status` field. After an apply the board is
5319    /// read again, and a pre-existing option or any item's value of either field that moved is
5320    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
5321    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
5322    // unchanged apply, a created field, an added option to each field, drift refusal, a board
5323    // with no Status field and a non-github-projects source through the compiled CLI against
5324    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
5325    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
5326        let owned: Vec<BoardField> = if self.priorities.is_some() {
5327            vec![BoardField::Status, BoardField::Priority]
5328        } else {
5329            vec![BoardField::Status]
5330        };
5331        let before = self.board_snapshot(&owned).await?;
5332        let mut plans = vec![FieldPlan {
5333            field: BoardField::Status,
5334            wanted: self.statuses.wanted(),
5335        }];
5336        if !before.fields.contains_key(&BoardField::Status) {
5337            return Err(self.no_status_field());
5338        }
5339        if let Some(mapping) = &self.priorities {
5340            plans.push(FieldPlan {
5341                field: BoardField::Priority,
5342                wanted: mapping.names().map(str::to_owned).collect(),
5343            });
5344        }
5345        // The snapshot reads single-select fields alone, so a field it did not find may still
5346        // be on the board under the name, of another type: creating one beside it would fail
5347        // part way, or leave two fields of one name. Asked of the board's own field list, and
5348        // only when a field is missing.
5349        if plans
5350            .iter()
5351            .any(|plan| !before.fields.contains_key(&plan.field))
5352        {
5353            let board = self.board_fields().await?;
5354            for plan in plans
5355                .iter()
5356                .filter(|plan| !before.fields.contains_key(&plan.field))
5357            {
5358                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
5359                    return Err(SourceError::Refused {
5360                        message: format!(
5361                            "source {}'s board has a {} field that is not a single-select field \
5362                             (it is a {}), so it cannot hold this source's options; next: rename \
5363                             or remove that field, then run this again",
5364                            self.name,
5365                            plan.field.name(),
5366                            optional_str(field, "__typename")?.unwrap_or("field of another type")
5367                        ),
5368                    });
5369                }
5370            }
5371        }
5372        let mut reports = Vec::new();
5373        for plan in &plans {
5374            let held = before.fields.get(&plan.field);
5375            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
5376            let mut missing: Vec<String> = Vec::new();
5377            for wanted in &plan.wanted {
5378                let present = existing
5379                    .iter()
5380                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5381                    || missing
5382                        .iter()
5383                        .any(|named| named.eq_ignore_ascii_case(wanted));
5384                if !present {
5385                    missing.push(wanted.clone());
5386                }
5387            }
5388            let kinds = match plan.field {
5389                BoardField::Status => self.statuses.missing_by_kind(&existing),
5390                BoardField::Priority => Vec::new(),
5391            };
5392            reports.push(FieldReport {
5393                field: plan.field,
5394                exists: held.is_some(),
5395                kinds,
5396                outcome: match (mode, held.is_some(), missing.is_empty()) {
5397                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
5398                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
5399                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
5400                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
5401                },
5402                missing,
5403                existing,
5404            });
5405        }
5406        // A projected field is a text field, which the single-select snapshot cannot see, so
5407        // it is asked of the board's own field list — and only when one is configured.
5408        let mut metadata_fields = Vec::new();
5409        if !self.metadata_fields.is_empty() {
5410            let board = self.board_fields().await?;
5411            for projection in &self.metadata_fields {
5412                let conflict = match Board::field(&board.fields, &projection.field)? {
5413                    None => None,
5414                    Some(field) => {
5415                        let typename = optional_str(field, "__typename")?;
5416                        let data_type = optional_str(field, "dataType")?;
5417                        if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
5418                            None
5419                        } else {
5420                            let named = data_type
5421                                .filter(|kind| *kind != "TEXT")
5422                                .or(typename)
5423                                .unwrap_or("field of another type");
5424                            Some(NonTextFieldType::try_from(named.to_owned()).map_err(
5425                                |message| SourceError::Malformed {
5426                                    message: format!(
5427                                        "GitHub answered the board's {} field with a type this \
5428                                         setup cannot read: {message}",
5429                                        &*projection.field
5430                                    ),
5431                                },
5432                            )?)
5433                        }
5434                    }
5435                };
5436                let exists =
5437                    conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
5438                metadata_fields.push(MetadataFieldReport {
5439                    field: projection.field.to_string(),
5440                    key: projection.key.to_string(),
5441                    path: projection.path.clone(),
5442                    exists,
5443                    outcome: match (mode, exists || conflict.is_some()) {
5444                        (SetupMode::Plan, _) => FieldOutcome::Planned,
5445                        (SetupMode::Apply, true) => FieldOutcome::Unchanged,
5446                        (SetupMode::Apply, false) => FieldOutcome::Created,
5447                    },
5448                    conflict,
5449                });
5450            }
5451        }
5452        let report = FieldsReport {
5453            source: self.name.clone(),
5454            fields: reports,
5455            metadata_fields,
5456        };
5457        let writes: Vec<&FieldReport> = report
5458            .fields
5459            .iter()
5460            .filter(|field| !field.missing.is_empty() || !field.exists)
5461            .collect();
5462        let creates: Vec<&MetadataFieldReport> = report
5463            .metadata_fields
5464            .iter()
5465            .filter(|field| field.outcome == FieldOutcome::Created)
5466            .collect();
5467        if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
5468            return Ok(report);
5469        }
5470        let mut landed: Vec<&str> = Vec::new();
5471        for field in &writes {
5472            let added = field
5473                .missing
5474                .iter()
5475                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
5476            let sent = match before.fields.get(&field.field) {
5477                Some(held) => {
5478                    let mut options = held
5479                        .options
5480                        .iter()
5481                        .map(|option| {
5482                            json!({
5483                                "id": option.id, "name": option.name, "color": option.color,
5484                                "description": option.description,
5485                            })
5486                        })
5487                        .collect::<Vec<_>>();
5488                    options.extend(added);
5489                    self.graphql(
5490                        graphql::STATUS_OPTIONS_UPDATE,
5491                        json!({"input": {
5492                            "fieldId": held.field_id,
5493                            "singleSelectOptions": options,
5494                        }}),
5495                    )
5496                    .await
5497                }
5498                None => {
5499                    self.graphql(
5500                        graphql::CREATE_FIELD,
5501                        json!({"input": {
5502                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
5503                            "name": field.field.name(),
5504                            "singleSelectOptions": added.collect::<Vec<_>>(),
5505                        }}),
5506                    )
5507                    .await
5508                }
5509            };
5510            // A mutation that failed does not establish that GitHub left its field as it was,
5511            // so every failure from here on carries the recovery data a drift refusal does.
5512            match sent {
5513                Ok(_) => landed.push(field.field.name()),
5514                Err(error) => {
5515                    let changed = if landed.is_empty() {
5516                        String::new()
5517                    } else {
5518                        format!("changed the {} field and then ", landed.join(" and "))
5519                    };
5520                    return Err(SourceError::Refused {
5521                        message: format!(
5522                            "the guarded field setup {changed}failed on the {} field, which it may \
5523                             have changed part way: {error}; the pre-write item assignments \
5524                             are:\n{}",
5525                            field.field.name(),
5526                            recovery(&report, &before)?
5527                        ),
5528                    });
5529                }
5530            }
5531        }
5532        for field in &creates {
5533            let sent = self
5534                .graphql(
5535                    graphql::CREATE_FIELD,
5536                    json!({"input": {
5537                        "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
5538                    }}),
5539                )
5540                .await;
5541            if let Err(error) = sent {
5542                let changed = if landed.is_empty() {
5543                    String::new()
5544                } else {
5545                    format!("changed the {} field and then ", landed.join(" and "))
5546                };
5547                return Err(SourceError::Refused {
5548                    message: format!(
5549                        "the guarded field setup {changed}failed creating the {} text field: \
5550                         {error}; run it again to create what is still missing",
5551                        field.field
5552                    ),
5553                });
5554            }
5555            landed.push(&field.field);
5556        }
5557        // The board has been written, so a verification read that fails leaves it unverified
5558        // rather than unchanged, and says what to put back.
5559        let after = match self.board_snapshot(&owned).await {
5560            Ok(after) => after,
5561            Err(error) => {
5562                return Err(SourceError::Refused {
5563                    message: format!(
5564                        "the guarded field setup changed the {} field and then could not read the \
5565                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
5566                        landed.join(" and "),
5567                        recovery(&report, &before)?
5568                    ),
5569                });
5570            }
5571        };
5572        let mut moved = Vec::new();
5573        for field in &report.fields {
5574            let name = field.field.name();
5575            let now = after
5576                .fields
5577                .get(&field.field)
5578                .map(|held| held.options.as_slice())
5579                .unwrap_or_default();
5580            if !field.existing.iter().all(|old| now.contains(old)) {
5581                moved.push(format!(
5582                    "a pre-existing {name} option id, name, color or description"
5583                ));
5584            }
5585            if !field.missing.iter().all(|wanted| {
5586                now.iter()
5587                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5588            }) {
5589                moved.push(format!("an added {name} option"));
5590            }
5591            if after.assignments(field.field) != before.assignments(field.field) {
5592                moved.push(format!("an item's {name} value"));
5593            }
5594        }
5595        if !creates.is_empty() {
5596            // Read afresh: the view held for this command is the one from before the creates.
5597            *self.fields_cache()? = None;
5598            let board = match self.board_fields().await {
5599                Ok(board) => board,
5600                Err(error) => {
5601                    return Err(SourceError::Refused {
5602                        message: format!(
5603                            "the guarded field setup changed the {} field and then could not \
5604                             read the board back to verify it: {error}; run it again to verify \
5605                             it, which creates nothing a second time",
5606                            landed.join(" and ")
5607                        ),
5608                    });
5609                }
5610            };
5611            for field in &creates {
5612                let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
5613                    held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
5614                        && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
5615                });
5616                if !text {
5617                    moved.push(format!("the created {} text field", field.field));
5618                }
5619            }
5620        }
5621        if !moved.is_empty() {
5622            return Err(SourceError::Refused {
5623                message: format!(
5624                    "GitHub changed {} after the guarded field setup; the pre-write item \
5625                     assignments are:\n{}",
5626                    moved.join(", "),
5627                    recovery(&report, &before)?
5628                ),
5629            });
5630        }
5631        Ok(report)
5632    }
5633
5634    /// Validate configuration and capture the named credential without exposing it.
5635    ///
5636    /// # Errors
5637    ///
5638    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
5639    /// [`SourceError::Auth`] when the named credential is missing or empty.
5640    pub fn new(
5641        name: &SourceName,
5642        config: GitHubProjectsConfig,
5643        secrets: &dyn SecretResolver,
5644    ) -> Result<Self, SourceError> {
5645        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
5646    }
5647
5648    /// The same, recording every request it sends into an accounting the caller holds too.
5649    ///
5650    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
5651    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
5652    /// up — passes the one it records those into, so the session total accounts for the
5653    /// whole session rather than for this source's share of it.
5654    ///
5655    /// # Errors
5656    ///
5657    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
5658    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
5659    pub fn recording_into(
5660        name: &SourceName,
5661        config: GitHubProjectsConfig,
5662        secrets: &dyn SecretResolver,
5663        ledger: Arc<Accounting>,
5664    ) -> Result<Self, SourceError> {
5665        if !valid_github_owner(&config.owner) {
5666            return Err(SourceError::Config {
5667                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
5668            });
5669        }
5670        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
5671            return Err(SourceError::Config {
5672                message: format!("project_number must be between 1 and {}", i32::MAX),
5673            });
5674        }
5675        if !valid_environment_name(&config.token_env) {
5676            return Err(SourceError::Config {
5677                message: "token_env must be a valid environment-variable name".into(),
5678            });
5679        }
5680        let repository = config
5681            .repository
5682            .as_deref()
5683            .map(RepositoryTarget::parse)
5684            .transpose()?;
5685        if let Some(entry) = config
5686            .creation_owners
5687            .iter()
5688            .find(|entry| !valid_github_owner(entry))
5689        {
5690            return Err(SourceError::Config {
5691                message: format!(
5692                    "creation_owners entry {entry:?} is not a GitHub login: a login is 1-39 ASCII \
5693                     letters, digits, or single hyphens, and cannot start or end with a hyphen"
5694                ),
5695            });
5696        }
5697        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
5698            message: format!("endpoint is not a valid URL: {e}"),
5699        })?;
5700        if endpoint.scheme() != "https"
5701            && !(endpoint.scheme() == "http"
5702                && endpoint
5703                    .host_str()
5704                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
5705        {
5706            return Err(SourceError::Config {
5707                message:
5708                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
5709                        .into(),
5710            });
5711        }
5712        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
5713            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),
5714        })?;
5715        Ok(Self {
5716            name: name.clone(),
5717            owner: config.owner,
5718            project_number: config.project_number,
5719            repository,
5720            creation_owners: config.creation_owners,
5721            asset_client: std::sync::OnceLock::new(),
5722            body_markup: std::sync::OnceLock::new(),
5723            endpoint,
5724            token,
5725            credential_name: config.token_env,
5726            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
5727            priorities: config
5728                .priority_mapping
5729                .map(|mapping| PriorityMapping::resolve(mapping, name))
5730                .transpose()?,
5731            metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
5732            sub_issues: config.sub_issues,
5733            link_types: onetaskgraph_plugin_api::LinkTypes::checked(
5734                KIND,
5735                &config.link_types,
5736                &[relations::RELATES_TO],
5737                relations::REFUSED_WORDS,
5738            )
5739            .map_err(|error| match error {
5740                SourceError::Config { message } => SourceError::Config {
5741                    message: format!("source {name}: {message}"),
5742                },
5743                other => other,
5744            })?,
5745            client: std::sync::OnceLock::new(),
5746            created: Mutex::new(Vec::new()),
5747            updated: Mutex::new(Vec::new()),
5748            commented: Mutex::new(Vec::new()),
5749            pacing: Pacing::resolve(config.pacing, name)?,
5750            last_mutation: Mutex::new(None),
5751            last_served: Mutex::new(None),
5752            windows: Mutex::new(pacing::Windows::default()),
5753            task_children: Mutex::new(BTreeMap::new()),
5754            written: Mutex::new(BTreeMap::new()),
5755            deferred: Mutex::new(None),
5756            held_updates: Mutex::new(Vec::new()),
5757            held_comments: Mutex::new(BTreeMap::new()),
5758            task_writes: Mutex::new(None),
5759            written_tokens: Mutex::new(BTreeMap::new()),
5760            clock: system_clock(),
5761            declared_private: std::sync::atomic::AtomicBool::new(false),
5762            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
5763            board_cache: Mutex::new(None),
5764            search_cache: Mutex::new(None),
5765            narrowed_cache: Mutex::new(BTreeMap::new()),
5766            resolved_cache: Mutex::new(BTreeMap::new()),
5767            read_ahead: Mutex::new(BTreeMap::new()),
5768            pending_origins: Mutex::new(Vec::new()),
5769            member_classes: Mutex::new(BTreeMap::new()),
5770            carried: Mutex::new(BTreeMap::new()),
5771            held_cache: Mutex::new(BTreeMap::new()),
5772            children_cache: Mutex::new(BTreeMap::new()),
5773            search_next: Mutex::new(BTreeMap::new()),
5774            fields_cache: Mutex::new(None),
5775            repository_cache: Mutex::new(BTreeMap::new()),
5776            ledger,
5777            expected: expected::Held::default(),
5778            projection: projection::Held::default(),
5779        })
5780    }
5781
5782    /// The client every API request goes through, built on the first one.
5783    ///
5784    /// Not with the source: building a client loads the system's certificate store, which
5785    /// costs tens of milliseconds, and a command that configures this board but never sends to
5786    /// it — a read of another source — should not pay that in its process.
5787    fn client(&self) -> Result<&Client, SourceError> {
5788        built(&self.client, || {
5789            Client::builder()
5790                .user_agent("onetaskgraph")
5791                .build()
5792                .map_err(|e| SourceError::Config {
5793                    message: format!("cannot build HTTP client: {e}"),
5794                })
5795        })
5796    }
5797
5798    /// The client attachment requests go through, with its own redirect policy, built on the
5799    /// first one for the reason [`Self::client`] gives.
5800    fn asset_client(&self) -> Result<&Client, SourceError> {
5801        built(&self.asset_client, || assets::client(&self.endpoint))
5802    }
5803
5804    /// A snapshot of every request this source has sent, and what each cost.
5805    ///
5806    /// A value to hold and compare rather than a borrow of the accounting itself, so two
5807    /// of them can sit side by side. When this source was built with
5808    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
5809    /// point of building it that way.
5810    #[must_use]
5811    pub fn accounting(&self) -> accounting::Session {
5812        self.ledger.snapshot()
5813    }
5814
5815    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
5816    /// rate limit rather than handing it straight back as an error.
5817    ///
5818    /// Retrying is safe for every document here, including the mutations, and the reason
5819    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
5820    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
5821    /// this replays has already taken effect. An outcome this source cannot know — the
5822    /// send failed, or the body could not be read, so the mutation may well have landed —
5823    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
5824    /// attempt. A duplicate write would come from replaying one of those, and none is
5825    /// replayed.
5826    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5827        // Updates held back go out before any other request, so whatever it reads or writes
5828        // sees the board as though each had been sent when it was made.
5829        Box::pin(self.send_held_updates()).await?;
5830        // Origins held back for a read ahead that never came are sent before anything else.
5831        if query != graphql::ORIGIN_LOOKUPS && query != graphql::ORIGIN_LOOKUPS_WITH_MEMBERS {
5832            Box::pin(self.send_pending_origins(None)).await?;
5833        }
5834        let journaling = self.expected.journaling();
5835        let spelling = self.spelling();
5836        if !journaling && !spelling {
5837            return self.graphql_sent(query, variables).await;
5838        }
5839        // While a write the store's cache expects runs, its mutations select the whole issue
5840        // they leave behind, so the response vouches for what the cache keeps — and every
5841        // request is recorded, so the write's parts are reported from their own responses.
5842        // A targeted update sends a twin of each selecting the issue's token parts inline, at
5843        // the same price, so the version it answers is the one its own last response spells.
5844        let (query, variables) = match (journaling, graphql::whole_twin(query)) {
5845            (true, Some(twin)) => (twin, expected::whole_variables(variables)),
5846            _ => match graphql::token_twin(query).filter(|_| spelling) {
5847                Some(twin) => (twin, token_variables(variables)),
5848                None => (query, variables),
5849            },
5850        };
5851        let sent = self.graphql_sent(query, variables.clone()).await;
5852        if spelling && let Ok(data) = &sent {
5853            self.record_written_token(query, &variables, data);
5854        }
5855        if journaling {
5856            self.journal(query, &variables, &sent).await;
5857        }
5858        sent
5859    }
5860
5861    /// Whether a targeted update of this source is running, whose mutations are each sent as
5862    /// the twin selecting the whole issue (see [`Self::written_tokens`]).
5863    fn spelling(&self) -> bool {
5864        SPELLING
5865            .try_with(|source| *source == std::ptr::from_ref(self) as usize)
5866            .unwrap_or(false)
5867    }
5868
5869    /// Record the change token the issue a mutation's response shows whole holds now, as
5870    /// [`TaskSource::written_task`] answers it; a response showing no whole issue records
5871    /// nothing.
5872    fn record_written_token(&self, document: &str, variables: &Value, data: &Value) {
5873        let (issue, _) = expected::read_back(document, variables, data);
5874        if let Some(issue) = issue {
5875            self.record_written_issue(issue);
5876        }
5877    }
5878
5879    /// Record the change token `issue`, as a write's own answer shows it whole, holds now — or,
5880    /// where that answer lacks a part of one, that it was shown and spells none.
5881    fn record_written_issue(&self, issue: &Value) {
5882        // An answer naming the issue by its id alone did not show it, so it says nothing.
5883        if issue.get("updatedAt").is_none() {
5884            return;
5885        }
5886        let Some(id) = issue.get("id").and_then(Value::as_str) else {
5887            return;
5888        };
5889        let (item, token) = match self.written_token_of(issue) {
5890            Some((item, token)) => (item, Some(token)),
5891            None => {
5892                // Answered whole, but without every part of a token: its board item's id, where
5893                // it says one, so a later write naming that item still drops the entry.
5894                let item = issue
5895                    .pointer("/projectItems/nodes")
5896                    .and_then(Value::as_array)
5897                    .and_then(|nodes| self.board_entry(nodes))
5898                    .and_then(|entry| entry.get("id"))
5899                    .and_then(Value::as_str)
5900                    .unwrap_or_default()
5901                    .to_owned();
5902                (item, None)
5903            }
5904        };
5905        if let Ok(mut tokens) = self.written_tokens.lock() {
5906            tokens.insert(NativeId(id.to_owned()), (item, token));
5907        }
5908    }
5909
5910    /// Record `token` as the change token the task `item` holds after this command's write.
5911    fn remember_written_token(&self, item: &Resolved, token: String) {
5912        if let Ok(mut tokens) = self.written_tokens.lock() {
5913            tokens.insert(item.id.clone(), (item.item_id.clone(), Some(token)));
5914        }
5915    }
5916
5917    /// The change token the task `id` holds after this command's latest write of it, when that
5918    /// write's own response — or the read made right after it — showed one.
5919    fn written_token(&self, id: &NativeId) -> Option<String> {
5920        self.written_answer(id).flatten()
5921    }
5922
5923    /// What this command's latest write of the task `id` showed of it: `Some` once a response
5924    /// showed the issue whole — its token, or `None` where that response could not spell one —
5925    /// and `None` where no response did.
5926    fn written_answer(&self, id: &NativeId) -> Option<Option<String>> {
5927        self.written_tokens
5928            .lock()
5929            .ok()?
5930            .get(id)
5931            .map(|(_, token)| token.clone())
5932    }
5933
5934    /// Drop the written token of every task `variables` names, by its id or its board item's,
5935    /// before a mutation that may move it is sent.
5936    fn forget_written_tokens(&self, variables: &Value) {
5937        let Ok(mut tokens) = self.written_tokens.lock() else {
5938            return;
5939        };
5940        if tokens.is_empty() {
5941            return;
5942        }
5943        let named: Vec<&str> = variables
5944            .as_object()
5945            .into_iter()
5946            .flat_map(|arguments| arguments.values())
5947            .flat_map(|input| {
5948                WRITTEN_NAMES
5949                    .iter()
5950                    .filter_map(|key| input.get(key).and_then(Value::as_str))
5951            })
5952            .collect();
5953        tokens.retain(|id, (item, _)| {
5954            !named
5955                .iter()
5956                .any(|name| *name == id.0 || *name == item.as_str())
5957        });
5958    }
5959
5960    /// One document sent, retried while GitHub asks for a wait, exactly as asked.
5961    async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5962        if is_mutation(query) {
5963            self.forget_written_tokens(&variables);
5964        }
5965        if is_mutation(query)
5966            && ![
5967                graphql::ADD_COMMENT,
5968                graphql::UPDATE_COMMENT,
5969                graphql::DELETE_COMMENT,
5970                graphql::ADD_COMMENT_WHOLE,
5971                graphql::UPDATE_COMMENT_WHOLE,
5972            ]
5973            .contains(&query)
5974        {
5975            let mut cache = self.resolved_cache()?;
5976            let mut ahead = self.read_ahead()?;
5977            let mut carried = self.carried()?;
5978            for argument in ["input", "second", "third", "clear"] {
5979                if let Some(input) = variables.get(argument) {
5980                    let names = |id: &NativeId, item_id: Option<&str>| {
5981                        ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
5982                            input
5983                                .get(key)
5984                                .and_then(Value::as_str)
5985                                .is_some_and(|value| value == id.0 || Some(value) == item_id)
5986                        })
5987                    };
5988                    cache.retain(|id, item| !names(id, Some(&item.item_id)));
5989                    ahead.retain(|id, item| {
5990                        !names(id, item.as_ref().map(|item| item.item_id.as_str()))
5991                    });
5992                    carried.retain(|id, item| !names(id, Some(&item.item_id)));
5993                }
5994            }
5995        }
5996        let doing = operation_description(query);
5997        let mutation = is_mutation(query);
5998        let charge = pacing::Charge::graphql(query, &variables);
5999        let mut waited = Duration::ZERO;
6000        let mut waits = 0_u32;
6001        let mut backoff = self.pacing.retry_backoff;
6002        loop {
6003            let ticket = self.pace(charge, mutation).await;
6004            // Every write but a create is asked for immediately before it is sent — a create's
6005            // fields, its relationships, its content and the delete that takes it back included
6006            // — so a copy that owns the create renews its hold first, and one that has lost it
6007            // sends nothing more. Asked again before a retry, which is sent later.
6008            if mutation
6009                && !query.contains("createIssue(")
6010                && let Err(refused) = <dyn onetaskgraph_plugin_api::WriteGuard>::check().await
6011            {
6012                self.paced(ticket, mutation);
6013                return Err(refused);
6014            }
6015            let attempt = self.send_once(query, &variables, waits + 1).await;
6016            self.paced(ticket, mutation);
6017            let remaining = self.pacing.retry_budget.saturating_sub(waited);
6018            let (wait, cause) = match attempt {
6019                Ok(data) => return Ok(data),
6020                Err(Attempt::Failed(error)) => return Err(error),
6021                Err(Attempt::Conflicted(said)) => {
6022                    // A create is never replayed: see `CONFLICT_WORDINGS`.
6023                    if query.contains("createIssue(") || backoff.is_zero() || backoff > remaining {
6024                        return Err(SourceError::Unavailable {
6025                            message: format!(
6026                                "GitHub refused this source while {doing} as a temporary \
6027                                 conflict ({said}); it retried {} over {} and the conflict \
6028                                 did not clear within the {} one call may spend waiting. \
6029                                 next: run the command again once nothing else is writing to \
6030                                 the same items",
6031                                plural(waits, "time"),
6032                                seconds(waited),
6033                                seconds(self.pacing.retry_budget),
6034                            ),
6035                        });
6036                    }
6037                    (backoff, pacing::cause::BACKOFF)
6038                }
6039                Err(Attempt::Limited(limited)) => {
6040                    let (wait, cause) = limited.wait(backoff);
6041                    // A wait of nothing spends none of the budget, so it is exhaustion rather
6042                    // than a retry. `Pacing::resolve` rules out every way of configuring one
6043                    // except a budget of zero, where reporting the first refusal is the ask.
6044                    if wait.is_zero() || wait > remaining {
6045                        return Err(limited.exhausted(
6046                            doing,
6047                            waits,
6048                            waited,
6049                            wait,
6050                            self.pacing.retry_budget,
6051                        ));
6052                    }
6053                    (wait, cause)
6054                }
6055            };
6056            self.clock.sleep_because(wait, cause).await;
6057            waited += wait;
6058            waits += 1;
6059            backoff = backoff.saturating_mul(2);
6060        }
6061    }
6062
6063    /// Hold one request charging `charge` until every secondary window it charges has room for
6064    /// the whole of it — and, for a mutation of an instance configured with
6065    /// `pacing.min_mutation_interval_ms`, until that interval has passed since the last one
6066    /// finished — then count it, answering the ticket [`Self::paced`] finishes it with.
6067    ///
6068    /// The moment is decided and counted under the lock and waited out outside it, so two
6069    /// callers take two places rather than the same one, and no lock is held across an await.
6070    /// A request with room goes out at once: nothing here waits that a window does not ask for.
6071    async fn pace(&self, charge: pacing::Charge, mutation: bool) -> pacing::Ticket {
6072        let (wait, cause, ticket) = {
6073            // A poisoned lock here costs pacing, not correctness, and refusing the write over
6074            // it would turn an earlier failure into a second one for no gain.
6075            let mut windows = self
6076                .windows
6077                .lock()
6078                .unwrap_or_else(std::sync::PoisonError::into_inner);
6079            let now = self.clock.now();
6080            let (mut at, mut cause) = if self.pacing.windows {
6081                windows.room(now, charge)
6082            } else {
6083                (now, None)
6084            };
6085            if mutation && !self.pacing.min_mutation_interval.is_zero() {
6086                let mut last = self
6087                    .last_mutation
6088                    .lock()
6089                    .unwrap_or_else(std::sync::PoisonError::into_inner);
6090                // `checked_add` rather than `+`: adding durations can panic on overflow, and
6091                // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
6092                if let Some(earliest) = last
6093                    .and_then(|previous| previous.checked_add(self.pacing.min_mutation_interval))
6094                    && earliest > at
6095                {
6096                    at = earliest;
6097                    cause = Some(pacing::cause::MIN_INTERVAL);
6098                }
6099                *last = Some(at);
6100            }
6101            let ticket = windows.charge(at, charge);
6102            (at.saturating_sub(now), cause, ticket)
6103        };
6104        if let (false, Some(cause)) = (wait.is_zero(), cause) {
6105            self.clock.sleep_because(wait, cause).await;
6106        }
6107        ticket
6108    }
6109
6110    /// Record that the request `ticket` was charged for has finished, so each window — and
6111    /// the configured spacing — counts it from here rather than from the moment it left.
6112    ///
6113    /// This source can only choose when a request *departs*; GitHub's limiter counts when it
6114    /// *arrives*, and the two differ by whatever the request spent in transit. Counting from
6115    /// departure would hand the limiter a window that looked roomier than it is, so a source
6116    /// pacing exactly at a limit could still be seen arriving over it — which is how a copy
6117    /// paced well inside a board's threshold was once refused by it on a slower machine while
6118    /// passing on a quick one. The previous request had arrived before its response came back,
6119    /// so counting from here is never earlier than the limiter does, whatever transit costs
6120    /// and on whatever platform: the safe side of a limit that punishes being wrong by refusing
6121    /// reads for the next fifty minutes.
6122    ///
6123    /// A failed attempt is finished too: a request refused by the limiter still arrived, and
6124    /// one that never left costs only room nobody needed.
6125    fn paced(&self, ticket: pacing::Ticket, mutation: bool) {
6126        let now = self.clock.now();
6127        self.windows
6128            .lock()
6129            .unwrap_or_else(std::sync::PoisonError::into_inner)
6130            .finish(ticket, now);
6131        if mutation && !self.pacing.min_mutation_interval.is_zero() {
6132            let mut last = self
6133                .last_mutation
6134                .lock()
6135                .unwrap_or_else(std::sync::PoisonError::into_inner);
6136            // `max` rather than an assignment: a concurrent caller may already have reserved a
6137            // slot further out, and completing this request must never pull that slot back in.
6138            *last = Some(last.map_or(now, |reserved| reserved.max(now)));
6139        }
6140    }
6141
6142    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
6143    /// failure that waiting cannot help — and recorded, whichever of the three it was.
6144    ///
6145    /// This is the one place a request leaves this crate, which is why the accounting is
6146    /// here rather than at each of the callers: a read path added later is counted without
6147    /// anybody remembering to count it, and
6148    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
6149    /// when one is not.
6150    async fn send_once(
6151        &self,
6152        query: &str,
6153        variables: &Value,
6154        number: u32,
6155    ) -> Result<Value, Attempt> {
6156        let executed = self.ledger.priced_document(query);
6157        let Attempted {
6158            result,
6159            limits,
6160            reported_cost,
6161            response_outcome,
6162        } = self.attempt(executed.as_ref(), variables).await;
6163        // No `otherwise` name: every document this source sends is one of its own, and the
6164        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
6165        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
6166        let outcome = match &result {
6167            Ok(_) => accounting::Outcome::Answered,
6168            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
6169            Err(Attempt::Failed(_) | Attempt::Conflicted(_)) => accounting::Outcome::Refused,
6170        };
6171        self.ledger
6172            .record(sending.finished(outcome, limits).with_execution(
6173                Some(executed.into_owned()),
6174                response_outcome,
6175                number,
6176            ));
6177        result
6178    }
6179
6180    /// The attempt itself, with what its response said about the rate limit alongside.
6181    ///
6182    /// The two are returned together rather than recorded here because every one of the
6183    /// early exits below is a different outcome, and a record written at each of them is a
6184    /// record one of them can be added without.
6185    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
6186        let mut limits = accounting::RateLimit::default();
6187        let mut reported_cost = None;
6188        let mut response_outcome = accounting::ResponseOutcome::TransportError;
6189        let result = self
6190            .attempted(
6191                query,
6192                variables,
6193                &mut limits,
6194                &mut reported_cost,
6195                &mut response_outcome,
6196            )
6197            .await;
6198        Attempted {
6199            result,
6200            limits,
6201            reported_cost,
6202            response_outcome,
6203        }
6204    }
6205
6206    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
6207    async fn attempted(
6208        &self,
6209        query: &str,
6210        variables: &Value,
6211        limits: &mut accounting::RateLimit,
6212        reported_cost: &mut Option<u64>,
6213        response_outcome: &mut accounting::ResponseOutcome,
6214    ) -> Result<Value, Attempt> {
6215        let response = self
6216            .client()
6217            .map_err(Attempt::Failed)?
6218            .post(self.endpoint.clone())
6219            .bearer_auth(self.token.expose_secret())
6220            .json(&json!({"query": query, "variables": variables}))
6221            .send()
6222            .await
6223            .map_err(|e| {
6224                Attempt::Failed(SourceError::Unavailable {
6225                    message: format!("GitHub GraphQL request failed: {}", transport_error(&e)),
6226                })
6227            })?;
6228        let status = response.status();
6229        if !status.is_success() {
6230            *response_outcome = accounting::ResponseOutcome::HttpError;
6231        }
6232        let header = |name: &str| whole_seconds(response.headers().get(name));
6233        let served = response
6234            .headers()
6235            .get(reqwest::header::DATE)
6236            .and_then(|value| value.to_str().ok())
6237            .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
6238            .map(|at| at.with_timezone(&Utc));
6239        *limits = accounting::RateLimit::read(|name| {
6240            response
6241                .headers()
6242                .get(name)
6243                .and_then(|value| value.to_str().ok())
6244                .map(str::to_owned)
6245        });
6246        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
6247        // that are not text at all — is "not known to be exhausted". This never makes a
6248        // response a refusal on its own: it says which limiter a refusal is attributed to
6249        // and where its hint comes from, so a value this cannot read costs a hint rather
6250        // than an answer.
6251        let exhausted = response
6252            .headers()
6253            .get("x-ratelimit-remaining")
6254            .and_then(|value| value.to_str().ok())
6255            == Some("0");
6256        // `retry-after` is what GitHub asks for when it asks; when it does not and the
6257        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
6258        // which is the same question answered as an absolute time. Nothing else here is a
6259        // hint, and a schedule is what answers a refusal that carries none.
6260        let retry_after = header("retry-after");
6261        let hinted_by = if retry_after.is_some() || !exhausted {
6262            Hint::RetryAfter
6263        } else {
6264            Hint::PrimaryReset
6265        };
6266        let hint = retry_after.or_else(|| {
6267            exhausted
6268                .then(|| header("x-ratelimit-reset"))
6269                .flatten()
6270                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
6271        });
6272        // Read before it is parsed, because the evidence which tells a secondary rate
6273        // limit from a rejected credential is in the body of a response whose status says
6274        // only "forbidden" — and a non-success response was never parsed at all.
6275        let body = response.text().await.map_err(|e| {
6276            Attempt::Failed(SourceError::Unavailable {
6277                message: format!(
6278                    "GitHub GraphQL response could not be read: {}",
6279                    transport_error(&e)
6280                ),
6281            })
6282        })?;
6283        *reported_cost = serde_json::from_str::<Value>(&body)
6284            .ok()
6285            .as_ref()
6286            .and_then(|body| body.pointer("/data/rateLimit/cost"))
6287            .and_then(Value::as_u64);
6288        let parsed = serde_json::from_str::<Value>(&body).ok();
6289        *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
6290        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
6291            return Err(Attempt::Limited(Limited {
6292                limiter,
6293                hint,
6294                hinted_by,
6295            }));
6296        }
6297        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
6298            return Err(Attempt::Failed(SourceError::Auth {
6299                message: format!(
6300                    "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"
6301                ),
6302            }));
6303        }
6304        if !status.is_success() {
6305            return Err(Attempt::Failed(SourceError::Unavailable {
6306                message: format!("GitHub GraphQL returned HTTP {status}"),
6307            }));
6308        }
6309        let said = refusal_wording(status, &body);
6310        let normalized = said.to_ascii_lowercase();
6311        if CONFLICT_WORDINGS
6312            .iter()
6313            .any(|wording| normalized.contains(wording))
6314        {
6315            return Err(Attempt::Conflicted(said));
6316        }
6317        let answered = if query == graphql::ISSUES_BY_REFERENCE {
6318            self.answer_unresolved(&body)
6319        } else if [
6320            graphql::CREATE_ISSUES,
6321            graphql::SEARCH_BATCH,
6322            graphql::ADD_COMMENTS,
6323            graphql::WRITE_TASKS,
6324            graphql::SET_PARENTS,
6325            graphql::ADD_RELATES_TO,
6326            graphql::REMOVE_RELATES_TO,
6327        ]
6328        .contains(&query)
6329        {
6330            answer_with_errors(&body).map_or_else(|| self.answer(&body), Ok)
6331        } else {
6332            self.answer(&body)
6333        };
6334        let mut data = answered.map_err(Attempt::Failed)?;
6335        if let Some(served) = served {
6336            *self
6337                .last_served
6338                .lock()
6339                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(served);
6340            stamp_served(&mut data, &served.to_rfc3339());
6341        }
6342        Ok(data)
6343    }
6344
6345    /// What one successful HTTP response says, once its GraphQL errors are read.
6346    /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
6347    /// for this credential — a repository it cannot see, an issue number it does not hold —
6348    /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
6349    /// that reference rather than a failure of the others. Any other error refuses the read,
6350    /// as there.
6351    fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
6352        let parsed: Option<Value> = serde_json::from_str(body).ok();
6353        // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
6354        // `issue`, and the answer holds `null` at exactly that path: anything else — another
6355        // kind of error, one naming no slot, or one beside a value it says is not there — is a
6356        // refusal of the whole read.
6357        let unresolved = |data: &Value, error: &Value| {
6358            error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
6359                && error
6360                    .get("path")
6361                    .and_then(Value::as_array)
6362                    .is_some_and(|path| {
6363                        let alias = path.first().and_then(Value::as_str).and_then(|alias| {
6364                            alias
6365                                .strip_prefix('r')
6366                                .and_then(|slot| slot.parse::<usize>().ok())
6367                                .filter(|slot| *slot < REFERENCE_BATCH)
6368                                .map(|_| alias)
6369                        });
6370                        match (alias, path.get(1).and_then(Value::as_str), path.len()) {
6371                            (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
6372                            (Some(alias), Some("issue"), 2) => data
6373                                .get(alias)
6374                                .and_then(|repository| repository.get("issue"))
6375                                .is_some_and(Value::is_null),
6376                            _ => false,
6377                        }
6378                    })
6379        };
6380        let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
6381            parsed.get("data").is_some_and(|data| {
6382                data.is_object()
6383                    && parsed
6384                        .get("errors")
6385                        .and_then(Value::as_array)
6386                        .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
6387            })
6388        });
6389        match parsed {
6390            Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
6391            _ => self.answer(body),
6392        }
6393    }
6394
6395    fn answer(&self, body: &str) -> Result<Value, SourceError> {
6396        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
6397            message: format!("GitHub returned invalid JSON: {e}"),
6398        })?;
6399        let errors = body
6400            .get("errors")
6401            .map(|value| {
6402                value.as_array().ok_or_else(|| SourceError::Malformed {
6403                    message: "GitHub response errors is not an array".into(),
6404                })
6405            })
6406            .transpose()?;
6407        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
6408            let messages = errors
6409                .iter()
6410                .filter_map(|e| e.get("message").and_then(Value::as_str))
6411                .collect::<Vec<_>>()
6412                .join("; ");
6413            let message = if messages.is_empty() {
6414                "GitHub returned GraphQL errors".into()
6415            } else {
6416                messages
6417            };
6418            let normalized = message.to_ascii_lowercase();
6419            if normalized.contains("resource not accessible") || normalized.contains("scope") {
6420                return Err(SourceError::Auth {
6421                    message: format!(
6422                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
6423                        self.credential_name
6424                    ),
6425                });
6426            }
6427            return Err(SourceError::Refused { message });
6428        }
6429        body.get("data")
6430            .filter(|data| data.is_object())
6431            .cloned()
6432            .ok_or_else(|| SourceError::Malformed {
6433                message: "GitHub response has no data object".into(),
6434            })
6435    }
6436
6437    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
6438    // GraphQL cannot independently page them inside the outer item page. This source page is
6439    // deliberately bounded at that published maximum; the live drift journey exercises it.
6440    async fn board_page(
6441        &self,
6442        items_after: Option<&str>,
6443        items_first: u32,
6444    ) -> Result<Value, SourceError> {
6445        let data = self
6446            .graphql(
6447                graphql::BOARD,
6448                json!({"owner":self.owner,"number":self.project_number,
6449                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
6450                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
6451            )
6452            .await?;
6453        data.pointer("/owner/projectV2")
6454            .filter(|v| !v.is_null())
6455            .cloned()
6456            .ok_or_else(|| SourceError::Refused {
6457                message: format!(
6458                    "GitHub project {}/{} was not found or is not visible to the token",
6459                    self.owner, self.project_number
6460                ),
6461            })
6462    }
6463
6464    /// The search that finds the issues of this board, narrowed by `also` when it is
6465    /// given.
6466    ///
6467    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
6468    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
6469    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
6470    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
6471    /// from a task by the `parent` field each issue carries rather than by the search.
6472    fn board_search(&self, also: Option<&str>) -> String {
6473        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
6474        match also {
6475            Some(also) => format!("{scope} {also}"),
6476            None => scope,
6477        }
6478    }
6479
6480    /// One issue this source reached directly, as the board item a read of the board would
6481    /// have produced — or `None` when this board does not hold it.
6482    ///
6483    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
6484    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
6485    /// item's own id, that item's field values, and the issue as its content. One resolver
6486    /// for both routes is what makes an issue read through a search, through its own node
6487    /// id, or through its project's sub-issues report the same title, the same status, the
6488    /// same labels and the same qualified id.
6489    ///
6490    /// An issue with no entry for *this* board is not this source's to report, which is
6491    /// what keeps an id naming some other repository's issue from being answered as an item
6492    /// of this board. That answer is given about an **exhausted** connection and never
6493    /// about an unread page: the entry is looked for on the page in hand, and only if that
6494    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
6495    /// rest of it.
6496    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
6497        self.resolve_issue_as(issue, true).await
6498    }
6499
6500    /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
6501    /// and writes only when `remember` says to.
6502    async fn resolve_issue_as(
6503        &self,
6504        issue: &Value,
6505        remember: bool,
6506    ) -> Result<Option<Resolved>, SourceError> {
6507        if optional_str(issue, "__typename")? != Some("Issue") {
6508            return Ok(None);
6509        }
6510        let memberships = issue
6511            .get("projectItems")
6512            .ok_or_else(|| SourceError::Malformed {
6513                message: "GitHub issue is missing projectItems".into(),
6514            })?;
6515        let nodes = memberships
6516            .get("nodes")
6517            .and_then(Value::as_array)
6518            .ok_or_else(|| SourceError::Malformed {
6519                message: "GitHub issue projectItems.nodes is not an array".into(),
6520            })?;
6521        let held = match self.board_entry(nodes) {
6522            Some(held) => held.clone(),
6523            None => {
6524                let info = memberships
6525                    .get("pageInfo")
6526                    .ok_or_else(|| SourceError::Malformed {
6527                        message: "GitHub issue projectItems has no pageInfo".into(),
6528                    })?;
6529                // The page held no entry for this board. Whether that means the issue is
6530                // not on it is a question about the rest of the connection, and only a
6531                // connection with no rest answers it here.
6532                if !required_bool(info, "hasNextPage")? {
6533                    return Ok(None);
6534                }
6535                let cursor = required_str(info, "endCursor")?;
6536                validate_cursor_progress(None, cursor)?;
6537                let issue_id = required_str(issue, "id")?;
6538                match self.board_membership(issue_id, cursor).await? {
6539                    Some(held) => held,
6540                    None => return Ok(None),
6541                }
6542            }
6543        };
6544        // The membership's own `updatedAt` rides along: it is the board half of the item's change
6545        // token, which a field change moves where the issue's own time does not.
6546        let item = json!({
6547            "id": required_str(&held, "id")?,
6548            "updatedAt": held.get("updatedAt"),
6549            "project": held.get("project"),
6550            "fieldValues": held.get("fieldValues"),
6551            "content": issue,
6552        });
6553        if remember {
6554            self.resolve(&item)
6555        } else {
6556            self.resolve_unremembered(&item)
6557        }
6558    }
6559
6560    /// This board's own entry among one page of an issue's `Issue.projectItems`.
6561    ///
6562    /// One spelling of *which membership is this board's*, so the page a read carries and
6563    /// the pages [`Self::board_membership`] walks are searched by the same rule.
6564    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
6565        nodes.iter().find(|node| {
6566            node.pointer("/project/number").and_then(Value::as_u64)
6567                == Some(u64::from(self.project_number))
6568        })
6569    }
6570
6571    /// The rest of one issue's board memberships, from `after`, for this board's entry.
6572    ///
6573    /// The recovery read: a page of memberships that holds no entry for this board says
6574    /// nothing about the memberships past it, so the connection is walked to exhaustion
6575    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
6576    /// positive answer — the whole connection was read and no entry named this board —
6577    /// rather than a failure, and the walk is held to
6578    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
6579    /// with a cursor that does not advance is refused instead of spun on.
6580    async fn board_membership(
6581        &self,
6582        issue: &str,
6583        after: &str,
6584    ) -> Result<Option<Value>, SourceError> {
6585        let mut after = after.to_owned();
6586        loop {
6587            let data = self
6588                .graphql(
6589                    graphql::ISSUE_BOARD_ITEMS,
6590                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
6591                           "nestedFirst":NESTED_PAGE_SIZE}),
6592                )
6593                .await?;
6594            let Some(connection) = data
6595                .pointer("/node/projectItems")
6596                .filter(|value| !value.is_null())
6597            else {
6598                // The id resolved to nothing, or to something with no memberships to walk —
6599                // which is the same answer as a connection holding no entry for this board.
6600                return Ok(None);
6601            };
6602            let nodes = connection
6603                .get("nodes")
6604                .and_then(Value::as_array)
6605                .ok_or_else(|| SourceError::Malformed {
6606                    message: "GitHub issue projectItems.nodes is not an array".into(),
6607                })?;
6608            if let Some(held) = self.board_entry(nodes) {
6609                return Ok(Some(held.clone()));
6610            }
6611            let info = connection
6612                .get("pageInfo")
6613                .ok_or_else(|| SourceError::Malformed {
6614                    message: "GitHub issue projectItems has no pageInfo".into(),
6615                })?;
6616            let next = required_bool(info, "hasNextPage")?
6617                .then(|| required_str(info, "endCursor"))
6618                .transpose()?;
6619            match next {
6620                Some(next) => {
6621                    validate_cursor_progress(Some(&after), next)?;
6622                    after = next.to_owned();
6623                }
6624                None => return Ok(None),
6625            }
6626        }
6627    }
6628
6629    /// One page of a board-scoped issue search, and where the next page resumes.
6630    async fn search_page(
6631        &self,
6632        search: &str,
6633        first: u32,
6634        after: Option<&str>,
6635    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
6636        let data = self
6637            .graphql(
6638                graphql::SEARCH_ISSUES,
6639                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
6640                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
6641                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6642            )
6643            .await?;
6644        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
6645            message: "GitHub search response has no search connection".into(),
6646        })?;
6647        let mut found = Vec::new();
6648        for node in connection
6649            .get("nodes")
6650            .and_then(Value::as_array)
6651            .ok_or_else(|| SourceError::Malformed {
6652                message: "GitHub search nodes is not an array".into(),
6653            })?
6654        {
6655            if let Some(resolved) = self.resolve_issue(node).await? {
6656                found.push(resolved);
6657            }
6658        }
6659        let info = connection
6660            .get("pageInfo")
6661            .ok_or_else(|| SourceError::Malformed {
6662                message: "GitHub search connection has no pageInfo".into(),
6663            })?;
6664        let next = required_bool(info, "hasNextPage")?
6665            .then(|| required_str(info, "endCursor"))
6666            .transpose()?
6667            .map(str::to_owned);
6668        if let Some(next) = &next {
6669            validate_cursor_progress(after, next)?;
6670        }
6671        Ok((found, next))
6672    }
6673
6674    /// Every issue this board holds, completed with what this run wrote.
6675    ///
6676    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
6677    /// is an index and is eventually consistent, so an issue this run created seconds ago
6678    /// can be absent from it, and a project listed straight after being written would
6679    /// otherwise be missing from its own board. What is added back is only what this
6680    /// process itself wrote, out of [`Self::created`], which lives and dies with the
6681    /// process.
6682    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6683        let found = self.searched_issues().await?;
6684        self.completed_with_written(found, |_| true)
6685    }
6686
6687    /// Every issue this board's own search reports, walked to exhaustion, read once per
6688    /// source.
6689    ///
6690    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
6691    /// needs it too and the two would otherwise walk the same search twice in one command.
6692    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
6693    /// is.
6694    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6695        let cached = self.search_cache()?.clone();
6696        if let Some(held) = cached {
6697            return Ok(held);
6698        }
6699        let mut after: Option<String> = None;
6700        let mut found = Vec::new();
6701        let search = self.board_search(None);
6702        loop {
6703            let (page, next) = self
6704                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6705                .await?;
6706            found.extend(page);
6707            match next {
6708                Some(next) => after = Some(next),
6709                None => break,
6710            }
6711        }
6712        *self.search_cache()? = Some(found.clone());
6713        Ok(found)
6714    }
6715
6716    /// Every issue this board's own search reports with `also` beside its scope, walked to
6717    /// exhaustion — held for no later read, which may not be narrowed alike.
6718    async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6719        let mut after: Option<String> = None;
6720        let mut found = Vec::new();
6721        let search = self.board_search(Some(also));
6722        loop {
6723            let (page, next) = self
6724                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6725                .await?;
6726            found.extend(page);
6727            match next {
6728                Some(next) => after = Some(next),
6729                None => return Ok(found),
6730            }
6731        }
6732    }
6733
6734    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
6735    fn search_cache(
6736        &self,
6737    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
6738        self.search_cache
6739            .lock()
6740            .map_err(|_| SourceError::Unavailable {
6741                message: "this source's view of the board's issues was left inconsistent by an \
6742                      earlier failure; next: run the command again"
6743                    .into(),
6744            })
6745    }
6746
6747    /// `found`, with everything this run wrote that `keep` accepts and the read did not
6748    /// report.
6749    ///
6750    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
6751    /// at all: the search index is behind, and a node read of an item filed moments ago can
6752    /// be too.
6753    fn completed_with_written(
6754        &self,
6755        mut found: Vec<Resolved>,
6756        keep: impl Fn(&Resolved) -> bool,
6757    ) -> Result<Vec<Resolved>, SourceError> {
6758        for own in self.created()?.iter().filter(|own| keep(own)) {
6759            if !found.iter().any(|item| item.id == own.id) {
6760                found.push(own.clone());
6761            }
6762        }
6763        Ok(found)
6764    }
6765
6766    /// What resolving one node id reached.
6767    ///
6768    /// Three answers rather than an `Option`, because a board *draft* is none of the other
6769    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
6770    /// is completed by a read of the draft itself rather than reported as nothing.
6771    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
6772        let asked = self
6773            .graphql(
6774                graphql::ISSUE,
6775                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
6776                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6777            )
6778            .await;
6779        let data = match asked {
6780            Ok(data) => data,
6781            // A string that is not a node id at all is not a failure to report: it is an id
6782            // this board does not hold, which is what every read of one already answers.
6783            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
6784            Err(error) => return Err(error),
6785        };
6786        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6787            return Ok(Reached::Nothing);
6788        };
6789        if optional_str(node, "__typename")? == Some("DraftIssue") {
6790            return Ok(Reached::Draft);
6791        }
6792        Ok(match self.resolve_issue(node).await? {
6793            Some(item) => Reached::Held(Box::new(item)),
6794            None => Reached::Nothing,
6795        })
6796    }
6797
6798    /// One item of this board by its own id, whatever kind it is.
6799    ///
6800    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
6801    /// run wrote is read first, because a node read of an item created moments ago can
6802    /// still be behind the board field values written onto it — see [`Self::created`].
6803    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6804        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
6805            return Ok(Some(own.clone()));
6806        }
6807        if let Some(held) = self.read_ahead()?.remove(id) {
6808            return Ok(held);
6809        }
6810        match self.reach(id).await? {
6811            Reached::Held(item) => Ok(Some(*item)),
6812            Reached::Nothing => Ok(None),
6813            Reached::Draft => self.draft_by_id(id).await,
6814        }
6815    }
6816
6817    /// [`Self::item_by_id`] for a document: one [`graphql::DOCUMENT_IN_PROJECT`] read, which
6818    /// answers the project it is filed under and that project's members beside it.
6819    ///
6820    /// Each is held for this command as a read of it would have been: the project for the
6821    /// next read of it by its id, and — when its page of members is the whole of them — the
6822    /// members for the next listing of that project. So a write held to who may read the
6823    /// document, which reads all three, is answered by this one request.
6824    async fn document_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6825        if self.created()?.iter().any(|own| own.id == *id) || self.read_ahead()?.contains_key(id) {
6826            return self.item_by_id(id).await;
6827        }
6828        let asked = self
6829            .graphql(
6830                graphql::DOCUMENT_IN_PROJECT,
6831                json!({"id":id.0,"first":MAX_PAGE_SIZE,"memberFirst":MEMBER_PAGE_SIZE,
6832                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6833                       "duplicates":true}),
6834            )
6835            .await;
6836        let data = match asked {
6837            Ok(data) => data,
6838            Err(error) if unresolvable_node(&error) => return Ok(None),
6839            Err(error) => return Err(error),
6840        };
6841        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6842            return Ok(None);
6843        };
6844        if optional_str(node, "__typename")? == Some("DraftIssue") {
6845            return self.draft_by_id(id).await;
6846        }
6847        let Some(item) = self.resolve_issue(node).await? else {
6848            return Ok(None);
6849        };
6850        // An answer for another issue than the one asked about says nothing of that one.
6851        if item.id != *id {
6852            return Err(SourceError::Malformed {
6853                message: format!(
6854                    "GitHub answered issue {} when asked for document {}",
6855                    item.id.0, id.0
6856                ),
6857            });
6858        }
6859        if let Some(project) = node.get("project").filter(|value| !value.is_null())
6860            && let Some(parent) = self
6861                .resolve_issue(project)
6862                .await?
6863                .filter(|parent| Some(&parent.id) == item.parent.as_ref())
6864        {
6865            let members = project.get("subIssues").filter(|value| !value.is_null());
6866            let whole = members
6867                .and_then(|members| members.pointer("/pageInfo/hasNextPage"))
6868                .and_then(Value::as_bool)
6869                == Some(false);
6870            if whole {
6871                let nodes = members
6872                    .and_then(|members| members.get("nodes"))
6873                    .and_then(Value::as_array)
6874                    .ok_or_else(|| SourceError::Malformed {
6875                        message: "GitHub subIssues.nodes is not an array".into(),
6876                    })?;
6877                let mut children = Vec::new();
6878                for member in nodes {
6879                    if let Some(resolved) = self.resolve_issue(member).await? {
6880                        children.push(resolved);
6881                    }
6882                }
6883                // Held as the project's members only when every one resolved and says it is
6884                // filed under it: a page with any other answers nothing of all the members.
6885                if children.len() == nodes.len()
6886                    && children
6887                        .iter()
6888                        .all(|child| child.parent.as_ref() == Some(&parent.id))
6889                {
6890                    self.children_cache()?
6891                        .insert(parent.id.clone(), (parent.id.clone(), children));
6892                }
6893            }
6894            self.read_ahead()?.insert(parent.id.clone(), Some(parent));
6895        }
6896        Ok(Some(item))
6897    }
6898
6899    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
6900    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
6901    /// than one request per id.
6902    ///
6903    /// What this run wrote answers first, as it does there, and only the rest is read. One id
6904    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
6905    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
6906    /// id at a time, so that id is answered as not held and the others as themselves; a draft
6907    /// is completed by a read of the draft, exactly as there.
6908    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
6909        let mut found: Vec<Option<Option<Resolved>>> = {
6910            let created = self.created()?;
6911            ids.iter()
6912                .map(|id| {
6913                    created
6914                        .iter()
6915                        .find(|own| own.id == *id)
6916                        .map(|own| Some(own.clone()))
6917                })
6918                .collect()
6919        };
6920        let unread: Vec<NativeId> = ids
6921            .iter()
6922            .zip(&found)
6923            .filter(|(_, found)| found.is_none())
6924            .map(|(id, _)| id.clone())
6925            .collect();
6926        let mut read = Vec::with_capacity(unread.len());
6927        if let [one] = unread.as_slice() {
6928            read.push(self.item_by_id(one).await?);
6929        } else {
6930            for batch in unread.chunks(DETAIL_BATCH) {
6931                let data = match self
6932                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
6933                    .await
6934                {
6935                    Ok(data) => data,
6936                    Err(error) if unresolvable_node(&error) => {
6937                        for id in batch {
6938                            read.push(self.item_by_id(id).await?);
6939                        }
6940                        continue;
6941                    }
6942                    Err(error) => return Err(error),
6943                };
6944                for (slot, id) in batch.iter().enumerate() {
6945                    let node =
6946                        data.get(format!("i{slot}"))
6947                            .ok_or_else(|| SourceError::Malformed {
6948                                message: format!(
6949                                    "GitHub answered a batch read with no item for {}",
6950                                    id.0
6951                                ),
6952                            })?;
6953                    read.push(if node.is_null() {
6954                        None
6955                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
6956                        self.draft_by_id(id).await?
6957                    } else {
6958                        if optional_str(node, "__typename")? == Some("Issue")
6959                            && required_str(node, "id")? != id.0
6960                        {
6961                            return Err(SourceError::Malformed {
6962                                message: format!(
6963                                    "GitHub answered the read of {} with issue {}",
6964                                    id.0,
6965                                    required_str(node, "id")?
6966                                ),
6967                            });
6968                        }
6969                        self.resolve_issue(node).await?
6970                    });
6971                }
6972            }
6973        }
6974        let mut read = read.into_iter();
6975        Ok(found
6976            .iter_mut()
6977            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
6978            .collect())
6979    }
6980
6981    fn read_ahead(
6982        &self,
6983    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
6984        self.read_ahead
6985            .lock()
6986            .map_err(|_| SourceError::Unavailable {
6987                message:
6988                    "items read ahead for a copy were left inconsistent; run the command again"
6989                        .into(),
6990            })
6991    }
6992
6993    fn resolved_cache(
6994        &self,
6995    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
6996        self.resolved_cache
6997            .lock()
6998            .map_err(|_| SourceError::Unavailable {
6999                message: "resolved item records were left inconsistent; run the command again"
7000                    .into(),
7001            })
7002    }
7003
7004    /// Reuse a record this invocation already resolved. The mutation sender invalidates
7005    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
7006    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7007        let cached = self.resolved_cache()?.get(id).cloned();
7008        if let Some(item) = cached {
7009            return Ok(Some(item));
7010        }
7011        self.item_by_id(id).await
7012    }
7013
7014    /// Take out the item an issue read by reference reached for `id`, when one is held: the
7015    /// status write it was read for is the one caller, so no other read or write is ever
7016    /// answered from a read that selected only what that write needs.
7017    fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7018        Ok(self
7019            .held_cache
7020            .lock()
7021            .map_err(|_| SourceError::Unavailable {
7022                message: "items read by issue reference were left inconsistent; run the \
7023                          command again"
7024                    .into(),
7025            })?
7026            .remove(id))
7027    }
7028
7029    /// One board draft by its own id, with the board item it sits in — or `None` when no
7030    /// item of this board is that draft's.
7031    ///
7032    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
7033    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
7034    /// links a draft to one board item, so the page this read carries is the whole of that
7035    /// connection, and a page that reports more than it holds is refused rather than read
7036    /// as an answer about memberships nobody read.
7037    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7038        let data = self
7039            .graphql(
7040                graphql::DRAFT,
7041                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
7042                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
7043            )
7044            .await?;
7045        // Gone between the two reads is an answer — the draft is no longer there. Anything
7046        // else than the draft [`Self::reach`] was just told this id is, is not one.
7047        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
7048            return Ok(None);
7049        };
7050        if optional_str(draft, "__typename")? != Some("DraftIssue") {
7051            return Err(SourceError::Malformed {
7052                message: format!(
7053                    "GitHub answered {} as a draft and then as something else",
7054                    id.0
7055                ),
7056            });
7057        }
7058        if required_str(draft, "id")? != id.0 {
7059            return Err(SourceError::Malformed {
7060                message: format!("GitHub answered a different draft for {}", id.0),
7061            });
7062        }
7063        let memberships = draft
7064            .get("projectV2Items")
7065            .ok_or_else(|| SourceError::Malformed {
7066                message: format!("GitHub draft {} is missing projectV2Items", id.0),
7067            })?;
7068        let nodes = memberships
7069            .get("nodes")
7070            .and_then(Value::as_array)
7071            .ok_or_else(|| SourceError::Malformed {
7072                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
7073            })?;
7074        let info = memberships
7075            .get("pageInfo")
7076            .ok_or_else(|| SourceError::Malformed {
7077                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
7078            })?;
7079        // Read whether or not this board's entry is on the page: a page claiming more than
7080        // the one item GitHub links a draft to is a malformed answer either way.
7081        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
7082            return Err(SourceError::Malformed {
7083                message: format!(
7084                    "GitHub draft {} reports more board items than the one GitHub links a draft \
7085                     to",
7086                    id.0
7087                ),
7088            });
7089        }
7090        if let Some(node) = nodes.first()
7091            && node
7092                .pointer("/project/number")
7093                .and_then(Value::as_u64)
7094                .is_none()
7095        {
7096            return Err(SourceError::Malformed {
7097                message: format!(
7098                    "GitHub draft {} board item has no numeric project number",
7099                    id.0
7100                ),
7101            });
7102        }
7103        let Some(held) = self.board_entry(nodes) else {
7104            return Ok(None);
7105        };
7106        if required_str(
7107            held.get("project").ok_or_else(|| SourceError::Malformed {
7108                message: format!("GitHub draft {} board item has no project", id.0),
7109            })?,
7110            "id",
7111        )? != self.board_fields().await?.id.as_str()
7112        {
7113            return Ok(None);
7114        }
7115        let item = json!({
7116            "id": required_str(held, "id")?,
7117            "project": held.get("project"),
7118            "fieldValues": held.get("fieldValues"),
7119            "content": draft,
7120        });
7121        self.resolve(&item)
7122    }
7123
7124    /// The board's own id and field definitions, for a write whose item does not carry
7125    /// them — never its items.
7126    ///
7127    /// A board this command has already listed supplies them, since it read them beside its
7128    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
7129    /// is consulted about which items the board holds: see the module documentation for
7130    /// why a question about one known item is answered by reading that item.
7131    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
7132        if let Some(board) = self.board_cache()?.as_ref() {
7133            return Ok(BoardFields {
7134                id: BoardId::parse(&board.id)?,
7135                fields: board.fields.clone(),
7136            });
7137        }
7138        if let Some(held) = self.fields_cache()?.clone() {
7139            return Ok(held);
7140        }
7141        let data = self
7142            .graphql(
7143                graphql::BOARD_FIELDS,
7144                json!({"owner":self.owner,"number":self.project_number,
7145                       "nestedFirst":NESTED_PAGE_SIZE}),
7146            )
7147            .await?;
7148        self.fields_read(&data)
7149    }
7150
7151    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
7152    /// the rest of this command.
7153    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
7154        let board = data
7155            .pointer("/boardFields/projectV2")
7156            .filter(|value| !value.is_null())
7157            .ok_or_else(|| SourceError::Refused {
7158                message: format!(
7159                    "GitHub project {}/{} was not found or is not visible to the token",
7160                    self.owner, self.project_number
7161                ),
7162            })?;
7163        let read = BoardFields {
7164            id: BoardId::parse(required_str(board, "id")?)?,
7165            fields: board.get("fields").cloned().unwrap_or(Value::Null),
7166        };
7167        *self.fields_cache()? = Some(read.clone());
7168        Ok(read)
7169    }
7170
7171    /// Read what creating an issue in `repository` needs and this command has not read yet —
7172    /// the board's fields and the repository's node id — in one request when it needs both.
7173    ///
7174    /// When either is already known this sends nothing, and the other is read by its own
7175    /// document where it is asked for, so no create reads anything twice.
7176    async fn creation_context(
7177        &self,
7178        repository: &RepositoryTarget,
7179        incoming: &Incoming<'_>,
7180    ) -> Result<(), SourceError> {
7181        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
7182        if fields_known || self.repository_cache()?.contains_key(repository) {
7183            return Ok(());
7184        }
7185        // A placement an earlier process kept is taken only while the project issue this create
7186        // has just read shows its setup-context token unchanged; see `projection`.
7187        if self.placed_by_kept(repository, incoming.parent) {
7188            return Ok(());
7189        }
7190        let data = self
7191            .graphql(
7192                graphql::CREATION_CONTEXT,
7193                json!({"owner":self.owner,"number":self.project_number,
7194                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
7195                       "repositoryName":repository.name}),
7196            )
7197            .await?;
7198        self.fields_read(&data)?;
7199        self.repository_read(&data, repository, incoming)?;
7200        Ok(())
7201    }
7202
7203    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
7204    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
7205        self.fields_cache
7206            .lock()
7207            .map_err(|_| SourceError::Unavailable {
7208                message: "this source's view of the board's fields was left inconsistent by an \
7209                      earlier failure; next: run the command again"
7210                    .into(),
7211            })
7212    }
7213
7214    /// What a write to `item` needs of the board, read off that item when it says enough and
7215    /// off [`Self::board_fields`] when it does not.
7216    ///
7217    /// A node read of an item names its board and carries the definition of every field it
7218    /// holds a value of — so an item naming its board, holding a value of the origin field
7219    /// when the write carries an origin, and, when the write carries a status, holding a
7220    /// `Status` value, needs no read of the board at all. **Nothing the item does not say is
7221    /// guessed:** a field it holds no value of may still be on the board, and a view reading it
7222    /// as absent would refuse a write the board can take or skip a field write the board
7223    /// needs, so such an item — and a create, which has no item yet — takes the board's fields
7224    /// from their own read instead. A write carrying no origin to an item holding none — every
7225    /// write out of a source declared private — writes no origin whatever the board defines,
7226    /// so it needs no definition of that field.
7227    async fn fields_for(
7228        &self,
7229        item: Option<&Resolved>,
7230        writes_origin: bool,
7231        writes_status: bool,
7232        selects_priority: bool,
7233    ) -> Result<BoardFields, SourceError> {
7234        if let Some(board) = item.and_then(Resolved::carried_board) {
7235            return Ok(board);
7236        }
7237        if let Some(item) = item
7238            && let Some(board_id) = item.named_board()
7239            && (!writes_origin || item.defines(ORIGIN_FIELD))
7240            && (!writes_status || item.defines("Status"))
7241            && (!selects_priority || item.defines(PRIORITY_FIELD))
7242            && self
7243                .metadata_fields
7244                .iter()
7245                .all(|projection| item.defines(&projection.field))
7246        {
7247            return Ok(BoardFields {
7248                id: board_id,
7249                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7250            });
7251        }
7252        self.board_fields().await
7253    }
7254
7255    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
7256    /// when that id names nothing here with a sub-issue relationship to walk.
7257    ///
7258    /// `None` and an empty answer are different: `None` is *this is not an issue of this
7259    /// GitHub*, which is what sends a project selector on to be read as a name, and an
7260    /// empty vector is a project that holds nothing.
7261    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7262        let mut after: Option<String> = None;
7263        let mut children = Vec::new();
7264        // The project issue as the first page carried it, held once the walk has ended.
7265        let mut parent: Option<Resolved> = None;
7266        loop {
7267            let asked = self
7268                .graphql(
7269                    graphql::SUB_ISSUES,
7270                    json!({"id":id.0,"memberFirst":MEMBER_PAGE_SIZE,"after":after,
7271                           "nestedFirst":NESTED_PAGE_SIZE,
7272                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
7273                )
7274                .await;
7275            let data = match asked {
7276                Ok(data) => data,
7277                // A string that is not a node id at all is not a failure to report: it is
7278                // the ordinary answer to a selector naming a project by its name.
7279                Err(error) if unresolvable_node(&error) => return Ok(None),
7280                Err(error) => return Err(error),
7281            };
7282            let Some(connection) = data
7283                .pointer("/node/subIssues")
7284                .filter(|value| !value.is_null())
7285            else {
7286                // No such node, or one with no sub-issue relationship — a board draft is
7287                // the one this board can really hold.
7288                return Ok(None);
7289            };
7290            if after.is_none()
7291                && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
7292            {
7293                parent = self
7294                    .resolve_issue(node)
7295                    .await?
7296                    .filter(|resolved| resolved.id == *id);
7297            }
7298            for node in connection
7299                .get("nodes")
7300                .and_then(Value::as_array)
7301                .ok_or_else(|| SourceError::Malformed {
7302                    message: "GitHub subIssues.nodes is not an array".into(),
7303                })?
7304            {
7305                if let Some(resolved) = self.resolve_issue(node).await? {
7306                    children.push(resolved);
7307                }
7308            }
7309            let info = connection
7310                .get("pageInfo")
7311                .ok_or_else(|| SourceError::Malformed {
7312                    message: "GitHub subIssues connection has no pageInfo".into(),
7313                })?;
7314            let next = required_bool(info, "hasNextPage")?
7315                .then(|| required_str(info, "endCursor"))
7316                .transpose()?;
7317            match next {
7318                Some(next) => {
7319                    validate_cursor_progress(after.as_deref(), next)?;
7320                    after = Some(next.to_owned());
7321                }
7322                None => {
7323                    // Held only once the walk has ended, so one failing part way holds
7324                    // nothing and every read it would have answered is made.
7325                    // The project issue the first page carried answers the next read of the
7326                    // project by its id in this command, as a read of it made now would: what
7327                    // classifying a project for a write reads straight after its members.
7328                    if let Some(parent) = &parent {
7329                        self.read_ahead()?
7330                            .entry(parent.id.clone())
7331                            .or_insert_with(|| Some(parent.clone()));
7332                    }
7333                    let mut carried = self.carried()?;
7334                    for item in parent.into_iter().chain(children.iter().cloned()) {
7335                        carried.insert(item.id.clone(), item);
7336                    }
7337                    return Ok(Some(children));
7338                }
7339            }
7340        }
7341    }
7342
7343    fn carried(
7344        &self,
7345    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
7346        self.carried.lock().map_err(|_| SourceError::Unavailable {
7347            message: "items a project's listing carried were left inconsistent; run the \
7348                      command again"
7349                .into(),
7350        })
7351    }
7352
7353    /// Which issue of this board a project *name* is, or `None` when none is.
7354    ///
7355    /// One bounded query which filters on that name at the server, rather than a walk of
7356    /// every issue the board holds. The name is compared again here: the qualifier narrows
7357    /// what GitHub sends, and this source decides what it names.
7358    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
7359        let search = self.board_search(Some(&title_qualifier(name)));
7360        let mut after = None;
7361        loop {
7362            let (candidates, next) = self
7363                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7364                .await?;
7365            if let Some(item) = candidates.into_iter().find(|item| {
7366                item.kind == BoardKind::Work(ItemKind::Project)
7367                    && item.title.eq_ignore_ascii_case(name)
7368            }) {
7369                return Ok(Some(item.id));
7370            }
7371            match next {
7372                Some(next) => after = Some(next),
7373                None => return Ok(None),
7374            }
7375        }
7376    }
7377
7378    /// Everything filed under one project of this board: the sub-issues of the issue that
7379    /// project is.
7380    ///
7381    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
7382    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
7383    /// gains projects, or as another project gains tasks.
7384    ///
7385    /// A qualified id names the issue and is asked for its sub-issues directly: one
7386    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
7387    /// read as a project *name*, which costs the one bounded search
7388    /// [`Self::project_by_name`] makes.
7389    ///
7390    /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
7391    /// — and each answer is still cut to the items whose parent is this project, so one this
7392    /// process has since filed elsewhere is not reported here, and completed with what this
7393    /// process filed under it.
7394    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
7395        let held = self.children_cache()?.get(selector).cloned();
7396        let (project, children) = match held {
7397            Some(held) => held,
7398            None => {
7399                let answered = match self.sub_issues(selector).await? {
7400                    Some(children) => (selector.clone(), children),
7401                    None => match self.project_by_name(&selector.0).await? {
7402                        Some(project) => {
7403                            let children = self.sub_issues(&project).await?.unwrap_or_default();
7404                            (project, children)
7405                        }
7406                        None => return Ok(Vec::new()),
7407                    },
7408                };
7409                self.children_cache()?
7410                    .insert(selector.clone(), answered.clone());
7411                answered
7412            }
7413        };
7414        let mut children: Vec<Resolved> = children
7415            .into_iter()
7416            .filter(|child| child.parent.as_ref() == Some(&project))
7417            .collect();
7418        for own in self.updated()?.iter() {
7419            if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
7420                children.push(own.clone());
7421            }
7422        }
7423        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
7424    }
7425
7426    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
7427    /// completed with what this run wrote — the candidates a comment-activity read confirms.
7428    ///
7429    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
7430    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
7431    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
7432    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
7433    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
7434    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
7435    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
7436    /// rather than silently narrowing a caller's answer.
7437    ///
7438    /// The instant is written to the second, rounded down, which can only widen what the
7439    /// search returns; confirmation against each candidate's own comments is what makes the
7440    /// answer exact. The search is an index that lags a write by a second or two — the module
7441    /// documentation records it — so a caller that asks again from its last instant should
7442    /// overlap the two by more than that.
7443    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
7444        let found = self.searched(&updated_qualifier(since)).await?;
7445        self.completed_with_written(found, |_| true)
7446    }
7447
7448    /// Every issue of this board GitHub's issue search reports for the board-scoped search
7449    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
7450    ///
7451    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
7452    /// own record is the fresher of the two.
7453    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
7454        let search = self.board_search(Some(also));
7455        let mut after: Option<String> = None;
7456        let mut found = Vec::new();
7457        loop {
7458            let (page, next) = self
7459                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7460                .await?;
7461            found.extend(page);
7462            match next {
7463                Some(next) => after = Some(next),
7464                None => return Ok(found),
7465            }
7466        }
7467    }
7468
7469    /// A bounded task answer; the versioned cursor carries the connection position, how
7470    /// many rows of the page starting there were already handed out, and the own-write ids
7471    /// already observed, including across a new source instance.
7472    ///
7473    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
7474    /// sliced from the pages it needs; why is the module documentation's paging contract.
7475    async fn search_tasks(
7476        &self,
7477        query: &TaskQuery,
7478        page: &PageRequest,
7479        also: &str,
7480    ) -> Result<Page<Task>, SourceError> {
7481        let mut position = match &page.cursor {
7482            None => SearchPosition::default(),
7483            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
7484                .ok()
7485                .filter(|position| {
7486                    position.version == SEARCH_CURSOR_VERSION
7487                        && position.connection.valid_resume(position.offset)
7488                })
7489                .ok_or_else(|| SourceError::Config {
7490                    message: "page cursor is invalid".into(),
7491                })?,
7492        };
7493        let search = self.board_search(Some(also));
7494        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
7495        let own = self.with_own_writes(Vec::new())?;
7496        // An issue this process commented on is a candidate of a comment-activity read
7497        // whether or not the search has caught up with the comment; see `Self::commented`.
7498        let commented = match query.commented_since {
7499            Some(_) => self.commented()?.clone(),
7500            None => Vec::new(),
7501        };
7502        for id in own.iter().map(|item| &item.id).chain(&commented) {
7503            if !position.own.contains(id) {
7504                position.own.push(id.clone());
7505            }
7506        }
7507        let mut tasks = Vec::new();
7508        while !position.connection.exhausted() && tasks.len() < limit {
7509            let first = SEARCH_PAGE_SIZE;
7510            // Page size is part of the key: a short cached answer cannot answer a wider ask.
7511            let key =
7512                serde_json::to_string(&("page", &search, &position.connection.after(), first))
7513                    .expect("search page key is serializable");
7514            let cached = if query.commented_since.is_none() {
7515                self.narrowed_cache()?.get(&key).cloned()
7516            } else {
7517                None
7518            };
7519            let (found, next) = match cached {
7520                Some(found) => {
7521                    let next = self
7522                        .search_next
7523                        .lock()
7524                        .map_err(|_| SourceError::Unavailable {
7525                            message:
7526                                "search pagination was left inconsistent; run the command again"
7527                                    .into(),
7528                        })?
7529                        .get(&key)
7530                        .cloned()
7531                        .flatten();
7532                    (found, next)
7533                }
7534                None => {
7535                    let (found, next) = self
7536                        .search_page(&search, first, position.connection.after())
7537                        .await?;
7538                    if query.commented_since.is_none() {
7539                        self.search_next
7540                            .lock()
7541                            .map_err(|_| SourceError::Unavailable {
7542                                message:
7543                                    "search pagination was left inconsistent; run the command again"
7544                                        .into(),
7545                            })?
7546                            .insert(key.clone(), next.clone());
7547                        self.narrowed_cache()?.insert(key, found.clone());
7548                    }
7549                    (found, next)
7550                }
7551            };
7552            let rows = found.len();
7553            // Every candidate of the page this read confirms by its comments is read together.
7554            let candidates =
7555                self.comment_candidates(found.iter().skip(position.offset), query, &query.project)?;
7556            self.hold_comment_pages(&candidates).await?;
7557            for mut item in found.into_iter().skip(position.offset) {
7558                if tasks.len() == limit {
7559                    break;
7560                }
7561                position.offset += 1;
7562                if position.own.contains(&item.id) {
7563                    if position.seen.contains(&item.id) {
7564                        continue;
7565                    }
7566                    position.seen.push(item.id.clone());
7567                    // The search's own copy of an issue this process only commented on is as
7568                    // good as a node read of it, since its comments are read either way.
7569                    let only_commented = commented.contains(&item.id)
7570                        && !own.iter().any(|written| written.id == item.id);
7571                    if !only_commented {
7572                        let updated_at = item.updated_at;
7573                        let Some(written) = self.search_written(&own, &item.id).await? else {
7574                            continue;
7575                        };
7576                        item = written;
7577                        item.updated_at = item.updated_at.max(updated_at);
7578                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
7579                    }
7580                }
7581                if item.kind == BoardKind::Work(ItemKind::Task) {
7582                    let task = item.task()?;
7583                    if task_matches(&task, query, &query.project)
7584                        && self.commented_since(&item, query.commented_since).await?
7585                    {
7586                        tasks.push(task);
7587                    }
7588                }
7589            }
7590            if position.offset < rows {
7591                continue;
7592            }
7593            position.offset = 0;
7594            position.connection = match next {
7595                Some(after) => SearchConnection::Continuing {
7596                    after: Cursor(after),
7597                },
7598                None => SearchConnection::Exhausted {},
7599            };
7600        }
7601        if position.connection.exhausted() {
7602            for id in position.own.clone() {
7603                if position.seen.contains(&id) {
7604                    continue;
7605                }
7606                if tasks.len() == limit {
7607                    break;
7608                }
7609                position.seen.push(id.clone());
7610                let Some(item) = self.search_written(&own, &id).await? else {
7611                    continue;
7612                };
7613                if item.kind == BoardKind::Work(ItemKind::Task) {
7614                    let task = item.task()?;
7615                    if task_matches(&task, query, &query.project)
7616                        && self.commented_since(&item, query.commented_since).await?
7617                    {
7618                        tasks.push(task);
7619                    }
7620                }
7621            }
7622        }
7623        let more = !position.connection.exhausted()
7624            || position.own.iter().any(|id| !position.seen.contains(id));
7625        Ok(Page {
7626            items: tasks,
7627            next: more.then(|| {
7628                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
7629            }),
7630        })
7631    }
7632
7633    /// A resumed process has the ids but no write records; resolve only a record the
7634    /// current page needs, by its uncached node read rather than the lagging search index.
7635    async fn search_written(
7636        &self,
7637        own: &[Resolved],
7638        id: &NativeId,
7639    ) -> Result<Option<Resolved>, SourceError> {
7640        match own.iter().find(|item| item.id == *id) {
7641            Some(item) => Ok(Some(item.clone())),
7642            None => self.item_by_id(id).await,
7643        }
7644    }
7645
7646    /// The candidates for a task query carrying a text, metadata or origin predicate, read
7647    /// without enumerating the board — or `None` for a query carrying none of the three, which
7648    /// keeps the reads it always had.
7649    ///
7650    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
7651    /// because it names at most a handful of items. Text and metadata are answered by one
7652    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
7653    /// further by `updated:>=` when the query also asks for comment activity, since both
7654    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
7655    /// process afterwards by the same predicates [`task_matches`] applies to every read.
7656    ///
7657    /// Completed with what this process wrote, its own record winning over the index's copy
7658    /// of the same item: see [`Self::with_own_writes`].
7659    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
7660        self.send_pending_origins(None).await?;
7661        let asked = match (&query.origin, narrowing_qualifiers(query)) {
7662            (Some(origin), _) => Narrowing::Origin(origin.clone()),
7663            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
7664                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
7665                None => qualifiers,
7666            }),
7667            (None, None) => return Ok(None),
7668        };
7669        // A question about comment activity is asked afresh every time, as it always was: it
7670        // is the one a caller polls from one source while waiting for the index, and an
7671        // answer held from the first poll would be the answer to every later one.
7672        let key = query.commented_since.is_none().then(|| asked.key());
7673        let cached = match &key {
7674            Some(key) => self.narrowed_cache()?.get(key).cloned(),
7675            None => None,
7676        };
7677        let found = match cached {
7678            Some(found) => found,
7679            None => {
7680                let found = match &asked {
7681                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
7682                    Narrowing::Search(also) => self.searched(also).await?,
7683                };
7684                if let Some(key) = key {
7685                    self.narrowed_cache()?.insert(key, found.clone());
7686                }
7687                found
7688            }
7689        };
7690        self.with_own_writes(found).map(Some)
7691    }
7692
7693    /// The candidates for a project or unscoped document query carrying a searchable text,
7694    /// read without enumerating the board — or `None` for a query with no text or a blank one,
7695    /// which keeps the read it always had.
7696    ///
7697    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
7698    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
7699    /// costs is the issues that match and never the board. Its answer is held for the command
7700    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
7701    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
7702    /// substring rule, exactly as an item of the wider read was, and is completed with what this
7703    /// process wrote: see [`Self::with_own_writes`].
7704    async fn text_searched(
7705        &self,
7706        text: Option<&TextQuery>,
7707    ) -> Result<Option<Vec<Resolved>>, SourceError> {
7708        let Some(also) = text_qualifiers(text) else {
7709            return Ok(None);
7710        };
7711        let key = Narrowing::Search(also.clone()).key();
7712        let cached = self.narrowed_cache()?.get(&key).cloned();
7713        let found = match cached {
7714            Some(found) => found,
7715            None => {
7716                let found = self.searched(&also).await?;
7717                self.narrowed_cache()?.insert(key, found.clone());
7718                found
7719            }
7720        };
7721        self.with_own_writes(found).map(Some)
7722    }
7723
7724    /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
7725    /// [`Self::narrowed`] reads for a task query carrying that origin — one
7726    /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
7727    /// what this process wrote — each confirmed against its own origin entry.
7728    ///
7729    /// What answers a project or document listing by origin, so finding every copy of one plan
7730    /// costs the items that carry its origin and never a page of the board.
7731    async fn copied_from(
7732        &self,
7733        origin: &str,
7734        kind: BoardKind,
7735    ) -> Result<Vec<Resolved>, SourceError> {
7736        let wanted = TaskQuery {
7737            origin: Some(origin.to_owned()),
7738            ..TaskQuery::default()
7739        };
7740        Ok(self
7741            .narrowed(&wanted)
7742            .await?
7743            .unwrap_or_default()
7744            .into_iter()
7745            .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
7746            .collect())
7747    }
7748
7749    /// Every item of this board that may carry `origin` — a superset of those that do — found
7750    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
7751    ///
7752    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
7753    /// which reads the field every carrier holds, whichever release wrote it — and the
7754    /// board-scoped issue search for the same id as a phrase in the body, where this source
7755    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
7756    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
7757    /// query's, exactly.
7758    ///
7759    /// Both connections are walked to exhaustion, each from its own cursor. One that has
7760    /// already ended is sent its last cursor again, which answers an empty page, so the one
7761    /// document serves every page of either. What the two leave is stated in the module
7762    /// documentation: a carrier another process added within the last second or two, before
7763    /// either index has it.
7764    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
7765        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
7766        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
7767        let mut items_after: Option<String> = None;
7768        let mut search_after: Option<String> = None;
7769        let mut found: Vec<Resolved> = Vec::new();
7770        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
7771            if !found.iter().any(|held| held.id == resolved.id) {
7772                found.push(resolved);
7773            }
7774        };
7775        loop {
7776            let data = self
7777                .graphql(
7778                    graphql::ORIGIN_LOOKUP,
7779                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
7780                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
7781                           "itemsAfter":items_after,"searchAfter":search_after,
7782                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7783                           "duplicates":true}),
7784                )
7785                .await?;
7786            let items = data
7787                .pointer("/originItems/projectV2/items")
7788                .filter(|value| !value.is_null())
7789                .ok_or_else(|| SourceError::Refused {
7790                    message: format!(
7791                        "GitHub project {}/{} was not found or is not visible to the token",
7792                        self.owner, self.project_number
7793                    ),
7794                })?;
7795            for item in optional_nodes(Some(items), "project items")?
7796                .into_iter()
7797                .flatten()
7798            {
7799                // The board's own items list its drafts too, and a draft is not an issue: no
7800                // narrowed read answers with one, whatever its origin field holds.
7801                if let Some(resolved) = self.resolve(item)?
7802                    && resolved.content_kind == ContentKind::Issue
7803                {
7804                    keep(resolved, &mut found);
7805                }
7806            }
7807            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
7808                message: "GitHub search response has no search connection".into(),
7809            })?;
7810            for node in optional_nodes(Some(searched), "search")?
7811                .into_iter()
7812                .flatten()
7813            {
7814                if let Some(resolved) = self.resolve_issue(node).await? {
7815                    keep(resolved, &mut found);
7816                }
7817            }
7818            let items_next = resumed(items, items_after.as_deref())?;
7819            let search_next = resumed(searched, search_after.as_deref())?;
7820            if !items_next.has_more() && !search_next.has_more() {
7821                return Ok(found);
7822            }
7823            items_after = items_next.cursor();
7824            search_after = search_next.cursor();
7825        }
7826    }
7827
7828    /// `found`, with every item this process created or wrote in its place, and every one of
7829    /// them the read did not report added.
7830    ///
7831    /// This process's own record wins over the read's copy of the same item, because a read
7832    /// of an item written moments ago can still be behind what was written onto it — the
7833    /// origin field included, which is the one a narrowed read is confirmed against — and a
7834    /// read that still names an item under a predicate this process's write moved it out of
7835    /// must not return it. The one thing the read knows that the record cannot is when GitHub
7836    /// last saw the item change, which is what a comment-activity read rules a candidate out
7837    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
7838    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
7839    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
7840        // A board draft is not an issue, so no narrowed read returns one, and this process
7841        // having written one does not make it an answer either.
7842        let own: Vec<Resolved> = self
7843            .created()?
7844            .iter()
7845            .chain(self.updated()?.iter())
7846            .filter(|own| own.content_kind == ContentKind::Issue)
7847            .cloned()
7848            .collect();
7849        for mut own in own {
7850            self.resolved_cache()?.insert(own.id.clone(), own.clone());
7851            match found.iter_mut().find(|read| read.id == own.id) {
7852                Some(read) => {
7853                    own.updated_at = own.updated_at.max(read.updated_at);
7854                    *read = own;
7855                }
7856                None => found.push(own),
7857            }
7858        }
7859        Ok(found)
7860    }
7861
7862    /// Whether `item` has a comment created or last edited at or after `since` — always, when
7863    /// there is no instant to hold it to.
7864    ///
7865    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
7866    /// or after the instant moved it there: an issue not updated since holds no such comment,
7867    /// and its comments are never asked for — unless this process commented on it in this
7868    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
7869    /// only as far as the first that matches. A board draft is not an issue and has no
7870    /// comments, so it never matches.
7871    async fn commented_since(
7872        &self,
7873        item: &Resolved,
7874        since: Option<DateTime<Utc>>,
7875    ) -> Result<bool, SourceError> {
7876        let Some(since) = since else {
7877            return Ok(true);
7878        };
7879        if item.content_kind == ContentKind::DraftIssue {
7880            return Ok(false);
7881        }
7882        // An `updatedAt` this process's own record or a lagging index holds can predate a
7883        // comment this process wrote since, so only an issue it did not comment on is ruled
7884        // out by one.
7885        if item.updated_at.is_some_and(|updated| updated < since)
7886            && !self.commented()?.contains(&item.id)
7887        {
7888            return Ok(false);
7889        }
7890        let query = TaskQuery {
7891            commented_since: Some(since),
7892            ..TaskQuery::default()
7893        };
7894        let mut after: Option<String> = None;
7895        // A first page read beside its fellow candidates answers as this read's own would.
7896        let held = self.held_comments()?.get(&item.id).cloned();
7897        if let Some(first) = held {
7898            if query.comments_match(&first.items) {
7899                return Ok(true);
7900            }
7901            match first.next {
7902                Some(next) => after = Some(next.0),
7903                None => return Ok(false),
7904            }
7905        }
7906        loop {
7907            let data = self
7908                .graphql(
7909                    graphql::ISSUE_COMMENTS,
7910                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
7911                )
7912                .await?;
7913            let Some(connection) = data
7914                .get("node")
7915                .filter(|value| !value.is_null())
7916                .and_then(|node| node.get("comments"))
7917                .filter(|value| !value.is_null())
7918            else {
7919                // Removed since the search reported it: no longer an issue with comments.
7920                return Ok(false);
7921            };
7922            let comments = optional_nodes(Some(connection), "issue comments")?
7923                .into_iter()
7924                .flatten()
7925                .map(comment_from)
7926                .collect::<Result<Vec<_>, _>>()?;
7927            if query.comments_match(&comments) {
7928                return Ok(true);
7929            }
7930            match next_cursor(connection)? {
7931                Some(next) => {
7932                    validate_cursor_progress(after.as_deref(), &next.0)?;
7933                    after = Some(next.0);
7934                }
7935                None => return Ok(false),
7936            }
7937        }
7938    }
7939
7940    /// Every item on the board: the union of both enumerations GitHub offers of one.
7941    ///
7942    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
7943    /// board **draft** and reads the board's own fields beside its items, and only the search
7944    /// reports an item that connection is behind on. The module documentation is where the lag and the
7945    /// measurements behind it are written down.
7946    ///
7947    /// A search result is admitted on the same terms as any other issue this source reaches
7948    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
7949    /// names *this* board — so an issue the index still believes is here after it was taken
7950    /// off is refused rather than reported.
7951    ///
7952    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
7953    /// which is what the cache could otherwise have broken.
7954    async fn board(&self) -> Result<Board, SourceError> {
7955        self.board_completed_by(None).await
7956    }
7957
7958    /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
7959    /// that half could add is then answered only if it holds.
7960    async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
7961        let cached = self.board_cache()?.clone();
7962        let mut board = match cached {
7963            Some(board) => board,
7964            None => {
7965                let read = self.read_board().await?;
7966                *self.board_cache()? = Some(read.clone());
7967                read
7968            }
7969        };
7970        let searched = match also {
7971            None => self.searched_issues().await?,
7972            Some(also) => self.searched_narrowly(also).await?,
7973        };
7974        // A complete sweep asks for every page of this view. Reconcile ids once per
7975        // view, rather than scanning the board for every search result on every page.
7976        let mut ids: std::collections::HashSet<_> =
7977            board.items.iter().map(|item| item.id.clone()).collect();
7978        for held in searched {
7979            if ids.insert(held.id.clone()) {
7980                board.items.push(held);
7981            }
7982        }
7983        for own in self.created()?.iter() {
7984            if ids.insert(own.id.clone()) {
7985                board.items.push(own.clone());
7986            }
7987        }
7988        Ok(board)
7989    }
7990
7991    /// This process's own view of the board, or the refusal a poisoned lock is.
7992    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
7993        self.board_cache
7994            .lock()
7995            .map_err(|_| SourceError::Unavailable {
7996                message: "this source's view of the board was left inconsistent by an earlier \
7997                      failure; next: run the command again"
7998                    .into(),
7999            })
8000    }
8001
8002    /// Bring this process's own view of the board up to an item it has just written.
8003    ///
8004    /// A created item goes to `created`, which is what completes a board read GitHub's own
8005    /// eventual consistency has left behind. An item that was already there is replaced
8006    /// where it sits, so a second write of it in the same command reads its real parent
8007    /// rather than the one it had before the first write.
8008    ///
8009    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
8010    /// that wins: an item this same run created is held in `created` and not in the cached
8011    /// board, and `board` completes the cached board *from* `created`, so replacing only
8012    /// the cached copy of such an item replaces nothing and the read still reports the
8013    /// title it was created with. The search is the third, and it is the one an item the
8014    /// board's own projection is behind on sits in *alone* — which is exactly the item this
8015    /// source is least able to re-read, so leaving it out would put the stale title back on
8016    /// the only items the completion in [`Self::board`] exists for.
8017    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
8018        self.resolved_cache()?.insert(item.id.clone(), item.clone());
8019        if created {
8020            self.created()?.push(item);
8021            return Ok(());
8022        }
8023        {
8024            let mut own = self.created()?;
8025            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
8026                *held = item;
8027                return Ok(());
8028            }
8029        }
8030        {
8031            let mut own = self.updated()?;
8032            match own.iter_mut().find(|held| held.id == item.id) {
8033                Some(held) => *held = item.clone(),
8034                None => own.push(item.clone()),
8035            }
8036        }
8037        if let Some(board) = self.board_cache()?.as_mut()
8038            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
8039        {
8040            *held = item.clone();
8041        }
8042        if let Some(found) = self.search_cache()?.as_mut()
8043            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
8044        {
8045            *held = item.clone();
8046        }
8047        for found in self.narrowed_cache()?.values_mut() {
8048            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8049                *held = item.clone();
8050            }
8051        }
8052        for (_, found) in self.children_cache()?.values_mut() {
8053            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8054                *held = item.clone();
8055            }
8056        }
8057        Ok(())
8058    }
8059
8060    /// Forget one item this process has just deleted, from every half of its own view.
8061    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
8062        if let Some(Deferred::Held { created, .. }) = self.deferred()?.as_mut() {
8063            created.retain(|held| held.content != *id);
8064        }
8065        self.resolved_cache()?.remove(id);
8066        self.read_ahead()?.remove(id);
8067        self.carried()?.remove(id);
8068        self.created()?.retain(|own| own.id != *id);
8069        self.updated()?.retain(|own| own.id != *id);
8070        self.commented()?.retain(|own| own != id);
8071        if let Some(board) = self.board_cache()?.as_mut() {
8072            board.items.retain(|item| item.id != *id);
8073        }
8074        if let Some(found) = self.search_cache()?.as_mut() {
8075            found.retain(|item| item.id != *id);
8076        }
8077        for found in self.narrowed_cache()?.values_mut() {
8078            found.retain(|item| item.id != *id);
8079        }
8080        for (_, found) in self.children_cache()?.values_mut() {
8081            found.retain(|item| item.id != *id);
8082        }
8083        Ok(())
8084    }
8085
8086    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
8087    fn narrowed_cache(
8088        &self,
8089    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
8090        self.narrowed_cache
8091            .lock()
8092            .map_err(|_| SourceError::Unavailable {
8093                message: "this source's view of a narrowed read was left inconsistent by an \
8094                      earlier failure; next: run the command again"
8095                    .into(),
8096            })
8097    }
8098
8099    /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
8100    /// is.
8101    /// The tasks `project` holds, as a copy's batched read found them this command, when it did.
8102    fn held_task_children(&self, project: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
8103        Ok(self.task_children()?.get(project).cloned())
8104    }
8105
8106    fn task_children(
8107        &self,
8108    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Vec<Resolved>>>, SourceError> {
8109        self.task_children
8110            .lock()
8111            .map_err(|_| SourceError::Unavailable {
8112                message: "this source's record of a project's tasks was left inconsistent by an \
8113                          earlier failure; next: run the command again"
8114                    .into(),
8115            })
8116    }
8117
8118    fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
8119        self.children_cache
8120            .lock()
8121            .map_err(|_| SourceError::Unavailable {
8122                message: "this source's view of a project's tasks was left inconsistent by an \
8123                      earlier failure; next: run the command again"
8124                    .into(),
8125            })
8126    }
8127
8128    /// Every page of the board, read from GitHub.
8129    async fn read_board(&self) -> Result<Board, SourceError> {
8130        let mut after: Option<String> = None;
8131        let mut items = Vec::new();
8132        let mut board;
8133        loop {
8134            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
8135            for item in page
8136                .pointer("/items/nodes")
8137                .and_then(Value::as_array)
8138                .ok_or_else(|| SourceError::Malformed {
8139                    message: "GitHub project items.nodes is not an array".into(),
8140                })?
8141            {
8142                if let Some(resolved) = self.resolve(item)? {
8143                    items.push(resolved);
8144                }
8145            }
8146            let info = page
8147                .pointer("/items/pageInfo")
8148                .ok_or_else(|| SourceError::Malformed {
8149                    message: "GitHub project items have no pageInfo".into(),
8150                })?;
8151            let has_next = required_bool(info, "hasNextPage")?;
8152            let next = has_next
8153                .then(|| required_str(info, "endCursor"))
8154                .transpose()?;
8155            board = page.clone();
8156            match next {
8157                Some(next) => {
8158                    validate_cursor_progress(after.as_deref(), next)?;
8159                    after = Some(next.to_owned());
8160                }
8161                None => break,
8162            }
8163        }
8164        Ok(Board {
8165            id: required_str(&board, "id")?.to_owned(),
8166            fields: board.get("fields").cloned().unwrap_or(Value::Null),
8167            items,
8168        })
8169    }
8170
8171    /// The existing items this source has written, for completing a narrowed read that is
8172    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
8173    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8174        self.updated.lock().map_err(|_| SourceError::Unavailable {
8175            message: "this source's record of what it wrote in this run was left inconsistent \
8176                      by an earlier failure; next: run the command again"
8177                .into(),
8178        })
8179    }
8180
8181    /// The issues this source has commented on in this command; see
8182    /// [`Self::commented`](GitHubProjectsSource::commented).
8183    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
8184        self.commented.lock().map_err(|_| SourceError::Unavailable {
8185            message: "this source's record of what it commented on in this run was left \
8186                      inconsistent by an earlier failure; next: run the command again"
8187                .into(),
8188        })
8189    }
8190
8191    /// Called only once GitHub has answered the comment write, so an issue whose comment
8192    /// failed is never made a candidate a later read would pay a node read for.
8193    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
8194        // A comment page held for this command is no longer what the issue holds.
8195        self.held_comments()?.remove(issue);
8196        let mut commented = self.commented()?;
8197        if !commented.contains(issue) {
8198            commented.push(issue.clone());
8199        }
8200        Ok(())
8201    }
8202
8203    /// The items this source has created, for completing a board read that is behind.
8204    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8205        self.created.lock().map_err(|_| SourceError::Unavailable {
8206            message: "this source's record of what it created in this run was left \
8207                      inconsistent by an earlier failure; next: run the command again"
8208                .into(),
8209        })
8210    }
8211
8212    /// One board item as this source reports it, or `None` for content it ignores.
8213    ///
8214    /// A pull request is neither a project nor a task — it is somebody's change, not a
8215    /// unit of plan — and an item whose content the token cannot see has nothing to
8216    /// report at all.
8217    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8218        let resolved = self.resolve_unremembered(item)?;
8219        if let Some(resolved) = &resolved {
8220            self.resolved_cache()?
8221                .insert(resolved.id.clone(), resolved.clone());
8222        }
8223        Ok(resolved)
8224    }
8225
8226    /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
8227    /// write's own response needs, which says nothing this process should answer a later read
8228    /// with.
8229    fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8230        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
8231            message: "GitHub project item is missing content".into(),
8232        })?;
8233        if content.is_null() {
8234            return Ok(None);
8235        }
8236        let content_kind = match required_str(content, "__typename")? {
8237            "Issue" => ContentKind::Issue,
8238            "DraftIssue" => ContentKind::DraftIssue,
8239            _ => return Ok(None),
8240        };
8241        let field_values = item
8242            .get("fieldValues")
8243            .ok_or_else(|| SourceError::Malformed {
8244                message: "GitHub project item is missing fieldValues".into(),
8245            })?;
8246        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
8247        let nodes = field_values
8248            .get("nodes")
8249            .and_then(Value::as_array)
8250            .ok_or_else(|| SourceError::Malformed {
8251                message: "GitHub project item fieldValues.nodes is not an array".into(),
8252            })?;
8253        if let Some(labels) = content.get("labels") {
8254            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
8255        }
8256        let raw_body = optional_str(content, "body")?.map(str::to_owned);
8257        let (body, slot) = self.visible(raw_body.clone())?;
8258        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
8259            .map(|id| NativeId(id.to_owned()));
8260        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
8261        // to read one from; it is a task, and never a project.
8262        let sub_issues = match content_kind {
8263            ContentKind::Issue => sub_issue_total(content)?,
8264            ContentKind::DraftIssue => 0,
8265        };
8266        let content_id = required_str(content, "id")?;
8267        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8268            message: format!("GitHub issue {content_id}: {message}"),
8269        })?;
8270        let raw_title = required_str(content, "title")?;
8271        // Read as children, a sub-issue is filed under no project and nothing is a project: the
8272        // GitHub parent is the parent relation instead.
8273        let (kind, parent, issue_parent) = match self.sub_issues {
8274            SubIssues::Project => (
8275                BoardKind::of(raw_title, parent.is_some(), sub_issues, marked),
8276                parent,
8277                None,
8278            ),
8279            SubIssues::Parent => (
8280                if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
8281                    BoardKind::Document
8282                } else {
8283                    BoardKind::Work(ItemKind::Task)
8284                },
8285                None,
8286                parent,
8287            ),
8288        };
8289        // The title a person wrote, which for a document is the one without the prefix —
8290        // the same way `content` above is the body without this source's metadata slot.
8291        let title = match kind {
8292            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
8293            BoardKind::Work(_) => raw_title.to_owned(),
8294        };
8295        let own_repository = content
8296            .pointer("/repository/nameWithOwner")
8297            .and_then(Value::as_str)
8298            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
8299            .transpose()
8300            .map_err(|message| SourceError::Malformed { message })?;
8301        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
8302            Repository::from_metadata(&slot)
8303                .map_err(|message| SourceError::Malformed { message })?
8304        } else {
8305            own_repository.clone().into_iter().collect()
8306        };
8307        let classification = Classification::from_metadata(&slot)
8308            .map_err(|message| SourceError::Malformed { message })?;
8309        let id = NativeId(content_id.to_owned());
8310        // Read only for a task, because only a task has either list: a project or a
8311        // document holding one of these keys holds nothing this source reports, and the
8312        // keys are left out of its caller-visible metadata all the same.
8313        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
8314            let listed = |key: &str| {
8315                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
8316                    .map_err(|message| SourceError::Malformed { message })
8317            };
8318            (
8319                listed(TaskRef::DELIVERS_KEY)?,
8320                listed(TaskRef::DELIVERED_BY_KEY)?,
8321            )
8322        } else {
8323            (Vec::new(), Vec::new())
8324        };
8325        let (option, closed, reason) = Self::status_parts(nodes, content)?;
8326        let priority = self.held_priority(nodes)?;
8327        let item_labels = labels(content)?;
8328        // Present when the item was reached through its own issue, whose board entry
8329        // names the board; a read of the board's own items has the board already. An
8330        // empty id names nothing a field write could address, so it is read as absent and
8331        // the write goes back to reading the board.
8332        let board_id = item
8333            .pointer("/project/id")
8334            .and_then(Value::as_str)
8335            .filter(|id| !id.is_empty());
8336        let resolved = Resolved {
8337            item_id: required_str(item, "id")?.to_owned(),
8338            id,
8339            content_kind,
8340            kind,
8341            title,
8342            body: body.filter(|value| !value.is_empty()),
8343            raw_body,
8344            status: self
8345                .statuses
8346                .status(kind.status_kind(), option, closed, reason),
8347            option: option.map(str::to_owned),
8348            priority,
8349            closed,
8350            reason: closed.then(|| reason.map(str::to_owned)).flatten(),
8351            delivers,
8352            delivered_by,
8353            labels: item_labels.clone(),
8354            parent,
8355            issue_parent,
8356            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
8357            projected: self.held_projections(nodes)?,
8358            number: match content_kind {
8359                ContentKind::Issue => Some(issue_number(content)?),
8360                // A draft is filed in no repository, so nothing ever numbered it:
8361                // `DraftIssue` declares no `number` at all, exactly as it declares no
8362                // `subIssuesSummary` the branch above reads.
8363                ContentKind::DraftIssue => None,
8364            },
8365            url: optional_str(content, "url")?.map(str::to_owned),
8366            created_at: optional_time(content, "createdAt")?,
8367            updated_at: optional_time(content, "updatedAt")?,
8368            item_updated_at: optional_time(item, "updatedAt")?,
8369            comment_count: content
8370                .pointer("/commentCount/totalCount")
8371                .and_then(Value::as_u64),
8372            relationship_events: content
8373                .pointer("/relationshipEvents/totalCount")
8374                .and_then(Value::as_u64),
8375            // The issue node is stamped wherever it was read; a board item a read of the issue
8376            // put together from its memberships is not.
8377            served_at: match optional_time(content, SERVED_AT)? {
8378                Some(served) => Some(served),
8379                None => optional_time(item, SERVED_AT)?,
8380            },
8381            // Both connections were read to their end above — one reporting a further page is
8382            // refused — so these are every field value and every label the item holds. A
8383            // response that did not select the labels at all says nothing of them, and gives
8384            // no facets.
8385            facets: content.get("labels").is_some().then(|| {
8386                change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
8387            }),
8388            own_repository,
8389            repositories,
8390            classification,
8391            slot,
8392            board_id: board_id.map(str::to_owned),
8393            fields: field_definitions(nodes),
8394            board_fields: Self::carried_board_fields(content, board_id)?,
8395            blocked_by: carried_blocked_by(content)?,
8396        };
8397        Ok(Some(resolved))
8398    }
8399
8400    /// The field definitions of the board `board_id` names — the project this issue's own
8401    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
8402    /// `None` when the read carried none, carried no entry for that board, or the board item
8403    /// named no board, which a write then answers by reading the board's fields itself.
8404    ///
8405    /// Matched by the board's node id and never by its number alone: a project number is
8406    /// unique only within its owner, so another owner's board numbered alike can sit on the
8407    /// same page, and its field and option ids address nothing on this one.
8408    fn carried_board_fields(
8409        content: &Value,
8410        board_id: Option<&str>,
8411    ) -> Result<Option<Value>, SourceError> {
8412        let (Some(nodes), Some(board_id)) = (
8413            content.pointer("/boards/nodes").and_then(Value::as_array),
8414            board_id,
8415        ) else {
8416            return Ok(None);
8417        };
8418        let Some(board) = nodes.iter().find_map(|node| {
8419            let project = node.get("project")?;
8420            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
8421        }) else {
8422            return Ok(None);
8423        };
8424        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
8425            return Ok(None);
8426        };
8427        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
8428        Ok(Some(fields.clone()))
8429    }
8430
8431    /// What one board item's `Priority` field says, through this instance's mapping.
8432    ///
8433    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
8434    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
8435    /// option the mapping does not name is kept as itself — never read as a level or as
8436    /// `none` — for a read of the task to report by name.
8437    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
8438        let Some(mapping) = &self.priorities else {
8439            return Ok(HeldPriority::Read(Priority::None));
8440        };
8441        // A value of the field that names no option — a text field someone called `Priority` —
8442        // is malformed rather than `none`: reading it as no priority would let the next copy
8443        // clear one a person set.
8444        let Some(option) = field_values
8445            .iter()
8446            .find(|value| {
8447                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
8448            })
8449            .map(|value| required_str(value, "name"))
8450            .transpose()?
8451        else {
8452            return Ok(HeldPriority::Read(Priority::None));
8453        };
8454        Ok(mapping.priority_of(option).map_or_else(
8455            || HeldPriority::Unmapped(option.to_owned()),
8456            HeldPriority::Read,
8457        ))
8458    }
8459
8460    /// What each projected metadata field holds for one board item, by field name — every text
8461    /// value GitHub answers, an empty one included.
8462    fn held_projections(
8463        &self,
8464        field_values: &[Value],
8465    ) -> Result<BTreeMap<String, String>, SourceError> {
8466        let mut held = BTreeMap::new();
8467        for projection in &self.metadata_fields {
8468            if let Some(text) = text_field(field_values, &projection.field)? {
8469                held.insert(projection.field.to_string(), text);
8470            }
8471        }
8472        Ok(held)
8473    }
8474
8475    /// What one board item's status is read from: its `Status` option, whether its issue
8476    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
8477    /// three into the status it reports.
8478    fn status_parts<'a>(
8479        field_values: &'a [Value],
8480        content: &'a Value,
8481    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
8482        let option = field_values
8483            .iter()
8484            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
8485            .map(|value| required_str(value, "name"))
8486            .transpose()?;
8487        let closed = optional_str(content, "state")? == Some("CLOSED");
8488        Ok((option, closed, optional_str(content, "stateReason")?))
8489    }
8490
8491    /// The board Status option this write selects, or the refusal that says why not.
8492    ///
8493    /// The mapped option is required for both open and terminal targets. A terminal write
8494    /// validates it before changing either representation, so it can never fall back to
8495    /// closing an issue whose board cannot display the matching status.
8496    ///
8497    /// Answers the field's id, the option's id, and the option's name as the board spells
8498    /// it — which is the name a read of the item reports once it sits there.
8499    fn column_for(
8500        &self,
8501        fields: &Value,
8502        kind: ItemKind,
8503        category: StatusCategory,
8504        target: &StatusTarget,
8505    ) -> Result<Option<(String, String, String)>, SourceError> {
8506        let Some(wanted) = target.option() else {
8507            return Ok(None);
8508        };
8509        let missing = |detail: &str| SourceError::Refused {
8510            message: format!(
8511                "{} status {} of source {} needs the board Status option {wanted:?}, and \
8512                 {detail}; next: add that option to the board, which `onetaskgraph sources \
8513                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
8514                 it has",
8515                kind.marker(),
8516                category_name(category),
8517                self.name,
8518                self.name,
8519                category_name(category),
8520                kind.marker()
8521            ),
8522        };
8523        let Some(field) = Board::field(fields, "Status")? else {
8524            return Err(missing("this board has no Status field"));
8525        };
8526        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8527            return Err(missing(
8528                "this board's Status field is not a single-select field",
8529            ));
8530        }
8531        let option = field
8532            .get("options")
8533            .and_then(Value::as_array)
8534            .and_then(|options| {
8535                options.iter().find(|option| {
8536                    option
8537                        .get("name")
8538                        .and_then(Value::as_str)
8539                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8540                })
8541            });
8542        match option {
8543            None => Err(missing("this board does not have it")),
8544            Some(option) => Ok(Some((
8545                required_str(field, "id")?.to_owned(),
8546                required_str(option, "id")?.to_owned(),
8547                required_str(option, "name")?.to_owned(),
8548            ))),
8549        }
8550    }
8551
8552    /// The refusal a status that closes an issue is answered with over a board draft.
8553    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
8554        SourceError::Refused {
8555            message: format!(
8556                "status {} of source {} closes the item's issue, and GitHub draft items have \
8557                 no open or closed state",
8558                category_name(category),
8559                self.name
8560            ),
8561        }
8562    }
8563
8564    /// What a status write to one item needs of the board: the board's id and the
8565    /// definition of its `Status` field, read off the item when the item says both.
8566    ///
8567    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
8568    /// and its `Status` value carries that field's definition, options and all. An item that
8569    /// does not say — no board id, or no `Status` value to read the field off — takes them
8570    /// from [`Self::board_fields`], which reads no item.
8571    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8572        if let Some(board) = item.carried_board() {
8573            return Ok(board);
8574        }
8575        if item.defines("Status")
8576            && let Some(board_id) = item.named_board()
8577        {
8578            return Ok(BoardFields {
8579                id: board_id,
8580                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8581            });
8582        }
8583        self.board_fields().await
8584    }
8585
8586    /// Set one task's or one project's status and nothing else; see
8587    /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
8588    async fn set_status(
8589        &self,
8590        id: &NativeId,
8591        kind: ItemKind,
8592        category: StatusCategory,
8593    ) -> Result<Option<Status>, SourceError> {
8594        // Refused before anything is read, in the words a write of the same status is.
8595        let target = self.resolved_target(kind, category)?;
8596        // An item read by issue reference carries only what this write needs, so what it
8597        // becomes is never remembered for a later read to be answered from.
8598        let held = self.take_held(id)?;
8599        let by_reference = held.is_some();
8600        let bound = match held {
8601            Some(item) => Some(item),
8602            None => self.bound_item(id).await?,
8603        };
8604        let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
8605            return Ok(None);
8606        };
8607        let board = self.status_board(&item).await?;
8608        let (field, option, name) = self
8609            .column_for(&board.fields, kind, category, &target)?
8610            .ok_or_else(|| SourceError::Malformed {
8611                message: format!(
8612                    "status {} of source {} names no board Status option",
8613                    category_name(category),
8614                    self.name
8615                ),
8616            })?;
8617        if item.status.category == category && item.option.as_deref() == Some(&name) {
8618            return Ok(Some(item.status));
8619        }
8620        match &target {
8621            StatusTarget::Terminal(_, reason) => {
8622                if item.content_kind == ContentKind::DraftIssue {
8623                    return Err(self.closes_a_draft(category));
8624                }
8625                // The option first, so a refused selection leaves an open issue open rather
8626                // than closed behind a card that still reads open.
8627                self.set_item_field(
8628                    board.id.as_str(),
8629                    &item.item_id,
8630                    &field,
8631                    json!({"singleSelectOptionId": option}),
8632                )
8633                .await?;
8634                // An issue already closed in a way that reads as this category keeps its state
8635                // and its reason: only its card was out of line, and closing it again would
8636                // overwrite a reason — a duplicate's — that says more than this write does.
8637                let already = item.closed && item.status.category == category;
8638                if !already {
8639                    self.update_content(
8640                        ContentKind::Issue,
8641                        &item.id,
8642                        json!({"stateInput": state_input(Some(&target))}),
8643                    )
8644                    .await?;
8645                }
8646                let reason = if already {
8647                    item.reason.clone()
8648                } else {
8649                    Some(reason.reason().to_owned())
8650                };
8651                item.closed = true;
8652                item.status = self
8653                    .statuses
8654                    .status(kind, Some(&name), true, reason.as_deref());
8655                item.option = Some(name);
8656                item.reason = reason;
8657            }
8658            StatusTarget::Column(_) => {
8659                // An option is what an open item's status is, so a closed issue is reopened
8660                // first — sitting closed in the column, it would read back as closed. A draft has
8661                // no state to reopen.
8662                if item.content_kind == ContentKind::Issue && item.closed {
8663                    self.update_content(
8664                        ContentKind::Issue,
8665                        &item.id,
8666                        json!({"stateInput": state_input(Some(&target))}),
8667                    )
8668                    .await?;
8669                    item.closed = false;
8670                }
8671                self.set_item_field(
8672                    board.id.as_str(),
8673                    &item.item_id,
8674                    &field,
8675                    json!({"singleSelectOptionId": option}),
8676                )
8677                .await?;
8678                item.status = self.statuses.status(kind, Some(&name), false, None);
8679                item.option = Some(name);
8680            }
8681            StatusTarget::Disabled(_) => {
8682                unreachable!("resolved_target refused a disabled status")
8683            }
8684        }
8685        let status = item.status.clone();
8686        if !by_reference {
8687            self.remember_written(item, false)?;
8688        }
8689        Ok(Some(status))
8690    }
8691
8692    /// Replace one task's `delivered_by` and nothing else; see
8693    /// [`TaskSource::set_delivered_by`].
8694    ///
8695    /// One update of the body, which differs from the body GitHub holds only inside the
8696    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
8697    async fn replace_delivered_by(
8698        &self,
8699        id: &NativeId,
8700        delivered_by: &[TaskRef],
8701    ) -> Result<Option<()>, SourceError> {
8702        let entries = TaskRef::listed(
8703            TaskRef::DELIVERED_BY_KEY,
8704            id,
8705            Some(&self.name),
8706            delivered_by.to_vec(),
8707        )
8708        .map_err(|message| SourceError::Refused { message })?;
8709        let Some(mut item) = self
8710            .bound_item(id)
8711            .await?
8712            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8713        else {
8714            return Ok(None);
8715        };
8716        let mut slot = item.slot.clone();
8717        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
8718        self.write_slot(&mut item, &slot).await?;
8719        item.delivered_by = entries;
8720        self.remember_written(item, false)?;
8721        Ok(Some(()))
8722    }
8723
8724    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
8725    /// see [`TaskSource::set_task_metadata`].
8726    ///
8727    /// `None` when this board holds no item by that id, or holds one of another kind. The
8728    /// answer is the item as this source now reads it, so what a caller is told the key
8729    /// holds is what the slot holds.
8730    ///
8731    /// A key already holding the value is answered without a write, compared as JSON rather
8732    /// than as the body's bytes: a slot a person spelled with other whitespace would
8733    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
8734    async fn set_slot_key(
8735        &self,
8736        id: &NativeId,
8737        kind: BoardKind,
8738        key: &MetadataKey,
8739        value: &Value,
8740    ) -> Result<Option<Resolved>, SourceError> {
8741        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8742            return Ok(None);
8743        };
8744        if item.slot.get(key.as_str()) == Some(value) {
8745            return Ok(Some(item));
8746        }
8747        let mut slot = item.slot.clone();
8748        slot.insert(key.as_str().to_owned(), value.clone());
8749        // A key a board text field projects moves that field first, so the body — the value's
8750        // one authoritative home — is written last, as every write of an existing item is.
8751        if self
8752            .metadata_fields
8753            .iter()
8754            .any(|projection| &*projection.key == key.as_str())
8755        {
8756            let board = self.projection_board(&item).await?;
8757            let (writes, projected) = self.projection_writes(
8758                &board.fields,
8759                Some(&item.projected),
8760                &slot,
8761                Some(&[key.as_str()]),
8762            )?;
8763            let (mut values, mut clears) = (Vec::new(), Vec::new());
8764            for write in &writes {
8765                write.join(&mut values, &mut clears);
8766            }
8767            self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
8768                .await?;
8769            item.projected = projected;
8770        }
8771        self.write_slot(&mut item, &slot).await?;
8772        self.remember_written(item.clone(), false)?;
8773        Ok(Some(item))
8774    }
8775
8776    /// What a projection write to `item` needs of the board, read off the item when it says
8777    /// enough, as [`Self::fields_for`] reads it.
8778    async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8779        if let Some(board) = item.carried_board() {
8780            return Ok(board);
8781        }
8782        if let Some(board_id) = item.named_board()
8783            && self
8784                .metadata_fields
8785                .iter()
8786                .all(|projection| item.defines(&projection.field))
8787        {
8788            return Ok(BoardFields {
8789                id: board_id,
8790                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8791            });
8792        }
8793        self.board_fields().await
8794    }
8795
8796    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
8797    /// `item` up to what that write left.
8798    ///
8799    /// The body sent differs from the body GitHub holds only inside the slot — see
8800    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
8801    /// the mutation the item's content takes, so a board draft's body is written with
8802    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
8803    async fn write_slot(
8804        &self,
8805        item: &mut Resolved,
8806        slot: &BTreeMap<String, Value>,
8807    ) -> Result<(), SourceError> {
8808        let held = item.raw_body.clone().unwrap_or_default();
8809        let body = with_slot(&held, slot)?;
8810        if body != held {
8811            self.update_content(item.content_kind, &item.id, json!({"body": body}))
8812                .await?;
8813        }
8814        let (visible, slot) = self.visible(Some(body.clone()))?;
8815        item.body = visible.filter(|value| !value.is_empty());
8816        item.raw_body = Some(body);
8817        item.slot = slot;
8818        Ok(())
8819    }
8820
8821    /// This instance's target for a category written to an item of `kind`, refusing one
8822    /// that kind has no option for — before anything is read or written.
8823    ///
8824    /// Nothing here mutates the board's option set to make room for a status. GitHub
8825    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
8826    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
8827    /// field and every item's status.
8828    fn resolved_target(
8829        &self,
8830        kind: ItemKind,
8831        category: StatusCategory,
8832    ) -> Result<StatusTarget, SourceError> {
8833        let target = self.statuses.target(kind, category).clone();
8834        let StatusTarget::Disabled(why) = target else {
8835            return Ok(target);
8836        };
8837        let refusal = why.refusal(&self.name, category, kind);
8838        // Why there is no shipped default, which is the question a person meeting this
8839        // refusal on a source that never mentioned the category asks.
8840        let shipped_none = match category {
8841            StatusCategory::Draft => Some(
8842                "draft has no shipped default because GitHub draft issues cannot have \
8843                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
8844            ),
8845            StatusCategory::Unknown => Some(
8846                "unknown has no shipped default because this board keeps no open-ended status \
8847                 word: every word classified unknown is written to the one board Status option \
8848                 status_mapping.unknown names",
8849            ),
8850            _ => None,
8851        };
8852        Err(match (refusal, shipped_none, why) {
8853            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
8854                SourceError::Refused {
8855                    message: format!("{message}; {note}"),
8856                }
8857            }
8858            (refusal, _, _) => refusal,
8859        })
8860    }
8861
8862    /// What writing `priority` does to one item's `Priority` field on this board, or the
8863    /// refusal naming what the board lacks.
8864    ///
8865    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
8866    /// none already, or of an item not created yet. Every other priority selects the option
8867    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
8868    /// without that option, is refused rather than given one: reads and writes never create
8869    /// a field or an option.
8870    fn priority_write(
8871        &self,
8872        fields: &Value,
8873        existing: Option<&Resolved>,
8874        priority: Priority,
8875    ) -> Result<Option<PriorityWrite>, SourceError> {
8876        let Some(mapping) = &self.priorities else {
8877            return Err(self.holds_no_priority());
8878        };
8879        let Some(wanted) = mapping.option(priority) else {
8880            if !existing.is_some_and(Resolved::holds_priority) {
8881                return Ok(None);
8882            }
8883            let field =
8884                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
8885                    message: format!(
8886                        "an item holding a {PRIORITY_FIELD} value was read without that field"
8887                    ),
8888                })?;
8889            return Ok(Some(PriorityWrite::Clear {
8890                field: required_str(field, "id")?.to_owned(),
8891            }));
8892        };
8893        let missing = |detail: &str| SourceError::Refused {
8894            message: format!(
8895                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
8896                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
8897                 it, or point priority_mapping.{priority} of this source at an option the board \
8898                 has",
8899                self.name, self.name
8900            ),
8901        };
8902        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
8903            return Err(missing(&format!(
8904                "this board has no {PRIORITY_FIELD} field"
8905            )));
8906        };
8907        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8908            return Err(missing(&format!(
8909                "this board's {PRIORITY_FIELD} field is not a single-select field"
8910            )));
8911        }
8912        // An options list that is absent or not a list is an answer this source cannot read,
8913        // not a board lacking the option: `sources fields --apply` is no remedy for it.
8914        let option = field
8915            .get("options")
8916            .and_then(Value::as_array)
8917            .ok_or_else(|| SourceError::Malformed {
8918                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
8919            })?
8920            .iter()
8921            .find(|option| {
8922                option
8923                    .get("name")
8924                    .and_then(Value::as_str)
8925                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8926            })
8927            .ok_or_else(|| missing("this board does not have it"))?;
8928        Ok(Some(PriorityWrite::Select {
8929            field: required_str(field, "id")?.to_owned(),
8930            option: required_str(option, "id")?.to_owned(),
8931        }))
8932    }
8933
8934    /// What writing `metadata` does to each projected text field of one item, and what those
8935    /// fields hold after it — or the refusal, before anything is sent.
8936    ///
8937    /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
8938    /// narrows the work to the entries projecting one of those metadata keys, for a write that
8939    /// touches only them; `None` is every entry. A string the field already holds, and nothing
8940    /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
8941    /// and so is a board without the field, or with a field of that name that is not a text
8942    /// field: reads and writes never create a field.
8943    fn projection_writes(
8944        &self,
8945        fields: &Value,
8946        held: Option<&BTreeMap<String, String>>,
8947        metadata: &BTreeMap<String, Value>,
8948        keys: Option<&[&str]>,
8949    ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
8950        let mut projected = held.cloned().unwrap_or_default();
8951        let mut writes = Vec::new();
8952        for projection in self
8953            .metadata_fields
8954            .iter()
8955            .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
8956        {
8957            let wanted = projection.projected(metadata, &self.name)?;
8958            let field = self.projection_field(fields, projection)?;
8959            let holding = projected.get(&*projection.field);
8960            match wanted {
8961                Projected::Text(text) if holding != Some(&text) => {
8962                    writes.push(ProjectionWrite::Set {
8963                        field,
8964                        text: text.clone(),
8965                    });
8966                    projected.insert(projection.field.to_string(), text);
8967                }
8968                Projected::Nothing if holding.is_some() => {
8969                    writes.push(ProjectionWrite::Clear { field });
8970                    projected.remove(&*projection.field);
8971                }
8972                Projected::Text(_) | Projected::Nothing => {}
8973            }
8974        }
8975        Ok((writes, projected))
8976    }
8977
8978    /// The id of the board text field one projection writes, or the refusal naming what the
8979    /// board has instead.
8980    fn projection_field(
8981        &self,
8982        fields: &Value,
8983        projection: &MetadataField,
8984    ) -> Result<String, SourceError> {
8985        let refuse = |detail: String| SourceError::Refused {
8986            message: format!(
8987                "source {} projects metadata key {:?} onto the board text field {:?}, and \
8988                 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
8989                 text field, or name another field in metadata_fields",
8990                self.name, &*projection.key, &*projection.field, self.name
8991            ),
8992        };
8993        let Some(field) = Board::field(fields, &projection.field)? else {
8994            return Err(refuse("this board has no field of that name".to_owned()));
8995        };
8996        let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
8997        let data_type = optional_str(field, "dataType")?;
8998        if typename != "ProjectV2Field" || data_type != Some("TEXT") {
8999            return Err(refuse(format!(
9000                "this board's field of that name is not a text field (it is a {}), so it is \
9001                 left as it is; rename or remove that field first",
9002                data_type.unwrap_or(typename)
9003            )));
9004        }
9005        Ok(required_str(field, "id")?.to_owned())
9006    }
9007
9008    /// Apply one priority write to one board item.
9009    async fn write_priority(
9010        &self,
9011        board_id: &str,
9012        item_id: &str,
9013        write: &PriorityWrite,
9014    ) -> Result<(), SourceError> {
9015        match write {
9016            PriorityWrite::Select { field, option } => {
9017                self.set_item_field(
9018                    board_id,
9019                    item_id,
9020                    field,
9021                    json!({"singleSelectOptionId": option}),
9022                )
9023                .await
9024            }
9025            PriorityWrite::Clear { field } => {
9026                let data = self
9027                    .graphql(
9028                        graphql::CLEAR_FIELD,
9029                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
9030                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
9031                    )
9032                    .await?;
9033                let returned = data
9034                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
9035                    .ok_or_else(|| SourceError::Malformed {
9036                        message: "GitHub field clear returned no project item".into(),
9037                    })?;
9038                if required_str(returned, "id")? != item_id {
9039                    return Err(SourceError::Malformed {
9040                        message: "GitHub field clear returned the wrong project item".into(),
9041                    });
9042                }
9043                Ok(())
9044            }
9045        }
9046    }
9047
9048    /// The refusal a priority is answered with by an instance configured with no
9049    /// `priority_mapping`, which holds none.
9050    fn holds_no_priority(&self) -> SourceError {
9051        SourceError::Refused {
9052            message: format!(
9053                "source {} holds no task priority: its configuration sets no priority_mapping; \
9054                 next: set priority_mapping on this source, then run `onetaskgraph sources \
9055                 fields {} --apply` to set its board up",
9056                self.name, self.name
9057            ),
9058        }
9059    }
9060
9061    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
9062    ///
9063    /// One field write — a select, or a clear for `none` — and no title, body, label, state
9064    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
9065    async fn set_priority(
9066        &self,
9067        id: &NativeId,
9068        priority: Priority,
9069    ) -> Result<Option<Priority>, SourceError> {
9070        if self.priorities.is_none() {
9071            return Err(self.holds_no_priority());
9072        }
9073        let Some(mut item) = self
9074            .bound_item(id)
9075            .await?
9076            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9077        else {
9078            return Ok(None);
9079        };
9080        if priority == Priority::None && !item.holds_priority() {
9081            return Ok(Some(priority));
9082        }
9083        // The item's own read carries the field's definition whenever it holds a value of
9084        // it, which a clear always does; a select onto an item holding none reads the board.
9085        let board = match (item.carried_board(), item.named_board()) {
9086            (Some(board), _) => board,
9087            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
9088                id,
9089                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
9090            },
9091            _ => self.board_fields().await?,
9092        };
9093        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
9094            return Ok(Some(priority));
9095        };
9096        let (document, root, input) = match write {
9097            PriorityWrite::Select { field, option } => (
9098                graphql::UPDATE_FIELD,
9099                "updateProjectV2ItemFieldValue",
9100                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
9101            ),
9102            PriorityWrite::Clear { field } => (
9103                graphql::CLEAR_FIELD,
9104                "clearProjectV2ItemFieldValue",
9105                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
9106            ),
9107        };
9108        let data = self
9109            .graphql(
9110                document,
9111                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
9112            )
9113            .await?;
9114        let returned = data
9115            .get(root)
9116            .and_then(|value| value.get("projectV2Item"))
9117            .ok_or_else(|| SourceError::Malformed {
9118                message: "GitHub priority write returned no project item".into(),
9119            })?;
9120        if required_str(returned, "id")? != item.item_id {
9121            return Err(SourceError::Malformed {
9122                message: "GitHub priority write returned the wrong project item".into(),
9123            });
9124        }
9125        let value = returned
9126            .get("fieldValueByName")
9127            .ok_or_else(|| SourceError::Malformed {
9128                message: "GitHub priority write returned no priority read-back".into(),
9129            })?;
9130        if !value.is_null()
9131            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
9132        {
9133            return Err(SourceError::Malformed {
9134                message: "GitHub priority read-back is not a Priority field value".into(),
9135            });
9136        }
9137        let values = if value.is_null() {
9138            Vec::new()
9139        } else {
9140            vec![value.clone()]
9141        };
9142        item.priority = self.held_priority(&values)?;
9143        let answer = item.task()?.priority;
9144        self.remember_written(item, false)?;
9145        Ok(Some(answer))
9146    }
9147
9148    /// Replace one task's visible body and nothing else; see
9149    /// [`TaskSource::set_task_content`].
9150    ///
9151    /// One update of the body, which differs from the body GitHub holds only outside the
9152    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
9153    /// this source keeps there reads back as it was. A body that would not change is not
9154    /// sent at all.
9155    async fn replace_content(
9156        &self,
9157        id: &NativeId,
9158        content: &str,
9159    ) -> Result<Option<()>, SourceError> {
9160        let Some(mut item) = self
9161            .bound_item(id)
9162            .await?
9163            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9164        else {
9165            return Ok(None);
9166        };
9167        let held = item.raw_body.clone().unwrap_or_default();
9168        // The task's `## Links` section, re-rendered from the links it holds, stays with it.
9169        let content = &onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &item.slot);
9170        let body = with_content(&held, &self.marked(content))?;
9171        // Checked before anything is sent: content ending in what this source reads as its own
9172        // metadata slot would read back as metadata rather than as the content it was.
9173        let (visible, slot) = self.visible(Some(body.clone()))?;
9174        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
9175            return Err(SourceError::Refused {
9176                message: format!(
9177                    "this content ends in what source {} reads as its own metadata slot \
9178                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9179                     as content; next: remove that trailing block from the content",
9180                    self.name
9181                ),
9182            });
9183        }
9184        if body != held {
9185            self.update_content(item.content_kind, &item.id, json!({"body": body}))
9186                .await?;
9187        }
9188        item.body = visible.filter(|value| !value.is_empty());
9189        item.raw_body = Some(body);
9190        item.slot = slot;
9191        self.remember_written(item, false)?;
9192        Ok(Some(()))
9193    }
9194
9195    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
9196    ///
9197    /// One read of the item — which carries the board's field definitions and the issue's
9198    /// `blockedBy`, so neither is read again — and then only what differs from it: the
9199    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
9200    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
9201    /// the title, the body — visible content and metadata slot together — and a state change.
9202    /// So an update naming any of title, body, metadata, status and priority is one read and
9203    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
9204    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
9205    /// as a whole write does; an open one selects its option and then reopens. The origin
9206    /// field is never written: an update is of an item that already exists, whose origin is
9207    /// what it is.
9208    ///
9209    /// The task answered is the item as those writes left it, built from the read and what was
9210    /// sent rather than read again — the same record a later read in this run answers from.
9211    ///
9212    /// Its `version` is the one the task holds after the writes: the change token the last
9213    /// mutation's own response spells — each sent as the twin selecting the whole issue, at
9214    /// the price of the plain document — or, where that mutation shows no whole issue (several
9215    /// board fields written together, or `blockedBy` alone), the token one read right after
9216    /// the writes spells. Never the token the item was read at before them. Against GitHub
9217    /// itself that token has not settled yet (`change_token::SETTLED`), so a read gives none
9218    /// until it has, and a write held to it before then is refused rather than let through;
9219    /// once settled, a read gives exactly this token unless the task changed again.
9220    async fn targeted_update(
9221        &self,
9222        id: &NativeId,
9223        update: &TaskUpdate,
9224    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9225        Box::pin(SPELLING.scope(
9226            std::ptr::from_ref(self) as usize,
9227            self.composed_update(id, update),
9228        ))
9229        .await
9230    }
9231
9232    /// [`Self::targeted_update`], inside the scope that has its mutations answer whole.
9233    async fn composed_update(
9234        &self,
9235        id: &NativeId,
9236        update: &TaskUpdate,
9237    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9238        // Everything this source can refuse without reading the item is refused first, in the
9239        // words a whole write of the same fields is refused with.
9240        update.consistent()?;
9241        if update
9242            .title
9243            .as_deref()
9244            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
9245        {
9246            return Err(SourceError::Refused {
9247                message: format!(
9248                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
9249                     spells a document, so it would read back as one rather than as a task; \
9250                     retitle it",
9251                    self.name
9252                ),
9253            });
9254        }
9255        if let Some(delivers) = &update.delivers {
9256            TaskRef::listed(
9257                TaskRef::DELIVERS_KEY,
9258                id,
9259                Some(&self.name),
9260                delivers.clone(),
9261            )
9262            .map_err(|message| SourceError::Refused { message })?;
9263        }
9264        if self.priorities.is_none()
9265            && update
9266                .priority
9267                .is_some_and(|priority| priority != Priority::None)
9268        {
9269            return Err(self.holds_no_priority());
9270        }
9271        let target = update
9272            .status
9273            .as_ref()
9274            .map(|status| self.resolved_target(ItemKind::Task, status.category))
9275            .transpose()?;
9276        let Some(mut item) = self
9277            .bound_item(id)
9278            .await?
9279            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9280        else {
9281            return Ok(None);
9282        };
9283        let before = item.task()?;
9284
9285        let mut status_move = None;
9286        if let (Some(status), Some(target)) = (&update.status, target) {
9287            let board = self.status_board(&item).await?;
9288            let (field, option, name) = self
9289                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
9290                .ok_or_else(|| SourceError::Malformed {
9291                    message: format!(
9292                        "status {} of source {} names no board Status option",
9293                        category_name(status.category),
9294                        self.name
9295                    ),
9296                })?;
9297            let terminal = matches!(target, StatusTarget::Terminal(_, _));
9298            if terminal && item.content_kind == ContentKind::DraftIssue {
9299                return Err(self.closes_a_draft(status.category));
9300            }
9301            let landed = match &target {
9302                StatusTarget::Terminal(_, reason) => {
9303                    self.statuses
9304                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
9305                }
9306                _ => self
9307                    .statuses
9308                    .status(ItemKind::Task, Some(&name), false, None),
9309            };
9310            let option_moves = item
9311                .option
9312                .as_deref()
9313                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
9314            let state_moves = item.content_kind == ContentKind::Issue
9315                && (item.closed != terminal || (terminal && item.status != landed));
9316            if let Some(moves) = Moves::of(option_moves, state_moves) {
9317                status_move = Some(StatusMove {
9318                    board: board.id,
9319                    field,
9320                    option,
9321                    name,
9322                    target,
9323                    landed,
9324                    moves,
9325                });
9326            }
9327        }
9328
9329        let mut priority_move = None;
9330        if let Some(priority) = update.priority
9331            && self.priorities.is_some()
9332            && item.priority != HeldPriority::Read(priority)
9333        {
9334            let board = match (item.carried_board(), item.named_board()) {
9335                (Some(board), _) => board,
9336                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
9337                    id: board,
9338                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
9339                },
9340                _ => self.board_fields().await?,
9341            };
9342            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
9343                priority_move = Some((board.id, write, priority));
9344            }
9345        }
9346
9347        // The metadata keys this update names that a board text field projects, and what
9348        // that does to those fields — refused, as a whole write refuses it, before anything
9349        // is sent.
9350        let touched: Vec<&str> = update
9351            .metadata_set
9352            .keys()
9353            .chain(update.metadata_remove.iter())
9354            .map(MetadataKey::as_str)
9355            .collect();
9356        let mut projection_move = None;
9357        if self
9358            .metadata_fields
9359            .iter()
9360            .any(|projection| touched.contains(&&*projection.key))
9361        {
9362            let mut metadata = item.slot.clone();
9363            for (key, value) in &update.metadata_set {
9364                metadata.insert(key.as_str().to_owned(), value.clone());
9365            }
9366            for key in &update.metadata_remove {
9367                metadata.remove(key.as_str());
9368            }
9369            let board = self.projection_board(&item).await?;
9370            let (writes, projected) = self.projection_writes(
9371                &board.fields,
9372                Some(&item.projected),
9373                &metadata,
9374                Some(&touched),
9375            )?;
9376            projection_move = Some((board.id, writes, projected));
9377        }
9378
9379        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
9380        // recorded in the slot, and the slot travels in the one body update below.
9381        let edges = match &update.depends_on {
9382            Some(edges) => Some(
9383                self.partition_edges(
9384                    BoardKind::Work(ItemKind::Task),
9385                    item.content_kind,
9386                    item.blocked_by.as_deref(),
9387                    edges,
9388                )
9389                .await?,
9390            ),
9391            None => None,
9392        };
9393
9394        // A record primed from what a producer wrote holds no body anybody read, so an update
9395        // changing the body reads it first: by the `Status` write's own answer when the update
9396        // moves the option — the mutation answering with the issue whole — and otherwise by
9397        // reading the issue. The body is then composed against what GitHub holds now.
9398        let mut option_selected = false;
9399        // The mark stays until a read lands, so an update that writes no body, or one whose
9400        // read fails, leaves the next update of it to read the body too.
9401        if (update.content.is_some()
9402            || !update.metadata_set.is_empty()
9403            || !update.metadata_remove.is_empty()
9404            || update.delivers.is_some()
9405            || update.repositories.is_some()
9406            || edges.is_some())
9407            && self.is_unread(id)
9408        {
9409            let live = match status_move.as_ref().filter(|moving| moving.moves.option()) {
9410                Some(moving) => {
9411                    option_selected = true;
9412                    Some(
9413                        self.select_reading_body(
9414                            moving.board.as_str(),
9415                            &item.item_id,
9416                            &moving.field,
9417                            &moving.option,
9418                        )
9419                        .await?,
9420                    )
9421                }
9422                None => {
9423                    self.forget(id)?;
9424                    let Some(read) = self.item_by_id(id).await? else {
9425                        return Ok(None);
9426                    };
9427                    read.raw_body
9428                }
9429            };
9430            let (visible, read_slot) = self.visible(live.clone())?;
9431            self.mark_read(id);
9432            item.raw_body = live;
9433            item.body = visible;
9434            item.slot = read_slot;
9435        }
9436
9437        let mut slot = item.slot.clone();
9438        for (key, value) in &update.metadata_set {
9439            slot.insert(key.as_str().to_owned(), value.clone());
9440        }
9441        for key in &update.metadata_remove {
9442            slot.remove(key.as_str());
9443        }
9444        if let Some(delivers) = &update.delivers {
9445            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
9446        }
9447        // The list a whole write records, by the same rule: none when it is exactly the issue's
9448        // own repository, or empty with no issue repository to derive, and otherwise the list.
9449        if let Some(repositories) = &update.repositories {
9450            let derivable = item
9451                .own_repository
9452                .as_ref()
9453                .map_or(repositories.is_empty(), |own| {
9454                    repositories.as_slice() == std::slice::from_ref(own)
9455                });
9456            if derivable {
9457                slot.remove(Repository::METADATA_KEY);
9458            } else {
9459                slot.insert(
9460                    Repository::METADATA_KEY.to_owned(),
9461                    Value::Array(
9462                        repositories
9463                            .iter()
9464                            .map(|repository| Value::String(repository.as_str().to_owned()))
9465                            .collect(),
9466                    ),
9467                );
9468            }
9469        }
9470        if let Some((_, recorded)) = &edges {
9471            record_edges(&mut slot, recorded);
9472        }
9473        let held = item.raw_body.clone().unwrap_or_default();
9474        // A content write keeps the task's `## Links` section, re-rendered from the links the
9475        // slot holds once written.
9476        let relinked = update
9477            .content
9478            .as_deref()
9479            .map(|content| onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot));
9480        let content = match &relinked {
9481            Some(content) => with_content(&held, &self.marked(content))?,
9482            None => held.clone(),
9483        };
9484        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
9485        // the body's bytes, as a metadata write compares it: a slot a person spelled with
9486        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
9487        let body = if slot == item.slot {
9488            content
9489        } else {
9490            with_slot(&content, &slot)?
9491        };
9492        // Checked before anything is sent, as a content write checks it: content ending in
9493        // what this source reads as its own slot would read back as metadata.
9494        let (visible, read) = self.visible(Some(body.clone()))?;
9495        let wanted = relinked.as_deref().or(item.body.as_deref());
9496        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
9497            return Err(SourceError::Refused {
9498                message: format!(
9499                    "this content ends in what source {} reads as its own metadata slot \
9500                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9501                     as content; next: remove that trailing block from the content",
9502                    self.name
9503                ),
9504            });
9505        }
9506        let recorded_moves =
9507            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
9508
9509        // One `updateIssue` carries all three, because every mutation spends the secondary
9510        // limiter and the title, body and state are one mutation's inputs.
9511        let mut fields = serde_json::Map::new();
9512        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
9513            fields.insert("title".to_owned(), json!(title));
9514        }
9515        if body != held {
9516            fields.insert("body".to_owned(), json!(body));
9517        }
9518        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
9519            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
9520        }
9521        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
9522        // GitHub runs no two requests as one, and runs one document's mutation fields in order
9523        // without undoing an earlier field when a later one fails — so a body written before a
9524        // board field the board then refused would be left changed. Written after every other
9525        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
9526        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
9527        // first, together in one request — a terminal option selected before the issue
9528        // closes, as a whole write does — then the `blockedBy` difference, then the body.
9529        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
9530        let mut clears: Vec<(&BoardId, &str)> = Vec::new();
9531        if let Some(moving) = status_move
9532            .as_ref()
9533            .filter(|moving| moving.moves.option() && !option_selected)
9534        {
9535            board_writes.push((
9536                &moving.board,
9537                (
9538                    moving.field.clone(),
9539                    json!({"singleSelectOptionId": moving.option}),
9540                ),
9541            ));
9542        }
9543        match &priority_move {
9544            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
9545                board,
9546                (field.clone(), json!({"singleSelectOptionId": option})),
9547            )),
9548            Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
9549            None => {}
9550        }
9551        if let Some((board, writes, _)) = &projection_move {
9552            for write in writes {
9553                let (mut values, mut cleared) = (Vec::new(), Vec::new());
9554                write.join(&mut values, &mut cleared);
9555                board_writes.extend(values.into_iter().map(|value| (board, value)));
9556                clears.extend(cleared.into_iter().map(|field| (board, field)));
9557            }
9558        }
9559        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
9560        boards.extend(clears.iter().map(|(board, _)| *board));
9561        boards.dedup_by(|one, other| one.as_str() == other.as_str());
9562        for board in boards {
9563            let writes = board_writes
9564                .iter()
9565                .filter(|(on, _)| on.as_str() == board.as_str())
9566                .map(|(_, write)| write.clone())
9567                .collect::<Vec<_>>();
9568            let cleared = clears
9569                .iter()
9570                .filter(|(on, _)| on.as_str() == board.as_str())
9571                .map(|(_, field)| *field)
9572                .collect::<Vec<_>>();
9573            self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
9574                .await?;
9575        }
9576        // Whether anything is sent: what the version answered has to come after.
9577        let sends =
9578            option_selected || !board_writes.is_empty() || !clears.is_empty() || !fields.is_empty();
9579        let mut blocked_by_moved = false;
9580        if let Some((native, _)) = &edges
9581            && item.content_kind == ContentKind::Issue
9582        {
9583            blocked_by_moved = self
9584                .reconcile_blocked_by(
9585                    &item.id,
9586                    native,
9587                    Issue::Existing(item.blocked_by.as_deref()),
9588                )
9589                .await?;
9590        }
9591        if !fields.is_empty() {
9592            self.update_content(item.content_kind, &item.id, Value::Object(fields))
9593                .await?;
9594        }
9595
9596        // The `blockedBy` this item was read with is what the write just changed, so it is no
9597        // answer any more: a later write in this command would reconcile against it — re-adding
9598        // what it kept and never removing what it dropped — and a forward-edge read would
9599        // answer with it. Left unknown, each asks GitHub instead.
9600        if blocked_by_moved {
9601            item.blocked_by = None;
9602        }
9603        if let Some(title) = &update.title {
9604            item.title.clone_from(title);
9605        }
9606        item.body = visible.filter(|value| !value.is_empty());
9607        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
9608        item.slot = slot;
9609        if let Some(delivers) = &update.delivers {
9610            item.delivers.clone_from(delivers);
9611        }
9612        if let Some(repositories) = &update.repositories {
9613            item.repositories.clone_from(repositories);
9614        }
9615        if let Some(moving) = status_move {
9616            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
9617                && item.content_kind == ContentKind::Issue;
9618            item.status = moving.landed;
9619            item.option = Some(moving.name);
9620        }
9621        if let Some((_, _, priority)) = priority_move {
9622            item.priority = HeldPriority::Read(priority);
9623        }
9624        if let Some((_, _, projected)) = projection_move {
9625            item.projected = projected;
9626        }
9627        let mut task = item.task()?;
9628        let mut written = update.changed(&before, &task);
9629        if blocked_by_moved || recorded_moves {
9630            written.insert(UpdatedField::DependsOn);
9631        }
9632        if sends || blocked_by_moved {
9633            // The token the item was read at is not the one it holds now. A batch of updates
9634            // holding this one's writes back reads every task it wrote once they are sent.
9635            task.version = if self.holding_writes_of(id) {
9636                None
9637            } else {
9638                self.version_after_write(id).await
9639            };
9640        }
9641        self.remember_written(item, false)?;
9642        Ok(Some(TaskUpdateOutcome {
9643            task,
9644            written,
9645            delivers_before: before.delivers,
9646        }))
9647    }
9648
9649    /// The change token the task `id` holds after the writes a targeted update just sent: the
9650    /// one the last mutation's own response showed, or, where none showed the whole issue, the
9651    /// one a read made now spells. `None` when that read cannot say — never the token the
9652    /// task was read at before the writes.
9653    async fn version_after_write(&self, id: &NativeId) -> Option<String> {
9654        // A response that showed the issue whole answers, a token or none: a read made now
9655        // would read the same parts.
9656        if let Some(answered) = self.written_answer(id) {
9657            return answered;
9658        }
9659        // An item this command created is answered from its own record, not read.
9660        if self.created().ok()?.iter().any(|own| own.id == *id) {
9661            return None;
9662        }
9663        let read = self.item_by_id(id).await.ok()??;
9664        let token = read.written_version()?;
9665        self.remember_written_token(&read, token.clone());
9666        Some(token)
9667    }
9668
9669    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
9670    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
9671    ///
9672    /// One update of the body: the content outside the slot, and inside it that one entry,
9673    /// every other entry kept as it was. This source keeps no template answers — an issue has
9674    /// no room beside itself that is not its body, and answers written there would duplicate
9675    /// what the content already says and count against GitHub's body limit — so `answers`
9676    /// reaches nothing here. A body that would not change is not sent at all.
9677    async fn replace_rendering(
9678        &self,
9679        id: &NativeId,
9680        kind: BoardKind,
9681        content: &str,
9682        provenance: &Value,
9683        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
9684    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9685        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
9686            return Ok(None);
9687        };
9688        let held = item.raw_body.clone().unwrap_or_default();
9689        let mut slot = item.slot.clone();
9690        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
9691        let rewritten;
9692        let content = if let Some(assets) = assets {
9693            let uploads = self
9694                .upload_assets(item.own_repository.as_ref(), assets)
9695                .await?;
9696            rewritten =
9697                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
9698            rewritten.as_str()
9699        } else {
9700            content
9701        };
9702        let relinked = onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot);
9703        let content = relinked.as_str();
9704        let body = with_slot(&with_content(&held, &self.marked(content))?, &slot)?;
9705        // Checked before anything is sent, as a content write checks it.
9706        let (visible, read) = self.visible(Some(body.clone()))?;
9707        if visible.as_deref().unwrap_or_default() != content || read != slot {
9708            return Err(SourceError::Refused {
9709                message: format!(
9710                    "this content ends in what source {} reads as its own metadata slot \
9711                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9712                     as content; next: remove that trailing block from the template",
9713                    self.name
9714                ),
9715            });
9716        }
9717        if body != held {
9718            self.update_content(item.content_kind, &item.id, json!({"body": body}))
9719                .await?;
9720        }
9721        item.body = visible.filter(|value| !value.is_empty());
9722        item.raw_body = Some(body);
9723        // The content as a read answers it: without the `## Links` section of a linked task.
9724        let answered = onetaskgraph_plugin_api::read_content(Some(content.to_owned()), &read);
9725        item.slot = read;
9726        self.remember_written(item, false)?;
9727        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
9728            id: id.clone(),
9729            content: answered,
9730        }))
9731    }
9732
9733    async fn set_item_field(
9734        &self,
9735        board_id: &str,
9736        item_id: &str,
9737        field_id: &str,
9738        value: Value,
9739    ) -> Result<(), SourceError> {
9740        let data = self
9741            .graphql(
9742                graphql::UPDATE_FIELD,
9743                json!({"input":{
9744                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
9745                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
9746            )
9747            .await?;
9748        field_update_item(&data, item_id).map(|_| ())
9749    }
9750
9751    /// GitHub accepts one value per field mutation; aliases combine those mutations in
9752    /// one request — writes in the order given, then clears. A lone write is
9753    /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
9754    /// request's slots hold go in further requests of the same document, in order. Every
9755    /// returned item id is checked, including optional aliases.
9756    async fn set_item_fields(
9757        &self,
9758        board: &str,
9759        item: &str,
9760        fields: &[(String, Value)],
9761        clears: &[&str],
9762    ) -> Result<(), SourceError> {
9763        if !(fields.is_empty() && clears.is_empty())
9764            && self.hold_fields(board, item, fields, clears)?
9765        {
9766            return Ok(());
9767        }
9768        match (fields, clears) {
9769            ([], []) => return Ok(()),
9770            ([(field, value)], []) => {
9771                return self.set_item_field(board, item, field, value.clone()).await;
9772            }
9773            ([], [field]) => {
9774                return self
9775                    .write_priority(
9776                        board,
9777                        item,
9778                        &PriorityWrite::Clear {
9779                            field: (*field).to_owned(),
9780                        },
9781                    )
9782                    .await;
9783            }
9784            _ => {}
9785        }
9786        // Any field of the request stands in for an input a slot leaves out, which GitHub
9787        // still requires to be well formed although it never runs it.
9788        let filler = fields
9789            .first()
9790            .map_or_else(|| clears[0], |(field, _)| field.as_str());
9791        let mut writes = fields.iter();
9792        let mut cleared = clears.iter();
9793        loop {
9794            let batch: Vec<&(String, Value)> =
9795                writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
9796            let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
9797            if batch.is_empty() && batch_clears.is_empty() {
9798                return Ok(());
9799            }
9800            let mut variables = serde_json::Map::new();
9801            for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
9802                let input = match batch.get(index) {
9803                    Some((field, value)) => {
9804                        json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
9805                    }
9806                    None => json!({"projectId":board,"itemId":item,"fieldId":filler,
9807                                   "value":{"text":""}}),
9808                };
9809                variables.insert(slot.variable.to_owned(), input);
9810                variables.insert(slot.include.to_owned(), json!(index < batch.len()));
9811            }
9812            for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
9813                let field = batch_clears.get(index).map_or(filler, |field| **field);
9814                variables.insert(
9815                    slot.variable.to_owned(),
9816                    json!({"projectId":board,"itemId":item,"fieldId":field}),
9817                );
9818                variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
9819            }
9820            let data = self
9821                .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
9822                .await?;
9823            let sent = FIELD_WRITE_SLOTS[..batch.len()]
9824                .iter()
9825                .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
9826            for slot in sent {
9827                let alias = slot.alias;
9828                let returned = data
9829                    .get(alias)
9830                    .and_then(|value| value.get("projectV2Item"))
9831                    .ok_or_else(|| SourceError::Malformed {
9832                        message: format!("GitHub field update {alias} returned no project item"),
9833                    })?;
9834                if required_str(returned, "id")? != item {
9835                    return Err(SourceError::Malformed {
9836                        message: format!(
9837                            "GitHub field update {alias} returned the wrong project item"
9838                        ),
9839                    });
9840                }
9841            }
9842        }
9843    }
9844
9845    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
9846        let mut after: Option<String> = None;
9847        let mut ids = Vec::new();
9848        loop {
9849            let data = self
9850                .graphql(
9851                    graphql::ISSUE_DEPENDENCIES,
9852                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
9853                )
9854                .await?;
9855            let connection =
9856                data.pointer("/node/blockedBy")
9857                    .ok_or_else(|| SourceError::Malformed {
9858                        message: "GitHub dependency response has no blockedBy connection".into(),
9859                    })?;
9860            ids.extend(
9861                connection
9862                    .get("nodes")
9863                    .and_then(Value::as_array)
9864                    .ok_or_else(|| SourceError::Malformed {
9865                        message: "GitHub dependency response nodes is not an array".into(),
9866                    })?
9867                    .iter()
9868                    .map(|value| required_str(value, "id").map(str::to_owned))
9869                    .collect::<Result<Vec<_>, _>>()?,
9870            );
9871            let next = next_cursor(connection)?;
9872            if let Some(next) = &next {
9873                validate_cursor_progress(after.as_deref(), &next.0)?;
9874            }
9875            after = next.map(|cursor| cursor.0);
9876            if after.is_none() {
9877                return Ok(ids);
9878            }
9879        }
9880    }
9881
9882    async fn dependencies(
9883        &self,
9884        id: &NativeId,
9885        near_kind: ItemKind,
9886        direction: Direction,
9887        page: &PageRequest,
9888    ) -> Result<Page<DependencyEdge>, SourceError> {
9889        validate_page(page)?;
9890        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
9891        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
9892        let recorded = recorded_offset(cursor, direction)?;
9893        // What this issue is blocked by, when a read of it by its own id in this command
9894        // already carried the whole connection — a copy reads the item it writes before it
9895        // reads its edges — and the page asked for is the whole of it, or the recorded tail
9896        // after it. Answered from that read, in the shape the dependency read answers in;
9897        // anything else is asked of GitHub.
9898        let carried = match direction {
9899            Direction::DependsOn => self
9900                .resolved_cache()?
9901                .get(id)
9902                .filter(|item| item.content_kind == ContentKind::Issue)
9903                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
9904            Direction::DependedOnBy => None,
9905        }
9906        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
9907        // Asked for even in the recorded phase, whose page reads nothing from the
9908        // connection: `__typename` is what says whether this item has a native
9909        // relationship at all, and that is what decides which far ends the reserved key is
9910        // allowed to hold.
9911        let data = match carried {
9912            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
9913                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
9914            None => {
9915                let after = if recorded.is_some() { None } else { cursor };
9916                let first = page.limit.min(MAX_PAGE_SIZE);
9917                match direction {
9918                    Direction::DependsOn => {
9919                        self.graphql(
9920                            graphql::ISSUE_DEPENDS_ON,
9921                            json!({"id":id.0,"first":first,"after":after,"owner":self.owner,
9922                                   "number":self.project_number,
9923                                   "boardItems":BOARD_ITEMS_PAGE_SIZE}),
9924                        )
9925                        .await?
9926                    }
9927                    Direction::DependedOnBy => {
9928                        self.graphql(
9929                            graphql::ISSUE_DEPENDED_ON_BY,
9930                            json!({"id":id.0,"first":first,"after":after}),
9931                        )
9932                        .await?
9933                    }
9934                }
9935            }
9936        };
9937        let draft_slot = draft_slot(&data, id, self.project_number)?;
9938        let node =
9939            data.get("node")
9940                .filter(|v| !v.is_null())
9941                .ok_or_else(|| SourceError::Refused {
9942                    message: format!(
9943                        "GitHub item {} was not found or does not support dependencies",
9944                        id.0
9945                    ),
9946                })?;
9947        let connection_name = match direction {
9948            Direction::DependsOn => "blockedBy",
9949            Direction::DependedOnBy => "blocking",
9950        };
9951        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
9952        // named natively and the reserved key may hold any far end. An issue's connections
9953        // hold issues, and this source reads them at the near item's own level.
9954        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
9955        if let Some(offset) = recorded {
9956            return Ok(recorded_page(
9957                self.recorded_edges(
9958                    id,
9959                    near_kind,
9960                    direction,
9961                    natively_names,
9962                    node,
9963                    draft_slot.as_ref(),
9964                )
9965                .await?,
9966                offset,
9967                limit,
9968            ));
9969        }
9970        if natively_names.is_none() {
9971            return Ok(recorded_page(
9972                self.recorded_edges(
9973                    id,
9974                    near_kind,
9975                    direction,
9976                    natively_names,
9977                    node,
9978                    draft_slot.as_ref(),
9979                )
9980                .await?,
9981                0,
9982                limit,
9983            ));
9984        }
9985        let connection = node
9986            .get(connection_name)
9987            .ok_or_else(|| SourceError::Malformed {
9988                message: "GitHub dependency response is missing its connection".into(),
9989            })?;
9990        let nodes = connection
9991            .get("nodes")
9992            .and_then(Value::as_array)
9993            .ok_or_else(|| SourceError::Malformed {
9994                message: "GitHub dependency response nodes is not an array".into(),
9995            })?;
9996        // `from` depends on `to`, always. GitHub spells the same relationship from either
9997        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
9998        // it — so the near item is `from` in one direction and `to` in the other.
9999        let items = nodes
10000            .iter()
10001            .map(|value| {
10002                let related = NativeId(required_str(value, "id")?.into());
10003                let related_kind = related_kind(value, self.sub_issues)?;
10004                let (from, to) = match direction {
10005                    Direction::DependsOn => (
10006                        DependencyEndpoint::from_native(id.clone(), near_kind),
10007                        DependencyEndpoint::from_native(related, related_kind),
10008                    ),
10009                    Direction::DependedOnBy => (
10010                        DependencyEndpoint::from_native(related, related_kind),
10011                        DependencyEndpoint::from_native(id.clone(), near_kind),
10012                    ),
10013                };
10014                Ok(DependencyEdge {
10015                    from,
10016                    to,
10017                    kind: DependencyKind::Blocks,
10018                })
10019            })
10020            .collect::<Result<Vec<_>, SourceError>>()?;
10021        let mut next = next_cursor(connection)?;
10022        if let Some(next) = &next {
10023            validate_cursor_progress(cursor, &next.0)?;
10024        }
10025        // The recorded far ends ride on the last page of `blockedBy`, whose answer carried the
10026        // body they are recorded in, as far as the page has room — so they cost no request of
10027        // their own — and only what does not fit is resumed by a cursor.
10028        let mut items = items;
10029        if next.is_none() {
10030            let recorded = self
10031                .recorded_edges(
10032                    id,
10033                    near_kind,
10034                    direction,
10035                    natively_names,
10036                    node,
10037                    draft_slot.as_ref(),
10038                )
10039                .await?;
10040            let room = limit.saturating_sub(items.len());
10041            let total = recorded.len();
10042            items.extend(recorded.into_iter().take(room));
10043            if total > room {
10044                next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
10045            }
10046        }
10047        Ok(Page { items, next })
10048    }
10049
10050    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
10051    /// a far end in another source has to live: no GitHub issue relationship can name one.
10052    ///
10053    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
10054    /// source never writes one down.
10055    ///
10056    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
10057    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
10058    /// request beyond the read already made, and reading the board for them would be a
10059    /// walk of every item for one field of one. A draft has no body in that answer, because
10060    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
10061    /// listing of the board, which can be behind on the very item asked about.
10062    async fn recorded_edges(
10063        &self,
10064        id: &NativeId,
10065        near_kind: ItemKind,
10066        direction: Direction,
10067        natively_names: Option<ItemKind>,
10068        node: &Value,
10069        draft_slot: Option<&Option<BTreeMap<String, Value>>>,
10070    ) -> Result<Vec<DependencyEdge>, SourceError> {
10071        if direction != Direction::DependsOn {
10072            return Ok(Vec::new());
10073        }
10074        let slot = match (node.get("body"), draft_slot) {
10075            (Some(body), _) if natively_names.is_some() => {
10076                metadata_body(body.as_str().map(str::to_owned))?.1
10077            }
10078            // A draft the dependency read itself answered: this board's, or none of its.
10079            (_, Some(Some(slot))) => slot.clone(),
10080            (_, Some(None)) => return Ok(Vec::new()),
10081            _ => {
10082                let Some(item) = self.bound_item(id).await? else {
10083                    return Ok(Vec::new());
10084                };
10085                item.slot
10086            }
10087        };
10088        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
10089            .map_err(|message| SourceError::Malformed { message })
10090    }
10091
10092    /// Why `creation_owners` refuses a create filed in `target`, or `None` when it allows it —
10093    /// every create when the list is empty.
10094    fn owner_refusal(&self, target: &RepositoryTarget) -> Option<String> {
10095        if self.creation_owners.is_empty()
10096            || self
10097                .creation_owners
10098                .iter()
10099                .any(|owner| owner.eq_ignore_ascii_case(&target.owner))
10100        {
10101            return None;
10102        }
10103        Some(format!(
10104            "source {} would create the issue in repository {}, owned by {}, and its \
10105             creation_owners allows only repositories of {}",
10106            self.name,
10107            target.slug(),
10108            target.owner,
10109            self.creation_owners.join(", ")
10110        ))
10111    }
10112
10113    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
10114        self.repository
10115            .as_ref()
10116            .ok_or_else(|| SourceError::Refused {
10117                message: format!(
10118                    "source {} has no repository configured, and a GitHub Projects board has no \
10119                 repository of its own to create an issue in; set repository: owner/name on \
10120                 this source",
10121                    self.name
10122                ),
10123            })
10124    }
10125
10126    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
10127    /// states.
10128    ///
10129    /// The fallback is demanded first, whichever arm answers: a write without a configured
10130    /// repository is refused naming the field exactly as it was before the rule existed,
10131    /// so a source that could not write before cannot write now, rather than writing for
10132    /// the one item whose own field happens to decide it.
10133    ///
10134    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
10135    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
10136    /// entry owned by someone other than the owner of the parent issue's repository —
10137    /// GitHub accepts a sub-issue from another repository of the same owner and from no
10138    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
10139    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
10140    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
10141    /// and is visible to the token is checked where its node id is resolved, still before
10142    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
10143    /// looked up in a listing of the board, which can be minutes behind an issue its own
10144    /// `projectItems` already places on it — and that read answers first from this process's
10145    /// own record, so a project created moments ago in this command answers though GitHub
10146    /// has not caught up.
10147    async fn creation_target(
10148        &self,
10149        incoming: &Incoming<'_>,
10150    ) -> Result<RepositoryTarget, SourceError> {
10151        let what = format!(
10152            "{} {:?}",
10153            incoming.written.kind().describes(),
10154            incoming.title
10155        );
10156        let target = self
10157            .creation_rule(incoming.repositories, incoming.parent, &what)
10158            .await?;
10159        // The engine asks `creation_refusal` before anything of a copy is written; this holds
10160        // every other create to the same restriction, before `createIssue`.
10161        match self.owner_refusal(&target) {
10162            Some(message) => Err(SourceError::Refused { message }),
10163            None => Ok(target),
10164        }
10165    }
10166
10167    /// [`Self::creation_target`]'s rule over what it reads of the item — its `repositories`
10168    /// and the project issue it is filed under — with `what` naming the item in a refusal: the
10169    /// one place the rule is, so the create, [`TaskSource::creation_repository`] and the
10170    /// write-time read of who can read the issue ([`TaskSource::visibility`]) cannot answer
10171    /// differently.
10172    ///
10173    /// On a source declared private, an item's one named repository is read for its
10174    /// visibility first — once per engine hold, see the `visibility` module — and one that
10175    /// does not read private decides nothing: the issue is created in the parent's
10176    /// repository, else the configured one. A read that cannot be answered refuses.
10177    async fn creation_rule(
10178        &self,
10179        repositories: &[Repository],
10180        parent: Option<&NativeId>,
10181        what: &str,
10182    ) -> Result<RepositoryTarget, SourceError> {
10183        let parent = match parent {
10184            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
10185                SourceError::Refused {
10186                    message: format!(
10187                        "GitHub project issue {} was not found on the board of source {}, so {} \
10188                         cannot be filed under it",
10189                        parent.0, self.name, what
10190                    ),
10191                }
10192            })?),
10193            None => None,
10194        };
10195        let parents_repository = parent
10196            .as_ref()
10197            .map(|parent| {
10198                // A draft is on the board and so is found, but it has no repository to
10199                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
10200                // would refuse the task only once `createIssue` had made it.
10201                if parent.content_kind == ContentKind::DraftIssue {
10202                    return Err(SourceError::Refused {
10203                        message: format!(
10204                            "GitHub project item {} on the board of source {} is a draft, \
10205                             which cannot have sub-issues, so {} cannot be filed under it",
10206                            parent.id.0, self.name, what
10207                        ),
10208                    });
10209                }
10210                // An issue's repository is where a sub-issue is placed and whose owner it
10211                // is compared against, so a parent whose repository this source cannot
10212                // spell as `owner/name` — GitHub's login grammar is wider than this
10213                // source's floor — is one nothing can be filed under.
10214                parent
10215                    .own_repository
10216                    .as_ref()
10217                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
10218                    .ok_or_else(|| SourceError::Malformed {
10219                        message: format!(
10220                            "GitHub project issue {} on the board of source {} is in {}, which \
10221                             is not a {}/owner/name repository this source can place {} in",
10222                            parent.id.0,
10223                            self.name,
10224                            parent
10225                                .own_repository
10226                                .as_ref()
10227                                .map_or("no repository", Repository::as_str),
10228                            RepositoryTarget::HOST,
10229                            what
10230                        ),
10231                    })
10232            })
10233            .transpose()?;
10234        self.creation_rule_in_repository(repositories, parents_repository, what)
10235            .await
10236    }
10237
10238    async fn creation_rule_in_repository(
10239        &self,
10240        repositories: &[Repository],
10241        parents_repository: Option<RepositoryTarget>,
10242        what: &str,
10243    ) -> Result<RepositoryTarget, SourceError> {
10244        let fallback = self.configured_repository()?;
10245        match repositories {
10246            [named] => {
10247                let target =
10248                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
10249                        message: format!(
10250                            "{} names repository {}, which is not a {}/owner/name repository \
10251                             source {} can create an issue in; name one that is, or name none",
10252                            what,
10253                            named.as_str(),
10254                            RepositoryTarget::HOST,
10255                            self.name
10256                        ),
10257                    })?;
10258                // On a source declared private, an issue whose one named repository does not
10259                // read private is created where an item naming none is: the item still names
10260                // that repository in its metadata slot, and its issue stays private.
10261                if self.is_declared_private()
10262                    && self.repository_visibility(&target).await?
10263                        != onetaskgraph_plugin_api::Visibility::Private
10264                {
10265                    return Ok(parents_repository.unwrap_or_else(|| fallback.clone()));
10266                }
10267                if let Some(parents) = &parents_repository
10268                    && parents.owner != target.owner
10269                {
10270                    return Err(SourceError::Refused {
10271                        message: format!(
10272                            "{} names repository {}, owned by {}, but its project's issue is in \
10273                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
10274                             of the same owner as its parent issue; name a repository of {}, or \
10275                             name none",
10276                            what,
10277                            target.slug(),
10278                            target.owner,
10279                            parents.slug(),
10280                            parents.owner,
10281                            parents.owner
10282                        ),
10283                    });
10284                }
10285                Ok(target)
10286            }
10287            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
10288        }
10289    }
10290
10291    /// The node id of the repository `incoming` is being created in, or the refusal naming
10292    /// the item and the repository the token cannot see.
10293    ///
10294    /// Resolved once per command per repository; see [`Self::repository_cache`].
10295    async fn repository_id(
10296        &self,
10297        repository: &RepositoryTarget,
10298        incoming: &Incoming<'_>,
10299    ) -> Result<String, SourceError> {
10300        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
10301            return Ok(id);
10302        }
10303        let data = self
10304            .graphql(
10305                graphql::REPOSITORY,
10306                json!({"owner":repository.owner,"name":repository.name}),
10307            )
10308            .await?;
10309        self.repository_read(&data, repository, incoming)
10310    }
10311
10312    /// The repository's node id out of an answer carrying the `repository` root, held for
10313    /// the rest of this command, or the refusal naming the item that cannot be created in it.
10314    fn repository_read(
10315        &self,
10316        data: &Value,
10317        repository: &RepositoryTarget,
10318        incoming: &Incoming<'_>,
10319    ) -> Result<String, SourceError> {
10320        let node = data
10321            .get("repository")
10322            .filter(|value| !value.is_null())
10323            .ok_or_else(|| SourceError::Refused {
10324                message: format!(
10325                    "GitHub repository {} was not found or is not visible to the token, so {} \
10326                     {:?} cannot be created in it",
10327                    repository.slug(),
10328                    incoming.written.kind().describes(),
10329                    incoming.title
10330                ),
10331            })?;
10332        let id = required_str(node, "id")?.to_owned();
10333        self.repository_cache()?
10334            .insert(repository.clone(), id.clone());
10335        Ok(id)
10336    }
10337
10338    fn repository_cache(
10339        &self,
10340    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
10341        self.repository_cache
10342            .lock()
10343            .map_err(|_| SourceError::Unavailable {
10344                message: "this source's record of the destination repository was left \
10345                          inconsistent by an earlier failure; next: run the command again"
10346                    .into(),
10347            })
10348    }
10349
10350    /// Create or update one board item, whichever kind it is.
10351    async fn write_item(
10352        &self,
10353        incoming: &Incoming<'_>,
10354        target: Option<&NativeId>,
10355        depends_on: &[DependencyEdge],
10356    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10357        let prepared = self.prepare_write(incoming, target, depends_on).await?;
10358        let landed = match &prepared.destination {
10359            // Its content is written last, by `complete_write`, once everything else has landed.
10360            WriteDestination::Existing(item) => Landed {
10361                content_id: item.id.clone(),
10362                item_id: Some(item.item_id.clone()),
10363                url: item.url.clone(),
10364                number: item.number,
10365            },
10366            WriteDestination::New(target) => {
10367                if self.deferring()? {
10368                    self.create_filed_issue(
10369                        prepared.board.id.as_str(),
10370                        target,
10371                        incoming,
10372                        &prepared.body,
10373                    )
10374                    .await?
10375                } else {
10376                    self.create_and_file_issue(
10377                        prepared.board.id.as_str(),
10378                        target,
10379                        incoming,
10380                        &prepared.body,
10381                    )
10382                    .await?
10383                }
10384            }
10385        };
10386        self.complete_write(incoming, prepared, landed).await
10387    }
10388
10389    /// Everything one write of `incoming` reads and decides before it sends anything: what is
10390    /// there now, where a new item is created, the board's fields, and every value refused while
10391    /// nothing has been written — the item's body included.
10392    async fn prepare_write(
10393        &self,
10394        incoming: &Incoming<'_>,
10395        target: Option<&NativeId>,
10396        depends_on: &[DependencyEdge],
10397    ) -> Result<Prepared, SourceError> {
10398        // Refused before anything is read or written: a task or a project titled the way
10399        // this board spells a document would land as an issue this same source reads back
10400        // as a document, so the field this destination cannot carry is named rather than
10401        // written and silently reclassified.
10402        if let Written::Work(kind, _) = incoming.written
10403            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
10404        {
10405            return Err(SourceError::Refused {
10406                message: format!(
10407                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
10408                     spells a document, so it would read back as one rather than as a {}; \
10409                     retitle it, or copy it as a document",
10410                    kind.marker(),
10411                    self.name,
10412                    kind.marker()
10413                ),
10414            });
10415        }
10416        // The destination is read by its own id, and whether this board holds it is decided
10417        // by that read — its own `projectItems` — rather than by whether a listing of the
10418        // board happens to include it yet. See the module documentation.
10419        // An existing issue is never moved; a new one is created where the rule says — and
10420        // knowing where is what lets the board's fields and that repository's id be read
10421        // together, before anything below needs either.
10422        let destination = match target {
10423            Some(target) => {
10424                WriteDestination::Existing(Box::new(self.bound_item(target).await?.ok_or_else(
10425                    || SourceError::Refused {
10426                        message: format!("GitHub destination item {} was not found", target.0),
10427                    },
10428                )?))
10429            }
10430            None => {
10431                let target = self.creation_target(incoming).await?;
10432                self.creation_context(&target, incoming).await?;
10433                WriteDestination::New(target)
10434            }
10435        };
10436        let existing = destination.existing();
10437        let board = self
10438            .fields_for(
10439                existing,
10440                incoming.metadata.contains_key(ORIGIN_KEY),
10441                incoming.written.status().is_some(),
10442                incoming
10443                    .priority
10444                    .is_some_and(|priority| priority != Priority::None),
10445            )
10446            .await?;
10447        let status_target = incoming
10448            .written
10449            .work_status()
10450            .map(|(kind, status)| self.resolved_target(kind, status.category))
10451            .transpose()?;
10452        let column = match (incoming.written.work_status(), status_target.as_ref()) {
10453            (Some((kind, status)), Some(target)) => {
10454                self.column_for(&board.fields, kind, status.category, target)?
10455            }
10456            _ => None,
10457        };
10458        // Resolved before anything is created, for the reason the column above is: a
10459        // priority this board has no option for is refused while nothing has been written.
10460        let priority_write = match incoming.priority {
10461            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
10462            None => None,
10463        };
10464        // And for the reason the priority is: a value no projected field can hold, or a board
10465        // that cannot hold the field, is refused while nothing has been written.
10466        let (projections, projected) = self.projection_writes(
10467            &board.fields,
10468            existing.map(|item| &item.projected),
10469            incoming.metadata,
10470            None,
10471        )?;
10472        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
10473        if content_kind == ContentKind::DraftIssue {
10474            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
10475                (status_target.as_ref(), incoming.written.status())
10476            {
10477                return Err(self.closes_a_draft(status.category));
10478            }
10479            if incoming.parent.is_some() {
10480                return Err(SourceError::Refused {
10481                    message: "GitHub draft items cannot be a project's sub-issue".into(),
10482                });
10483            }
10484        }
10485        match existing {
10486            Some(item) if content_kind == ContentKind::Issue => {
10487                if item.labels != incoming.labels {
10488                    return Err(SourceError::Refused {
10489                        message: "GitHub issue labels differ from the labels being written".into(),
10490                    });
10491                }
10492            }
10493            _ => {
10494                if !incoming.labels.is_empty() {
10495                    return Err(SourceError::Refused {
10496                        message: "GitHub items created by this destination carry no labels".into(),
10497                    });
10498                }
10499            }
10500        }
10501
10502        // The repository the issue really lives in is what the slot below is written against,
10503        // so a single entry that is where the issue is created travels as no key at all, and
10504        // the read side derives it back from the issue.
10505        let own_repository = match &destination {
10506            WriteDestination::Existing(item) => item.own_repository.clone(),
10507            WriteDestination::New(target) => Some(
10508                Repository::try_from(target.origin())
10509                    .map_err(|message| SourceError::Config { message })?,
10510            ),
10511        };
10512        let (native, fallback) = self
10513            .partition_edges(
10514                incoming.written.kind(),
10515                content_kind,
10516                existing.and_then(|item| item.blocked_by.as_deref()),
10517                depends_on,
10518            )
10519            .await?;
10520        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
10521        let content = match incoming.assets {
10522            Some(assets) => {
10523                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
10524                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
10525                    incoming.content.unwrap_or_default(),
10526                    &mut slot,
10527                    &uploads,
10528                );
10529                incoming.content.map(|_| rewritten)
10530            }
10531            None => incoming.content.map(str::to_owned),
10532        };
10533        // A write over an existing item keeps its `## Links` section, re-rendered from the
10534        // links it holds.
10535        let content = content.map(|content| {
10536            onetaskgraph_plugin_api::relinked(
10537                &content,
10538                existing.and_then(|item| item.body.as_deref()),
10539                incoming.metadata,
10540            )
10541        });
10542        let marked = content.as_deref().map(|content| self.marked(content));
10543        let body = compose_body(marked.as_deref(), &slot)?;
10544        // Read before anything is created, for the reason the field below is: a value
10545        // this destination cannot store has to refuse, and refusing after `createIssue`
10546        // would leave an issue behind that nothing asked for. The engine writes a
10547        // qualified id here; a caller handing this key anything else is told so rather
10548        // than having it silently stored as no origin at all.
10549        // 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.
10550        let origin = match incoming.metadata.get(ORIGIN_KEY) {
10551            None => "",
10552            Some(Value::String(origin)) => origin.as_str(),
10553            Some(other) => {
10554                return Err(SourceError::Refused {
10555                    message: format!(
10556                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
10557                         is {other}"
10558                    ),
10559                });
10560            }
10561        };
10562        // Resolved before anything is created: a board that cannot carry the copy origin
10563        // has to refuse the write, and refusing it after `createIssue` would leave an
10564        // issue behind that nothing asked for.
10565        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
10566            Some(field) => {
10567                if required_str(field, "__typename")? != "ProjectV2Field" {
10568                    return Err(SourceError::Refused {
10569                        message: format!(
10570                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
10571                        ),
10572                    });
10573                }
10574                Some(required_str(field, "id")?.to_owned())
10575            }
10576            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
10577                return Err(SourceError::Refused {
10578                    message: format!(
10579                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
10580                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
10581                         the board"
10582                    ),
10583                });
10584            }
10585            None => None,
10586        };
10587
10588        Ok(Prepared {
10589            origin: origin.to_owned(),
10590            destination,
10591            board,
10592            status_target,
10593            column,
10594            priority_write,
10595            projections,
10596            projected,
10597            content_kind,
10598            own_repository,
10599            native,
10600            slot,
10601            content,
10602            body,
10603            origin_field,
10604        })
10605    }
10606
10607    /// Everything one write does once the item it writes exists: its board fields, its
10608    /// relationships and — for an item that was already there — its content last, then what this
10609    /// process remembers of it.
10610    ///
10611    /// While this source is holding writes back — see [`TaskSource::begin_deferred_writes`] — an
10612    /// item this command created has its fields, its closing and its edges held for
10613    /// [`TaskSource::finish_writes`] rather than sent here.
10614    async fn complete_write(
10615        &self,
10616        incoming: &Incoming<'_>,
10617        prepared: Prepared,
10618        landed: Landed,
10619    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10620        let Prepared {
10621            destination,
10622            board,
10623            status_target,
10624            column,
10625            priority_write,
10626            projections,
10627            projected,
10628            content_kind,
10629            own_repository,
10630            native,
10631            slot,
10632            content,
10633            body,
10634            origin,
10635            origin_field,
10636        } = prepared;
10637        let existing = destination.existing();
10638        let origin = origin.as_str();
10639        let Landed {
10640            content_id,
10641            item_id,
10642            url,
10643            number,
10644        } = landed;
10645
10646        // Only what differs from the item as this write read it is sent: a priority the item
10647        // already holds is not, so an unchanged re-copy writes no field and a person's edit of
10648        // it since that read is left alone.
10649        let priority_write = priority_write.filter(|_| {
10650            !existing.is_some_and(|item| {
10651                incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
10652            })
10653        });
10654        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
10655        let column = column
10656            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
10657            .map(|(field, option, _)| (field, option));
10658        // Creating an item here is several calls — `createIssue`, which files it on the
10659        // board, then its board fields, the parent and the dependencies — and GitHub can fail
10660        // at any of them. Everything this source can refuse *before* the first of those is
10661        // already checked above, so what is left is GitHub itself failing part way. When it
10662        // does over an item this call created, the issue is taken back: a write that
10663        // refused must not leave an item behind that nobody asked for, and one that does
10664        // makes the retry create a second.
10665        // Whether the board-field write carrying a moved origin was answered as landing whole.
10666        // When it was refused, GitHub does not say which of its fields ran before the one that
10667        // failed, so the origin may or may not have moved.
10668        let mut origin_landed = false;
10669        // An item this command created while writes are held back has every part that can land
10670        // later held for `finish_writes` — its own create filed it on the board and under its
10671        // project already — and so does a later write of it in the same command.
10672        let holding = (existing.is_none() && self.deferring()?) || self.holds_back(&content_id)?;
10673        let landed = if holding {
10674            self.hold_back(
10675                &board.id,
10676                &content_id,
10677                existing,
10678                origin_field.as_deref(),
10679                origin,
10680                column,
10681                status_target.as_ref(),
10682                priority_write.as_ref(),
10683                &projections,
10684                &native,
10685            )
10686        } else if let Some(item_id) = item_id.as_deref() {
10687            self.finish_write(
10688                board.id.as_str(),
10689                incoming,
10690                &content_id,
10691                item_id,
10692                content_kind,
10693                existing,
10694                origin_field.as_deref(),
10695                origin,
10696                column,
10697                status_target.as_ref(),
10698                priority_write.as_ref(),
10699                &projections,
10700                &native,
10701                &mut origin_landed,
10702            )
10703            .await
10704        } else {
10705            // An issue a create filed on the board itself has no board item this process
10706            // knows until `finish_writes` reads it, so nothing but a held-back write may land
10707            // on it before then.
10708            Err(SourceError::Malformed {
10709                message: format!(
10710                    "issue {} was written before the board item GitHub filed it as was read",
10711                    content_id.0
10712                ),
10713            })
10714        };
10715        // An existing item's title, body and state go last, in one `updateIssue`, once its board
10716        // fields and its relationships have landed: a refusal of any of those then leaves its
10717        // body — and the metadata slot inside it — exactly as it stood.
10718        let landed = match (landed, existing) {
10719            (Ok(()), Some(item)) => {
10720                self.update_existing(item, incoming, &body, status_target.as_ref())
10721                    .await
10722            }
10723            (landed, _) => landed,
10724        };
10725        if let Err(error) = landed {
10726            match existing {
10727                // Best effort, and the write's own failure is what the caller is told: a
10728                // refusal naming the tidy-up would hide why the write failed at all.
10729                None => {
10730                    let _ = self.delete_issue(&content_id).await;
10731                }
10732                // The origin field is the one piece of an existing item's metadata written
10733                // before its body, so a write refused after it puts it back as it was. When
10734                // that is refused too, the write's own failure is still what the caller is
10735                // told — with what it left behind added, because the item's metadata is then
10736                // not as it stood and a caller retrying has to know which key moved.
10737                Some(item) => {
10738                    let before = item.origin.as_deref().unwrap_or("");
10739                    if let Some(field) = origin_field.as_deref()
10740                        && before != origin
10741                        && let Err(restore) = self
10742                            .set_item_field(
10743                                board.id.as_str(),
10744                                &item.item_id,
10745                                field,
10746                                json!({"text": before}),
10747                            )
10748                            .await
10749                    {
10750                        let left = if origin_landed {
10751                            format!(
10752                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
10753                                 not be put back to {before:?} ({restore}), so item {} still \
10754                                 holds {origin:?} there",
10755                                item.id.0
10756                            )
10757                        } else {
10758                            format!(
10759                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
10760                                 {origin:?}, GitHub does not say whether that part of it ran, \
10761                                 and putting it back to {before:?} was refused ({restore}), so \
10762                                 item {} holds {origin:?} or {before:?} there",
10763                                item.id.0
10764                            )
10765                        };
10766                        return Err(noting(
10767                            error,
10768                            &format!(
10769                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
10770                                 run the write again"
10771                            ),
10772                        ));
10773                    }
10774                }
10775            }
10776            return Err(error);
10777        }
10778
10779        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
10780            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
10781                self.statuses
10782                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
10783            }
10784            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
10785                self.statuses
10786                    .status(kind, written_option.as_deref(), false, None)
10787            }
10788            (Some((_, status)), _) => status.clone(),
10789            (None, _) => Status {
10790                category: StatusCategory::Unknown,
10791                name: "Open".to_owned(),
10792            },
10793        };
10794
10795        // So the rest of this command reads what it just did rather than what the board
10796        // said before it. See `remember_written` for which half takes it.
10797        let remembered = Resolved {
10798            // Empty for an issue whose board item is not read yet: `finish_writes` reads it
10799            // and `learn_item` puts it here.
10800            // 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.
10801            item_id: item_id.unwrap_or_default(),
10802            id: content_id.clone(),
10803            content_kind,
10804            kind: incoming.written.kind(),
10805            title: incoming.title.to_owned(),
10806            // The visible half of the body this write composed, split back off it the
10807            // way a read splits it — so what this record reports is what a read of the
10808            // same issue reports, rather than the person's text with the metadata slot
10809            // still on the end of it.
10810            body: self.visible(body.clone())?.0,
10811            raw_body: body.clone(),
10812            // A document has no status of its own; what it reads back as is whatever
10813            // the issue's own state says, which is what a re-read reports.
10814            status: written_status,
10815            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
10816            priority: match incoming.priority {
10817                Some(priority) => HeldPriority::Read(priority),
10818                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
10819                    item.priority.clone()
10820                }),
10821            },
10822            // What `state_input` asked for: closed for a terminal target, open for any other
10823            // status, and the issue's own state left as it was by a document write.
10824            closed: content_kind == ContentKind::Issue
10825                && match status_target.as_ref() {
10826                    Some(StatusTarget::Terminal(_, _)) => true,
10827                    Some(_) => false,
10828                    None => existing.is_some_and(|item| item.closed),
10829                },
10830            reason: match status_target.as_ref() {
10831                Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
10832                    Some(reason.reason().to_owned())
10833                }
10834                Some(_) => None,
10835                None => existing.and_then(|item| item.reason.clone()),
10836            },
10837            delivers: incoming.delivers.to_vec(),
10838            delivered_by: incoming.delivered_by.to_vec(),
10839            labels: incoming.labels.to_vec(),
10840            parent: incoming.parent.cloned(),
10841            issue_parent: None,
10842            origin: (!origin.is_empty()).then(|| origin.to_owned()),
10843            projected,
10844            number,
10845            // In the update path this is the item's own url, read off `existing` where the
10846            // record above was bound, so one expression serves both halves.
10847            url,
10848            created_at: existing.and_then(|item| item.created_at),
10849            updated_at: existing.and_then(|item| item.updated_at),
10850            // What this write moved is GitHub's to say, so a record of it gives no token until
10851            // it is read again.
10852            item_updated_at: None,
10853            comment_count: None,
10854            relationship_events: None,
10855            served_at: None,
10856            facets: None,
10857            own_repository,
10858            repositories: incoming.repositories.to_vec(),
10859            classification: incoming.classification,
10860            slot,
10861            board_id: Some(board.id.as_str().to_owned()),
10862            fields: board
10863                .fields
10864                .get("nodes")
10865                .and_then(Value::as_array)
10866                .cloned()
10867                .unwrap_or_default(),
10868            board_fields: Some(board.fields.clone()),
10869            // What this write left the relationship holding is known by id alone, and a
10870            // later read of its edges needs each far end's kind, so it reads them again.
10871            blocked_by: None,
10872        };
10873        self.remember_written(remembered, existing.is_none())?;
10874        Ok(onetaskgraph_plugin_api::AssetsWritten {
10875            id: content_id,
10876            content,
10877        })
10878    }
10879
10880    /// Everything a write does after the item exists: its board fields, its parent, and
10881    /// its dependencies.
10882    ///
10883    /// Split out of `write_item` so there is one place a failure past the point of no
10884    /// return is caught, rather than a tidy-up repeated at each `?` above.
10885    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
10886    // so there is one place a failure past the point of no return is caught, and its
10887    // arguments are exactly the values that tail already had in scope. Bundling them into a
10888    // struct would describe no concept — it would be "the arguments of this function" — and
10889    // would put the whole of `write_item`'s locals behind one more indirection.
10890    #[allow(clippy::too_many_arguments)]
10891    async fn finish_write(
10892        &self,
10893        board_id: &str,
10894        incoming: &Incoming<'_>,
10895        content_id: &NativeId,
10896        item_id: &str,
10897        content_kind: ContentKind,
10898        existing: Option<&Resolved>,
10899        origin_field: Option<&str>,
10900        origin: &str,
10901        column: Option<(String, String)>,
10902        status_target: Option<&StatusTarget>,
10903        priority: Option<&PriorityWrite>,
10904        projections: &[ProjectionWrite],
10905        native: &[String],
10906        origin_landed: &mut bool,
10907    ) -> Result<(), SourceError> {
10908        let (fields, clears) = field_writes(
10909            existing,
10910            origin_field,
10911            origin,
10912            column,
10913            priority,
10914            projections,
10915        );
10916        self.set_item_fields(board_id, item_id, &fields, &clears)
10917            .await?;
10918        *origin_landed = true;
10919
10920        // An existing issue closes in the `updateIssue` its write ends with; one created just
10921        // now closes here, once its option is selected.
10922        if existing.is_none()
10923            && content_kind == ContentKind::Issue
10924            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
10925        {
10926            self.update_content(
10927                ContentKind::Issue,
10928                content_id,
10929                json!({"stateInput":state_input(status_target)}),
10930            )
10931            .await?;
10932        }
10933
10934        if content_kind == ContentKind::Issue {
10935            self.reparent(
10936                existing.and_then(|item| item.parent.clone()),
10937                content_id,
10938                incoming.parent,
10939            )
10940            .await?;
10941            // A document takes part in no dependency graph, so writing one neither reads
10942            // nor changes the issue's own `blockedBy` relationships. Reconciling them
10943            // against the empty list a document write carries would *delete* whatever
10944            // relationships a person had made on that issue, which is a write nobody
10945            // asked for.
10946            if incoming.written.kind() != BoardKind::Document {
10947                let issue = match existing {
10948                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
10949                    None => Issue::Created,
10950                };
10951                self.reconcile_blocked_by(content_id, native, issue).await?;
10952            }
10953        }
10954        Ok(())
10955    }
10956
10957    /// Whether this source is holding writes back — see [`TaskSource::begin_deferred_writes`].
10958    fn deferring(&self) -> Result<bool, SourceError> {
10959        Ok(self.deferred()?.is_some())
10960    }
10961
10962    /// Whether `content` is an item this command created whose parts are held back.
10963    fn holds_back(&self, content: &NativeId) -> Result<bool, SourceError> {
10964        Ok(self
10965            .deferred()?
10966            .as_ref()
10967            .is_some_and(|deferred| deferred.holds(content)))
10968    }
10969
10970    fn deferred(&self) -> Result<std::sync::MutexGuard<'_, Option<Deferred>>, SourceError> {
10971        self.deferred.lock().map_err(|_| SourceError::Unavailable {
10972            message: "this source's record of the writes it is holding back was left \
10973                      inconsistent by an earlier failure; next: run the command again"
10974                .into(),
10975        })
10976    }
10977
10978    /// Creates one issue filed on the board and under its project by the `createIssue` itself —
10979    /// `projectV2Ids` naming the board and `parentIssueId` the project — which is what a copy
10980    /// creates with: one request where adding it to the board and filing it under its project
10981    /// were two more. GitHub adds the board item after answering, so its id is not known here;
10982    /// [`TaskSource::finish_writes`] reads it.
10983    async fn create_filed_issue(
10984        &self,
10985        board_id: &str,
10986        repository: &RepositoryTarget,
10987        incoming: &Incoming<'_>,
10988        body: &Option<String>,
10989    ) -> Result<Landed, SourceError> {
10990        let repository_id = self.repository_id(repository, incoming).await?;
10991        let data = self
10992            .graphql(
10993                graphql::CREATE_ISSUE,
10994                json!({"input": filed_input(&repository_id, board_id, incoming, body)}),
10995            )
10996            .await?;
10997        let created = data
10998            .pointer("/createIssue/issue")
10999            .filter(|value| !value.is_null())
11000            .ok_or_else(|| SourceError::Malformed {
11001                message: "GitHub issue creation returned no issue".into(),
11002            })?;
11003        self.created_landed(created).await
11004    }
11005
11006    /// Keep the address `createIssue` answered for the issue `content`, for
11007    /// [`TaskSource::written_location`].
11008    fn note_written(&self, content: &NativeId, url: Option<&str>) {
11009        if let Some(url) = url {
11010            self.written
11011                .lock()
11012                .unwrap_or_else(std::sync::PoisonError::into_inner)
11013                .insert(content.clone(), url.to_owned());
11014        }
11015    }
11016
11017    /// What one `createIssue` answered, as the item it created — or, when its number cannot be
11018    /// read, the refusal, after taking the issue back.
11019    async fn created_landed(&self, created: &Value) -> Result<Landed, SourceError> {
11020        let content_id = NativeId(required_nonblank_str(created, "id")?.to_owned());
11021        let url = optional_str(created, "url")?.map(str::to_owned);
11022        let number = match created_issue_number(created) {
11023            Ok(number) => number,
11024            Err(error) => {
11025                let _ = self.delete_issue(&content_id).await;
11026                return Err(error);
11027            }
11028        };
11029        self.note_written(&content_id, url.as_deref());
11030        Ok(Landed {
11031            content_id,
11032            item_id: None,
11033            url,
11034            number,
11035        })
11036    }
11037
11038    /// Hold back everything a write of the created item `content` would send after creating it,
11039    /// for [`TaskSource::finish_writes`]: the same field values, closing and edges
11040    /// `finish_write` sends, the parent excepted, which its create already named. A later write
11041    /// of the same item replaces what it changes.
11042    // 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.
11043    #[allow(clippy::too_many_arguments)]
11044    fn hold_back(
11045        &self,
11046        board_id: &BoardId,
11047        content: &NativeId,
11048        existing: Option<&Resolved>,
11049        origin_field: Option<&str>,
11050        origin: &str,
11051        column: Option<(String, String)>,
11052        status_target: Option<&StatusTarget>,
11053        priority: Option<&PriorityWrite>,
11054        projections: &[ProjectionWrite],
11055        native: &[String],
11056    ) -> Result<(), SourceError> {
11057        let status = column.map(|(field, option)| {
11058            let name = self
11059                .fields_cache()
11060                .ok()
11061                .and_then(|fields| {
11062                    fields.as_ref().and_then(|board| {
11063                        option_name(&board.fields, &field, &option).map(str::to_owned)
11064                    })
11065                })
11066                .unwrap_or_default();
11067            (field, option, name)
11068        });
11069        let (fields, clears) =
11070            field_writes(existing, origin_field, origin, None, priority, projections);
11071        let close = match status_target {
11072            Some(StatusTarget::Terminal(_, reason)) => Some(*reason),
11073            _ => None,
11074        };
11075        let mut deferred = self.deferred()?;
11076        let deferred = deferred.get_or_insert_with(Deferred::default);
11077        if let Deferred::Nothing = deferred {
11078            *deferred = Deferred::Held {
11079                board: board_id.clone(),
11080                created: Vec::new(),
11081            };
11082        }
11083        let Deferred::Held { created, .. } = deferred else {
11084            unreachable!("held back on the line above");
11085        };
11086        let held = match created.iter().position(|held| held.content == *content) {
11087            Some(at) => &mut created[at],
11088            None => {
11089                created.push(HeldBack {
11090                    content: content.clone(),
11091                    fields: Vec::new(),
11092                    status: None,
11093                    clears: Vec::new(),
11094                    close: None,
11095                    edges: Vec::new(),
11096                });
11097                created.last_mut().expect("just held")
11098            }
11099        };
11100        for (field, value) in fields {
11101            held.fields.retain(|(held, _)| *held != field);
11102            held.clears.retain(|held| *held != field);
11103            held.fields.push((field, value));
11104        }
11105        for field in clears {
11106            held.fields.retain(|(held, _)| held != field);
11107            if !held.clears.iter().any(|held| held == field) {
11108                held.clears.push(field.to_owned());
11109            }
11110        }
11111        if status.is_some() {
11112            held.status = status;
11113        }
11114        if close.is_some() {
11115            held.close = close;
11116        }
11117        // Nothing of an item created in this command has been sent but its create, so the edges
11118        // it is to have are exactly the ones its latest write names.
11119        held.edges = native.iter().map(|far| NativeId(far.clone())).collect();
11120        Ok(())
11121    }
11122
11123    /// Create every task of `writes`, together, filed on the board and under each one's
11124    /// project: [`graphql::CREATE_ISSUES`], [`MUTATION_BATCH`] to a request.
11125    ///
11126    /// Each is prepared as [`Self::write_item`] prepares one — every refusal a write owes made
11127    /// before the first request — then created, then completed with its parts held back. A
11128    /// request refused part way leaves the issues it did create, which are taken back, with the
11129    /// rest of this call's, before the refusal is answered.
11130    async fn create_together(
11131        &self,
11132        writes: &[ItemWrite<Task>],
11133    ) -> Result<Vec<NativeId>, SourceError> {
11134        let incomings: Vec<Incoming<'_>> = writes
11135            .iter()
11136            .map(|write| self.task_incoming(write, None))
11137            .collect::<Result<_, _>>()?;
11138        let mut prepared = Vec::with_capacity(writes.len());
11139        for (incoming, write) in incomings.iter().zip(writes) {
11140            if write.target.is_some() {
11141                return Err(SourceError::Malformed {
11142                    message: "a write naming its target was handed to a batch of creates".into(),
11143                });
11144            }
11145            prepared.push(
11146                self.prepare_write(incoming, None, &write.depends_on)
11147                    .await?,
11148            );
11149        }
11150        let mut inputs = Vec::with_capacity(prepared.len());
11151        for (incoming, prepared) in incomings.iter().zip(&prepared) {
11152            let WriteDestination::New(target) = &prepared.destination else {
11153                return Err(SourceError::Malformed {
11154                    message: "an update was decided for a write handed to a batch of creates"
11155                        .into(),
11156                });
11157            };
11158            let repository_id = self.repository_id(target, incoming).await?;
11159            inputs.push(filed_input(
11160                &repository_id,
11161                prepared.board.id.as_str(),
11162                incoming,
11163                &prepared.body,
11164            ));
11165        }
11166        let mut landed: Vec<Landed> = Vec::with_capacity(inputs.len());
11167        let refused = 'create: {
11168            for chunk in inputs.chunks(MUTATION_BATCH) {
11169                let mut variables = serde_json::Map::new();
11170                for slot in 0..MUTATION_BATCH {
11171                    variables.insert(
11172                        format!("create{slot}"),
11173                        chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11174                    );
11175                    variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11176                }
11177                let data = match self
11178                    .graphql(graphql::CREATE_ISSUES, Value::Object(variables))
11179                    .await
11180                {
11181                    Ok(data) => data,
11182                    Err(error) => break 'create Some(error),
11183                };
11184                let mut refusal = data.get(ANSWER_ERRORS).map(|errors| {
11185                    let said: Vec<&str> = errors
11186                        .as_array()
11187                        .into_iter()
11188                        .flatten()
11189                        .filter_map(|error| error.get("message").and_then(Value::as_str))
11190                        .collect();
11191                    SourceError::Refused {
11192                        message: if said.is_empty() {
11193                            "GitHub returned GraphQL errors".to_owned()
11194                        } else {
11195                            said.join("; ")
11196                        },
11197                    }
11198                });
11199                for slot in 0..chunk.len() {
11200                    match data
11201                        .pointer(&format!("/create{slot}/issue"))
11202                        .filter(|value| !value.is_null())
11203                    {
11204                        Some(created) => match self.created_landed(created).await {
11205                            Ok(created) => landed.push(created),
11206                            Err(error) => {
11207                                refusal.get_or_insert(error);
11208                            }
11209                        },
11210                        // An alias GitHub refused answers nothing, and the refusal says why.
11211                        None => {
11212                            refusal.get_or_insert(SourceError::Malformed {
11213                                message: "GitHub issue creation returned no issue".into(),
11214                            });
11215                        }
11216                    }
11217                }
11218                if let Some(error) = refusal {
11219                    break 'create Some(error);
11220                }
11221            }
11222            None
11223        };
11224        if let Some(error) = refused {
11225            // None of these ids reaches the caller, so an issue GitHub will not take back is
11226            // named here or by nobody.
11227            let ids: Vec<&NativeId> = landed.iter().map(|created| &created.content_id).collect();
11228            let left = self.delete_issues(&ids).await;
11229            let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11230            return Err(left_behind(error, &left));
11231        }
11232        let created: Vec<NativeId> = landed
11233            .iter()
11234            .map(|landed| landed.content_id.clone())
11235            .collect();
11236        let mut ids = Vec::with_capacity(landed.len());
11237        for ((incoming, prepared), landed) in incomings.iter().zip(prepared).zip(landed) {
11238            match self.complete_write(incoming, prepared, landed).await {
11239                Ok(written) => ids.push(written.id),
11240                // 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.
11241                Err(error) => {
11242                    let ids: Vec<&NativeId> = created.iter().collect();
11243                    let left = self.delete_issues(&ids).await;
11244                    let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11245                    return Err(left_behind(error, &left));
11246                }
11247            }
11248        }
11249        Ok(ids)
11250    }
11251
11252    /// Hold back the update of the existing issue `issue` — `members`, what its own
11253    /// [`graphql::UPDATE_ISSUE`] would send beside its id — while writes are being held back — so the updates a copy makes one after
11254    /// another go to GitHub as one request. Whatever this source held of the issue is dropped
11255    /// as the mutation itself would drop it. Answers whether it was held: never while a write
11256    /// the store's cache expects runs, which records each mutation's own answer, and never for
11257    /// an issue an update is already held for, which that one is sent ahead of.
11258    fn hold_update(&self, issue: &NativeId, members: &Value) -> Result<bool, SourceError> {
11259        if self.expected.journaling() || self.deferred()?.is_none() {
11260            return Ok(false);
11261        }
11262        let mut held = self
11263            .held_updates
11264            .lock()
11265            .map_err(|_| SourceError::Unavailable {
11266                message: "issue updates held back were left inconsistent; run the command again"
11267                    .into(),
11268            })?;
11269        if held.iter().any(|earlier| earlier.issue == *issue) {
11270            return Ok(false);
11271        }
11272        let named = |value: &str| issue.0 == value;
11273        self.resolved_cache()?
11274            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11275        self.read_ahead()?.retain(|id, item| {
11276            !named(&id.0) && !item.as_ref().is_some_and(|item| named(&item.item_id))
11277        });
11278        self.carried()?
11279            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11280        held.push(HeldUpdate::new(issue, members));
11281        Ok(true)
11282    }
11283
11284    /// Send every update [`Self::hold_update`] held back: one alone as [`graphql::UPDATE_ISSUE`]
11285    /// sends it, several as aliased `updateIssue`s of [`graphql::UPDATE_ISSUES`],
11286    /// [`MUTATION_BATCH`] to a request. Each counts one write, as it would alone.
11287    async fn send_held_updates(&self) -> Result<(), SourceError> {
11288        let held = std::mem::take(&mut *self.held_updates.lock().map_err(|_| {
11289            SourceError::Unavailable {
11290                message: "issue updates held back were left inconsistent; run the command again"
11291                    .into(),
11292            }
11293        })?);
11294        if let [one] = held.as_slice() {
11295            return self
11296                .send_update(
11297                    graphql::UPDATE_ISSUE,
11298                    "/updateIssue/issue",
11299                    &one.issue,
11300                    one.input.clone(),
11301                )
11302                .await;
11303        }
11304        for chunk in held.chunks(MUTATION_BATCH) {
11305            let mut variables = serde_json::Map::new();
11306            for slot in 0..MUTATION_BATCH {
11307                variables.insert(
11308                    format!("update{slot}"),
11309                    chunk.get(slot).unwrap_or(&chunk[0]).input.clone(),
11310                );
11311                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11312            }
11313            let data = self
11314                .graphql(graphql::UPDATE_ISSUES, Value::Object(variables))
11315                .await?;
11316            let inputs: Vec<&Value> = chunk.iter().map(|held| &held.input).collect();
11317            check_aliased_answers(
11318                graphql::UPDATE_ISSUES,
11319                &data,
11320                "update",
11321                &inputs,
11322                &[("issue", "id")],
11323            )?;
11324        }
11325        Ok(())
11326    }
11327
11328    /// Send everything held back since [`TaskSource::begin_deferred_writes`]: one read of the
11329    /// created items' board items, then their field values and edges together in requests of
11330    /// [`WRITE_BATCH`], then their closes in requests of [`MUTATION_BATCH`]. Anything refused
11331    /// takes back every issue held, [`DELETE_BATCH`] to a request, and answers the refusal.
11332    async fn send_held_back(&self) -> Result<(), SourceError> {
11333        let Some(Deferred::Held { board, created }) = self.deferred()?.take() else {
11334            return Ok(());
11335        };
11336        if created.is_empty() {
11337            return Ok(());
11338        }
11339        let sent = self.send_deferred(board.as_str(), &created).await;
11340        if let Err(error) = sent {
11341            // Best effort, and the failure is what the caller is told, as a write of one item
11342            // that fails part way takes back what it created: the copy that held these back
11343            // journalled every one, so one this cannot take back its undo tries again, and says
11344            // so when it cannot either.
11345            let ids: Vec<&NativeId> = created.iter().map(|held| &held.content).collect();
11346            let _ = self.delete_issues(&ids).await;
11347            return Err(error);
11348        }
11349        Ok(())
11350    }
11351
11352    /// The requests [`Self::send_held_back`] makes.
11353    async fn send_deferred(&self, board: &str, created: &[HeldBack]) -> Result<(), SourceError> {
11354        let items = self.created_board_items(board, created).await?;
11355        let mut fields: Vec<Value> = Vec::new();
11356        let mut closes: Vec<Value> = Vec::new();
11357        let mut edges: Vec<Value> = Vec::new();
11358        for held in created {
11359            let (item, status) = &items[&held.content];
11360            let mut values = held.fields.clone();
11361            // A status the board already shows — set by a workflow a person turned on to file
11362            // new items at it — is not written again.
11363            if let Some((field, option, name)) = &held.status
11364                && status.as_deref() != Some(name.as_str())
11365            {
11366                values.insert(0, (field.clone(), json!({"singleSelectOptionId": option})));
11367            }
11368            for (field, value) in values {
11369                fields.push(json!({"projectId":board,"itemId":item,"fieldId":field,"value":value}));
11370            }
11371            if let Some(reason) = held.close {
11372                closes.push(json!({"id": held.content.0, "stateInput": closed_input(reason)}));
11373            }
11374            for far in &held.edges {
11375                edges.push(json!({"issueId": held.content.0, "blockingIssueId": far.0}));
11376            }
11377            if !held.clears.is_empty() {
11378                let clears: Vec<&str> = held.clears.iter().map(String::as_str).collect();
11379                self.set_item_fields(board, item, &[], &clears).await?;
11380            }
11381        }
11382        // Each alias answers with what it wrote, which is what its input named: the board item a
11383        // field value is set on, the issue closed, the issue blocked and the one blocking it.
11384        let field_answer: &[(&str, &str)] = &[("projectV2Item", "itemId")];
11385        let close_answer: &[(&str, &str)] = &[("issue", "id")];
11386        let block_answer: &[(&str, &str)] =
11387            &[("issue", "issueId"), ("blockingIssue", "blockingIssueId")];
11388        // Every field value, then every edge, as one ordered list sent `WRITE_BATCH` aliases to a
11389        // request: a request holding both kinds is the one document carrying both, so a plan's
11390        // values and edges cost one request where they fit in one.
11391        let writes: Vec<HeldWrite<'_>> = fields
11392            .iter()
11393            .map(HeldWrite::Field)
11394            .chain(edges.iter().map(HeldWrite::Edge))
11395            .collect();
11396        for chunk in writes.chunks(WRITE_BATCH) {
11397            let values: Vec<&Value> = chunk
11398                .iter()
11399                .filter_map(|write| match write {
11400                    HeldWrite::Field(input) => Some(*input),
11401                    HeldWrite::Edge(_) => None,
11402                })
11403                .collect();
11404            let blocks: Vec<&Value> = chunk
11405                .iter()
11406                .filter_map(|write| match write {
11407                    HeldWrite::Edge(input) => Some(*input),
11408                    HeldWrite::Field(_) => None,
11409                })
11410                .collect();
11411            let (document, sends) = match (values.is_empty(), blocks.is_empty()) {
11412                (false, false) => (graphql::WRITE_FIELDS_AND_EDGES, ["sendField", "sendBlock"]),
11413                (false, true) => (graphql::WRITE_FIELD_VALUES, ["send", "send"]),
11414                _ => (graphql::ADD_BLOCKED_BYS, ["send", "send"]),
11415            };
11416            let mut variables = serde_json::Map::new();
11417            let kinds = [
11418                ("field", &values, sends[0], field_answer),
11419                ("block", &blocks, sends[1], block_answer),
11420            ];
11421            for (prefix, inputs, send, _) in &kinds {
11422                if inputs.is_empty() {
11423                    continue;
11424                }
11425                for slot in 0..WRITE_BATCH {
11426                    variables.insert(
11427                        format!("{prefix}{slot}"),
11428                        (*inputs.get(slot).unwrap_or(&inputs[0])).clone(),
11429                    );
11430                    variables.insert(format!("{send}{slot}"), json!(slot < inputs.len()));
11431                }
11432            }
11433            let data = self.graphql(document, Value::Object(variables)).await?;
11434            for (prefix, inputs, _, answers) in kinds {
11435                check_aliased_answers(document, &data, prefix, inputs, answers)?;
11436            }
11437        }
11438        for chunk in closes.chunks(MUTATION_BATCH) {
11439            let mut variables = serde_json::Map::new();
11440            for slot in 0..MUTATION_BATCH {
11441                variables.insert(
11442                    format!("close{slot}"),
11443                    chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11444                );
11445                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11446            }
11447            let data = self
11448                .graphql(graphql::CLOSE_ISSUES, Value::Object(variables))
11449                .await?;
11450            let inputs: Vec<&Value> = chunk.iter().collect();
11451            check_aliased_answers(graphql::CLOSE_ISSUES, &data, "close", &inputs, close_answer)?;
11452        }
11453        for held in created {
11454            self.learn_item(&held.content, &items[&held.content].0)?;
11455        }
11456        Ok(())
11457    }
11458
11459    /// The board item each of `created` is on this board, with its `Status` as the board set it:
11460    /// [`graphql::CREATED_BOARD_ITEMS`], read again — after a wait of a second, then two, four,
11461    /// eight and sixteen — for only those GitHub has not filed yet.
11462    ///
11463    /// **Membership is never assumed.** `createIssue`'s `projectV2Ids` files an issue some time
11464    /// after GitHub answered the create — the feasibility spike saw 0.45 to 1.84 seconds, and no
11465    /// create answer carrying the item — so only a read says it is there. One still absent once
11466    /// those reads are spent is filed with [`graphql::ADD_TO_BOARD`], one write each; GitHub
11467    /// filing it meanwhile refuses that "Content already exists in this project", which says it
11468    /// is placed, and one more read finds the item rather than a second one being made.
11469    async fn created_board_items(
11470        &self,
11471        board: &str,
11472        created: &[HeldBack],
11473    ) -> Result<BTreeMap<NativeId, (String, Option<String>)>, SourceError> {
11474        let mut found: BTreeMap<NativeId, (String, Option<String>)> = BTreeMap::new();
11475        let unfiled = |found: &BTreeMap<NativeId, (String, Option<String>)>| -> Vec<NativeId> {
11476            created
11477                .iter()
11478                .map(|held| held.content.clone())
11479                .filter(|id| !found.contains_key(id))
11480                .collect()
11481        };
11482        let mut wait = Duration::from_secs(1);
11483        for attempt in 0..6 {
11484            let absent = unfiled(&found);
11485            if absent.is_empty() {
11486                return Ok(found);
11487            }
11488            if attempt > 0 {
11489                self.clock
11490                    .sleep_because(wait, pacing::cause::BOARD_FILING)
11491                    .await;
11492                wait = wait.saturating_mul(2);
11493            }
11494            self.read_created_items(board, &absent, &mut found).await?;
11495        }
11496        let mut placed_meanwhile = Vec::new();
11497        for id in unfiled(&found) {
11498            match self
11499                .graphql(
11500                    graphql::ADD_TO_BOARD,
11501                    json!({"input":{"projectId":board,"contentId":id.0}}),
11502                )
11503                .await
11504            {
11505                Ok(added) => {
11506                    let item = added
11507                        .pointer("/addProjectV2ItemById/item")
11508                        .filter(|value| !value.is_null())
11509                        .ok_or_else(|| SourceError::Malformed {
11510                            message: "GitHub board addition returned no project item".into(),
11511                        })?;
11512                    found.insert(id, (required_nonblank_str(item, "id")?.to_owned(), None));
11513                }
11514                Err(error) if already_on_the_board(&error) => placed_meanwhile.push(id),
11515                Err(error) => return Err(error),
11516            }
11517        }
11518        if !placed_meanwhile.is_empty() {
11519            self.read_created_items(board, &placed_meanwhile, &mut found)
11520                .await?;
11521        }
11522        let absent = unfiled(&found);
11523        if absent.is_empty() {
11524            return Ok(found);
11525        }
11526        let absent: Vec<&str> = absent.iter().map(|id| id.0.as_str()).collect();
11527        Err(SourceError::Unavailable {
11528            message: format!(
11529                "GitHub created {} filed on board {board} and, after 31 seconds and adding {} \
11530                 to it, still answers {} on no item of it: {}; next: run the copy again once \
11531                 GitHub has caught up",
11532                if absent.len() == 1 {
11533                    "an issue"
11534                } else {
11535                    "issues"
11536                },
11537                if absent.len() == 1 { "it" } else { "them" },
11538                if absent.len() == 1 { "it" } else { "them" },
11539                absent.join(", ")
11540            ),
11541        })
11542    }
11543
11544    /// One [`graphql::CREATED_BOARD_ITEMS`] read of each of `ids`, [`COPY_DETAIL_BATCH`] to a
11545    /// request, adding to `found` the board item of every one GitHub has filed on `board`.
11546    async fn read_created_items(
11547        &self,
11548        board: &str,
11549        ids: &[NativeId],
11550        found: &mut BTreeMap<NativeId, (String, Option<String>)>,
11551    ) -> Result<(), SourceError> {
11552        for chunk in ids.chunks(COPY_DETAIL_BATCH) {
11553            let mut variables = serde_json::Map::new();
11554            for slot in 0..COPY_DETAIL_BATCH {
11555                variables.insert(
11556                    format!("id{slot}"),
11557                    json!(chunk.get(slot).unwrap_or(&chunk[0]).0),
11558                );
11559                variables.insert(format!("read{slot}"), json!(slot < chunk.len()));
11560            }
11561            variables.insert("boardItems".into(), json!(BOARD_ITEMS_PAGE_SIZE));
11562            variables.insert("statusName".into(), json!(STATUS_FIELD));
11563            let data = self
11564                .graphql(graphql::CREATED_BOARD_ITEMS, Value::Object(variables))
11565                .await?;
11566            for (slot, id) in chunk.iter().enumerate() {
11567                // An answer for another issue than the one asked about says nothing of
11568                // where that one is filed, and its board item is not this one's.
11569                let node = data.get(format!("i{slot}")).filter(|node| !node.is_null());
11570                let named = node.map(|node| node.get("id").and_then(Value::as_str));
11571                if let Some(named) = named
11572                    && named != Some(id.0.as_str())
11573                {
11574                    return Err(SourceError::Malformed {
11575                        message: format!(
11576                            "GitHub answered the board items of {} when asked for those of {}",
11577                            named.unwrap_or("an issue it did not name"),
11578                            id.0
11579                        ),
11580                    });
11581                }
11582                // An issue GitHub does not answer yet is not filed yet; one it answers is
11583                // read whole, its connection, each board item's board and its status, and
11584                // any of them unreadable is refused rather than read as not filed.
11585                let Some(node) = node else { continue };
11586                let malformed = |what: &str| SourceError::Malformed {
11587                    message: format!("GitHub answered the board items of {} with {what}", id.0),
11588                };
11589                let nodes = node
11590                    .pointer("/projectItems/nodes")
11591                    .and_then(Value::as_array)
11592                    .ok_or_else(|| malformed("no list of board items"))?;
11593                let mut item = None;
11594                for node in nodes {
11595                    let on = node
11596                        .pointer("/project/id")
11597                        .and_then(Value::as_str)
11598                        .ok_or_else(|| malformed("a board item naming no board"))?;
11599                    if on == board {
11600                        item = Some(node);
11601                    }
11602                }
11603                if let Some(item) = item {
11604                    let status = match item.get("status") {
11605                        None | Some(Value::Null) => None,
11606                        Some(status) => Some(
11607                            status
11608                                .get("name")
11609                                .and_then(Value::as_str)
11610                                .ok_or_else(|| malformed("a status that names no option"))?
11611                                .to_owned(),
11612                        ),
11613                    };
11614                    found.insert(
11615                        id.clone(),
11616                        (required_nonblank_str(item, "id")?.to_owned(), status),
11617                    );
11618                }
11619            }
11620        }
11621        Ok(())
11622    }
11623
11624    /// The board item id of `content`, which this process created and remembered without one,
11625    /// in every half of its own view.
11626    fn learn_item(&self, content: &NativeId, item: &str) -> Result<(), SourceError> {
11627        if let Some(held) = self.resolved_cache()?.get_mut(content) {
11628            held.item_id = item.to_owned();
11629        }
11630        if let Some(held) = self.created()?.iter_mut().find(|held| held.id == *content) {
11631            held.item_id = item.to_owned();
11632        }
11633        Ok(())
11634    }
11635
11636    /// Ask together which items each of `origins` names, and what creating an item reads; see
11637    /// [`TaskSource::read_ahead_origins_for_copy`]. An origin whose answer runs past its first
11638    /// page is left to its own lookup.
11639    async fn origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
11640        if self.repository.is_none() {
11641            return Ok(());
11642        }
11643        let unasked: Vec<&String> = {
11644            let held = self.narrowed_cache()?;
11645            let mut unasked: Vec<&String> = Vec::new();
11646            for origin in origins {
11647                if !held.contains_key(&Narrowing::Origin(origin.clone()).key())
11648                    && !unasked.contains(&origin)
11649                {
11650                    unasked.push(origin);
11651                }
11652            }
11653            unasked
11654        };
11655        if unasked.is_empty() {
11656            return Ok(());
11657        }
11658        // One batch is held back for the read ahead that follows, which may carry a project's
11659        // members beside it in the same request.
11660        if unasked.len() <= ORIGIN_BATCH {
11661            let mut pending =
11662                self.pending_origins
11663                    .lock()
11664                    .map_err(|_| SourceError::Unavailable {
11665                        message:
11666                            "origins held for a read ahead were left inconsistent; run the command \
11667                          again"
11668                                .into(),
11669                    })?;
11670            for origin in unasked {
11671                if !pending.contains(origin) {
11672                    pending.push(origin.clone());
11673                }
11674            }
11675            return Ok(());
11676        }
11677        let unasked: Vec<String> = unasked.into_iter().cloned().collect();
11678        self.send_origin_lookups(&unasked, None).await
11679    }
11680
11681    /// Hold, for [`TaskSource::member_classifications`] of `project`, each member's
11682    /// classification and repositories as the members page `data` carries them — when that page
11683    /// is every member, each one names `project` as its parent, and every record it holds is
11684    /// readable. Any other page holds nothing, so the members are read as they always were.
11685    fn hold_member_classes(&self, project: &NativeId, data: &Value) -> Result<(), SourceError> {
11686        let Some(page) = data
11687            .pointer("/members/subIssues")
11688            .filter(|page| !page.is_null())
11689        else {
11690            return Ok(());
11691        };
11692        if page
11693            .pointer("/pageInfo/hasNextPage")
11694            .and_then(Value::as_bool)
11695            != Some(false)
11696        {
11697            return Ok(());
11698        }
11699        let Some(nodes) = page.get("nodes").and_then(Value::as_array) else {
11700            return Ok(());
11701        };
11702        let mut members = Vec::with_capacity(nodes.len());
11703        for node in nodes {
11704            if node.pointer("/parent/id").and_then(Value::as_str) != Some(project.0.as_str()) {
11705                return Ok(());
11706            }
11707            let Some(body) = node.get("body").and_then(Value::as_str) else {
11708                return Ok(());
11709            };
11710            // Every node here is filed under `project`, which `BoardKind::of` reads as one of its
11711            // tasks whatever its marker says, so each is a member the full read would classify.
11712            let Ok((_, slot)) = metadata_body(Some(body.to_owned())) else {
11713                return Ok(());
11714            };
11715            let Ok(classification) = Classification::from_metadata(&slot) else {
11716                return Ok(());
11717            };
11718            let repositories = if slot.contains_key(Repository::METADATA_KEY) {
11719                let Ok(repositories) = Repository::from_metadata(&slot) else {
11720                    return Ok(());
11721                };
11722                repositories
11723            } else {
11724                let Some(origin) = node
11725                    .pointer("/repository/nameWithOwner")
11726                    .and_then(Value::as_str)
11727                else {
11728                    return Ok(());
11729                };
11730                let Ok(own) = Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST))
11731                else {
11732                    return Ok(());
11733                };
11734                vec![own]
11735            };
11736            members.push((classification, repositories));
11737        }
11738        self.member_classes
11739            .lock()
11740            .map_err(|_| SourceError::Unavailable {
11741                message: "a project's members held for its classification were left \
11742                          inconsistent; run the command again"
11743                    .into(),
11744            })?
11745            .insert(project.clone(), members);
11746        Ok(())
11747    }
11748
11749    /// Send every origin held back for the read ahead, beside the members of `members` when it
11750    /// names a project — or nothing, when none is held.
11751    async fn send_pending_origins(&self, members: Option<&NativeId>) -> Result<(), SourceError> {
11752        let pending = std::mem::take(&mut *self.pending_origins.lock().map_err(|_| {
11753            SourceError::Unavailable {
11754                message: "origins held for a read ahead were left inconsistent; run the command \
11755                          again"
11756                    .into(),
11757            }
11758        })?);
11759        if pending.is_empty() {
11760            return Ok(());
11761        }
11762        self.send_origin_lookups(&pending, members).await
11763    }
11764
11765    /// [`graphql::ORIGIN_LOOKUPS`] of `unasked`, [`ORIGIN_BATCH`] to a request — the first one
11766    /// [`graphql::ORIGIN_LOOKUPS_WITH_MEMBERS`], carrying the members of `members`, when it names
11767    /// a project — holding each origin's answer and, from a members page that is the whole of
11768    /// them, each member's classification.
11769    async fn send_origin_lookups(
11770        &self,
11771        unasked: &[String],
11772        members: Option<&NativeId>,
11773    ) -> Result<(), SourceError> {
11774        let Some(repository) = self.repository.clone() else {
11775            return Ok(());
11776        };
11777        let mut members = members;
11778        for chunk in unasked.chunks(ORIGIN_BATCH) {
11779            let mut variables = json!({"owner":self.owner,"number":self.project_number,
11780                "type":"ISSUE","lookupFirst":ORIGIN_LOOKUPS_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
11781                "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true,
11782                "repositoryOwner":repository.owner,"repositoryName":repository.name});
11783            for slot in 0..ORIGIN_BATCH {
11784                let origin = chunk.get(slot).unwrap_or(&chunk[0]);
11785                variables[format!("filter{slot}")] =
11786                    json!(format!("{ORIGIN_FIELD}:{}", quoted(origin)));
11787                variables[format!("search{slot}")] = json!(
11788                    self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))))
11789                );
11790                variables[format!("ask{slot}")] = json!(slot < chunk.len());
11791            }
11792            let project = members.take();
11793            let document = match project {
11794                Some(project) => {
11795                    variables["project"] = json!(project.0);
11796                    variables["memberFirst"] = json!(MEMBER_PAGE_SIZE);
11797                    graphql::ORIGIN_LOOKUPS_WITH_MEMBERS
11798                }
11799                None => graphql::ORIGIN_LOOKUPS,
11800            };
11801            let data = self.graphql(document, variables).await?;
11802            self.fields_read(&data)?;
11803            if let Some(project) = project {
11804                self.hold_member_classes(project, &data)?;
11805            }
11806            if let Some(node) = data.get("repository").filter(|value| !value.is_null()) {
11807                let id = required_nonblank_str(node, "id")?.to_owned();
11808                self.repository_cache()?.insert(repository.clone(), id);
11809                if let Some(numeric) = node
11810                    .get("databaseId")
11811                    .and_then(Value::as_u64)
11812                    .and_then(std::num::NonZeroU64::new)
11813                {
11814                    self.numeric_repositories
11815                        .lock()
11816                        .await
11817                        .insert(repository.clone(), numeric);
11818                }
11819            }
11820            for (slot, origin) in chunk.iter().enumerate() {
11821                let items = data
11822                    .pointer(&format!("/o{slot}/projectV2/items"))
11823                    .filter(|value| !value.is_null())
11824                    .ok_or_else(|| SourceError::Refused {
11825                        message: format!(
11826                            "GitHub project {}/{} was not found or is not visible to the token",
11827                            self.owner, self.project_number
11828                        ),
11829                    })?;
11830                let searched =
11831                    data.get(format!("s{slot}"))
11832                        .ok_or_else(|| SourceError::Malformed {
11833                            message: "GitHub search response has no search connection".into(),
11834                        })?;
11835                if resumed(items, None)?.has_more() || resumed(searched, None)?.has_more() {
11836                    continue;
11837                }
11838                let mut found: Vec<Resolved> = Vec::new();
11839                for item in optional_nodes(Some(items), "project items")?
11840                    .into_iter()
11841                    .flatten()
11842                {
11843                    if let Some(resolved) = self.resolve(item)?
11844                        && resolved.content_kind == ContentKind::Issue
11845                        && !found.iter().any(|held| held.id == resolved.id)
11846                    {
11847                        found.push(resolved);
11848                    }
11849                }
11850                for node in optional_nodes(Some(searched), "search")?
11851                    .into_iter()
11852                    .flatten()
11853                {
11854                    if let Some(resolved) = self.resolve_issue(node).await?
11855                        && !found.iter().any(|held| held.id == resolved.id)
11856                    {
11857                        found.push(resolved);
11858                    }
11859                }
11860                self.narrowed_cache()?
11861                    .insert(Narrowing::Origin((*origin).clone()).key(), found);
11862            }
11863        }
11864        Ok(())
11865    }
11866
11867    /// Delete every issue of `ids`, [`DELETE_BATCH`] to a request with
11868    /// [`graphql::DELETE_ISSUES`], and answer those still there: each a request's answer does not
11869    /// confirm, every one of a request refused whole included.
11870    async fn delete_issues(&self, ids: &[&NativeId]) -> Vec<NativeId> {
11871        let mut left = Vec::new();
11872        for chunk in ids.chunks(DELETE_BATCH) {
11873            let mut variables = serde_json::Map::new();
11874            for slot in 0..DELETE_BATCH {
11875                let id = chunk.get(slot).unwrap_or(&chunk[0]);
11876                variables.insert(format!("delete{slot}"), json!({"issueId": id.0}));
11877                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11878            }
11879            let data = self
11880                .graphql(graphql::DELETE_ISSUES, Value::Object(variables))
11881                .await
11882                .unwrap_or(Value::Null);
11883            for (slot, id) in chunk.iter().enumerate() {
11884                // Confirmed only by the repository the deleted issue was in, named by a
11885                // non-blank id: anything else says nothing of whether the issue is gone.
11886                let confirmed = data
11887                    .pointer(&format!("/delete{slot}/repository/id"))
11888                    .and_then(Value::as_str)
11889                    .is_some_and(|id| !id.trim().is_empty());
11890                if !confirmed {
11891                    left.push((*id).clone());
11892                }
11893            }
11894        }
11895        left
11896    }
11897
11898    /// Delete one issue, which takes its board item with it.
11899    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
11900        let data = self
11901            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11902            .await?;
11903        data.pointer("/deleteIssue/repository")
11904            .filter(|value| !value.is_null())
11905            .ok_or_else(|| SourceError::Malformed {
11906                message: "GitHub issue deletion returned no repository".into(),
11907            })?;
11908        self.forget(id)?;
11909        Ok(())
11910    }
11911
11912    /// Remove one item this copy created, so a copy that could not finish leaves the board
11913    /// as it found it.
11914    ///
11915    /// Deleting the issue takes its board item with it, so there is no second mutation to
11916    /// keep in step. An id the board does not hold is not an error: the item is already
11917    /// gone, which is the state this asks for. Which that is, is decided by reading the item
11918    /// by its own id — a listing of the board can still be missing an item it holds, and
11919    /// reading that as *already gone* would leave behind the very item this was asked to
11920    /// take back.
11921    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
11922        let Some(item) = self.bound_item(id).await? else {
11923            return Ok(());
11924        };
11925        if item.content_kind == ContentKind::DraftIssue {
11926            return Err(SourceError::Refused {
11927                message: format!(
11928                    "GitHub item {} is a draft, and this source removes an item by deleting \
11929                     its issue; next: remove it from the board by hand",
11930                    id.0
11931                ),
11932            });
11933        }
11934        let data = self
11935            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11936            .await?;
11937        data.pointer("/deleteIssue/repository")
11938            .filter(|value| !value.is_null())
11939            .ok_or_else(|| SourceError::Malformed {
11940                message: "GitHub issue deletion returned no repository".into(),
11941            })?;
11942        self.forget(id)?;
11943        Ok(())
11944    }
11945
11946    /// The issue a comment call on `task` is about, or `None` when this board holds no such
11947    /// task.
11948    ///
11949    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
11950    /// read of the task cannot disagree about which ids name one: a project or a document of
11951    /// this board is not a task here either.
11952    ///
11953    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
11954    /// issues and a draft is not one. It is refused rather than answered with an empty page,
11955    /// which would read as a task nobody has commented on yet.
11956    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
11957        let cached = self.resolved_cache()?.get(task).cloned();
11958        let Some(item) = (match cached {
11959            Some(item) => Some(item),
11960            None => self.item_by_id(task).await?,
11961        })
11962        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11963            return Ok(None);
11964        };
11965        if item.content_kind == ContentKind::DraftIssue {
11966            return Err(self.draft_has_no_comments(task));
11967        }
11968        Ok(Some(item.id))
11969    }
11970
11971    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
11972    /// issues, and a draft is not one.
11973    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
11974        SourceError::Refused {
11975            message: format!(
11976                "task {} of source {} is a draft item on the board, and GitHub keeps \
11977                 comments on issues alone, so a draft has none to read or write; next: \
11978                 convert the draft to an issue on the board, then comment on the issue it \
11979                 becomes",
11980                task.0, self.name
11981            ),
11982        }
11983    }
11984
11985    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
11986    /// request — or `None` when this board holds no task by that id.
11987    ///
11988    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
11989    /// is answered with the draft and the refusal, at the price of the draft's own read.
11990    async fn issue_detail(
11991        &self,
11992        id: &NativeId,
11993        page: &PageRequest,
11994    ) -> Result<Option<TaskDetailRead>, SourceError> {
11995        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
11996        let asked = self
11997            .graphql(
11998                graphql::ISSUE_DETAIL,
11999                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
12000                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
12001                       "duplicates":true}),
12002            )
12003            .await;
12004        let data = match asked {
12005            Ok(data) => data,
12006            Err(error) if unresolvable_node(&error) => return Ok(None),
12007            Err(error) => return Err(error),
12008        };
12009        // `node` is null for an id that names nothing, and absent only from an answer this
12010        // source cannot read — never the same thing.
12011        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
12012            message: format!("GitHub answered the read of {} with no node", id.0),
12013        })?;
12014        self.detail_of(id, node, true, after).await
12015    }
12016
12017    /// Several tasks, each with the first page of its comments when `comments` is set, read
12018    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
12019    /// order.
12020    ///
12021    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
12022    /// one item at a time, so that id is answered as missing and the others as themselves; any
12023    /// other refusal is every id of that batch's answer.
12024    async fn issue_details(
12025        &self,
12026        ids: &[NativeId],
12027        comments: Option<&PageRequest>,
12028    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
12029        let mut read = Vec::with_capacity(ids.len());
12030        for batch in ids.chunks(DETAIL_BATCH) {
12031            match self
12032                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
12033                .await
12034            {
12035                Ok(data) => {
12036                    for (slot, id) in batch.iter().enumerate() {
12037                        // Every alias asked for is answered, null for an id naming nothing;
12038                        // one missing is an answer this source cannot read.
12039                        let read_one = match data.get(format!("i{slot}")) {
12040                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
12041                            None => Err(SourceError::Malformed {
12042                                message: format!(
12043                                    "GitHub answered a batch read with no item for {}",
12044                                    id.0
12045                                ),
12046                            }),
12047                        };
12048                        read.push(read_one);
12049                    }
12050                }
12051                Err(error) if unresolvable_node(&error) => {
12052                    for id in batch {
12053                        read.push(match comments {
12054                            Some(page) => self.issue_detail(id, page).await,
12055                            None => self.task_read(id).await,
12056                        });
12057                    }
12058                }
12059                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
12060            }
12061        }
12062        read
12063    }
12064
12065    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
12066    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
12067        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
12068            task,
12069            comments: None,
12070        }))
12071    }
12072
12073    /// What one node a detail read reached says: the task this board holds by `id`, with the
12074    /// page of comments the node carries when `commented` — or `None` for a node that is no
12075    /// task of this board.
12076    ///
12077    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
12078    /// and an item this process created answers from this process's own record, which a node
12079    /// read taken moments after the write can still be behind.
12080    async fn detail_of(
12081        &self,
12082        id: &NativeId,
12083        node: &Value,
12084        commented: bool,
12085        after: Option<&str>,
12086    ) -> Result<Option<TaskDetailRead>, SourceError> {
12087        if node.is_null() {
12088            return Ok(None);
12089        }
12090        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
12091        // An issue answered under one id is that id's, or the answer is not one this source
12092        // can report: reporting another issue's task and comments under the qualified id asked
12093        // for would be the one wrong answer here. A draft's own read checks the same.
12094        if !draft
12095            && optional_str(node, "__typename")? == Some("Issue")
12096            && required_str(node, "id")? != id.0
12097        {
12098            return Err(SourceError::Malformed {
12099                message: format!(
12100                    "GitHub answered the read of {} with issue {}",
12101                    id.0,
12102                    required_str(node, "id")?
12103                ),
12104            });
12105        }
12106        let item = if draft {
12107            self.draft_by_id(id).await?
12108        } else {
12109            self.resolve_issue(node).await?
12110        };
12111        // A read that carried the whole of what the issue is blocked by — a copy's — is kept
12112        // for this command, so the dependency read the copy makes next is answered from it.
12113        if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
12114            self.resolved_cache()?.insert(read.id.clone(), read.clone());
12115        }
12116        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
12117            return Ok(None);
12118        };
12119        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
12120        let task = own.unwrap_or(item).task()?;
12121        let comments = match (commented, draft) {
12122            (false, _) => None,
12123            (true, true) => Some(Err(self.draft_has_no_comments(id))),
12124            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
12125        };
12126        if commented && !draft && after.is_none() {
12127            self.expected.learn_comments(id, node);
12128        }
12129        Ok(Some(TaskDetailRead { task, comments }))
12130    }
12131
12132    /// Whether the comment `comment` is one of `issue`'s own.
12133    ///
12134    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
12135    /// comment's id and nothing else: a comment id given against the wrong task would
12136    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
12137    /// names something that is not an issue comment, is a comment this task does not have —
12138    /// which is what GitHub refusing to resolve it means too.
12139    async fn comment_is_on(
12140        &self,
12141        issue: &NativeId,
12142        comment: &NativeId,
12143    ) -> Result<bool, SourceError> {
12144        Ok(self.comment_on(issue, comment).await?.is_some())
12145    }
12146
12147    /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
12148    /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
12149    async fn comment_on(
12150        &self,
12151        issue: &NativeId,
12152        comment: &NativeId,
12153    ) -> Result<Option<String>, SourceError> {
12154        // A write the store's cache expects knows which comments its issue held, and their
12155        // bodies, at the version it expects: what this read would answer.
12156        if let Some(primed) = self.expected.primed_comment(issue, comment) {
12157            return Ok(primed);
12158        }
12159        let asked = self
12160            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
12161            .await;
12162        let data = match asked {
12163            Ok(data) => data,
12164            Err(error) if unresolvable_node(&error) => return Ok(None),
12165            Err(error) => return Err(error),
12166        };
12167        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
12168            return Ok(None);
12169        };
12170        if optional_str(node, "__typename")? != Some("IssueComment") {
12171            return Ok(None);
12172        }
12173        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
12174            message: format!("GitHub issue comment {} names no issue", comment.0),
12175        })?;
12176        if required_str(on, "id")? != issue.0 {
12177            return Ok(None);
12178        }
12179        Ok(Some(required_str(node, "body")?.to_owned()))
12180    }
12181
12182    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
12183    async fn partition_edges(
12184        &self,
12185        near_kind: BoardKind,
12186        near_content: ContentKind,
12187        carried: Option<&[Value]>,
12188        depends_on: &[DependencyEdge],
12189    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
12190        let mut native = Vec::new();
12191        let mut fallback = Vec::new();
12192        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
12193            .iter()
12194            .map(|edge| {
12195                // An opaque far end names another tracker, so it is never this board's own.
12196                let same_source = edge.to.designator().is_none()
12197                    && edge
12198                        .to
12199                        .source()
12200                        .is_none_or(|source| source == self.name.as_str());
12201                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
12202                // `DependencyEndpoint::source` both read it that way — and a native id may hold
12203                // colons of its own, so the far end is everything after that one separator.
12204                // Splitting at the last would truncate `work:urn:task:7` to `7`.
12205                let far_id = if edge.to.is_qualified() {
12206                    edge.to
12207                        .id()
12208                        .split_once(':')
12209                        .map_or(edge.to.id(), |(_, native)| native)
12210                } else {
12211                    edge.to.id()
12212                };
12213                // One that already blocks the near issue was answered by that issue's own
12214                // read, which carried each of its blockers' kinds — an issue every one — so it
12215                // is not read again.
12216                let blocking = carried.and_then(|nodes| {
12217                    nodes
12218                        .iter()
12219                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
12220                });
12221                (edge, far_id, same_source, blocking)
12222            })
12223            .collect();
12224        // Every other same-source far end is read by its own id, exactly as the item it is a
12225        // far end of is: whether this board holds it is that read's answer, never a listing's.
12226        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
12227        let mut unread: Vec<NativeId> = Vec::new();
12228        for (_, far_id, same_source, blocking) in &far_ends {
12229            let id = NativeId((*far_id).to_owned());
12230            if *same_source && blocking.is_none() && !unread.contains(&id) {
12231                unread.push(id);
12232            }
12233        }
12234        let read: BTreeMap<NativeId, Option<Resolved>> = unread
12235            .iter()
12236            .cloned()
12237            .zip(self.items_by_ids(&unread).await?)
12238            .collect();
12239        for (edge, far_id, same_source, blocking) in far_ends {
12240            let far = match (same_source, blocking) {
12241                (false, _) => None,
12242                (true, Some(node)) => Some(FarEnd {
12243                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
12244                        BoardKind::Document
12245                    } else {
12246                        BoardKind::Work(related_kind(node, self.sub_issues)?)
12247                    },
12248                    content_kind: ContentKind::Issue,
12249                }),
12250                (true, None) => {
12251                    let read = read
12252                        .get(&NativeId(far_id.to_owned()))
12253                        .cloned()
12254                        .flatten()
12255                        .ok_or_else(|| SourceError::Refused {
12256                            message: format!("GitHub dependency item {far_id} was not found"),
12257                        })?;
12258                    Some(FarEnd {
12259                        kind: read.kind,
12260                        content_kind: read.content_kind,
12261                    })
12262                }
12263            };
12264            let far = far.as_ref();
12265            // The caller says which kind the far end is, and this board holds the far end
12266            // itself, so a disagreement is settled here rather than stored: recorded, the
12267            // wrong kind would read back as a cross-level edge that never existed; written
12268            // natively, it would name a relationship of a different level than the caller
12269            // asked for.
12270            //
12271            // A far end this board holds as a *document* fails the same comparison and is
12272            // refused by the same sentence: `ItemKind` has no document variant because
12273            // nothing may point at one, so no caller can name it correctly and the refusal
12274            // is the only honest answer.
12275            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
12276                return Err(SourceError::Refused {
12277                    message: format!(
12278                        "GitHub dependency item {far_id} is a {} of this board, and this item \
12279                         names it as a {}; record the kind it is",
12280                        disagreeing.kind.describes(),
12281                        edge.to.kind.marker()
12282                    ),
12283                });
12284            }
12285            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
12286            // however the far end is spelled — and one classified native here would be
12287            // written nowhere at all, because a draft's native reconciliation never runs.
12288            let native_here = near_content == ContentKind::Issue
12289                && far.is_some_and(|far| {
12290                    far.content_kind == ContentKind::Issue
12291                        && BoardKind::Work(edge.to.kind) == near_kind
12292                });
12293            if native_here {
12294                native.push(far_id.to_owned());
12295            } else {
12296                fallback.push(edge.clone());
12297            }
12298        }
12299        Ok((native, fallback))
12300    }
12301
12302    async fn update_existing(
12303        &self,
12304        item: &Resolved,
12305        incoming: &Incoming<'_>,
12306        body: &Option<String>,
12307        status_target: Option<&StatusTarget>,
12308    ) -> Result<(), SourceError> {
12309        let title = incoming.written_title();
12310        // A terminal status closes the issue here, in the same mutation as its body: its board
12311        // option was selected before this, so a close never lands on an item whose board cannot
12312        // show it.
12313        let mut fields = match item.content_kind {
12314            ContentKind::DraftIssue => json!({"title":title,"body":body}),
12315            ContentKind::Issue => json!({"title":title,"body":body,
12316                                         "stateInput":state_input(status_target)}),
12317        };
12318        // Only the members whose value differs from the item as this write read it are sent,
12319        // and nothing at all when none does — whether or not the store's cache expects a
12320        // version of it. A member left out is one GitHub leaves as it is, so a person's edit
12321        // of it since that read is kept rather than overwritten with what it already held,
12322        // and a re-copy of an unchanged item costs no mutation.
12323        if let Some(members) = fields.as_object_mut() {
12324            if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
12325                members.remove("title");
12326            }
12327            let body_unchanged = match (members.get("body"), item.raw_body.as_deref()) {
12328                (Some(Value::String(sent)), Some(held)) => sent == held,
12329                (Some(Value::Null), held) => held.is_none_or(str::is_empty),
12330                _ => false,
12331            };
12332            if body_unchanged {
12333                members.remove("body");
12334            }
12335            // An issue already in the state the target asks for keeps it: an open one stays
12336            // open, and a closed one whose closure reads as the target's category keeps its
12337            // reason — a duplicate's says more than this write would.
12338            let unchanged_state = match status_target {
12339                Some(StatusTarget::Terminal(_, reason)) => {
12340                    item.closed
12341                        && closed_category(item.reason.as_deref())
12342                            == closed_category(Some(reason.reason()))
12343                }
12344                Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => !item.closed,
12345                None => true,
12346            };
12347            if unchanged_state {
12348                members.remove("stateInput");
12349            }
12350            if members.is_empty() {
12351                return Ok(());
12352            }
12353        }
12354        self.update_content(item.content_kind, &item.id, fields)
12355            .await
12356    }
12357
12358    /// Update one board item's content with exactly `fields` beside its id, through the
12359    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
12360    /// a draft.
12361    ///
12362    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
12363    /// is what lets a narrow write carry the one thing it changes and nothing else.
12364    async fn update_content(
12365        &self,
12366        kind: ContentKind,
12367        id: &NativeId,
12368        fields: Value,
12369    ) -> Result<(), SourceError> {
12370        let (operation, id_key, pointer) = match kind {
12371            ContentKind::DraftIssue => (
12372                graphql::UPDATE_DRAFT,
12373                "draftIssueId",
12374                "/updateProjectV2DraftIssue/draftIssue",
12375            ),
12376            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
12377        };
12378        if kind == ContentKind::Issue && self.hold_issue(&fields)? {
12379            return Ok(());
12380        }
12381        if kind == ContentKind::Issue && self.hold_update(id, &fields)? {
12382            return Ok(());
12383        }
12384        let mut input = fields;
12385        input[id_key] = json!(id.0);
12386        self.send_update(operation, pointer, id, input).await
12387    }
12388
12389    /// Send one item's update, `input`, as `operation`, and hold GitHub's answer at `pointer`
12390    /// to naming that item, `id`.
12391    async fn send_update(
12392        &self,
12393        operation: &str,
12394        pointer: &str,
12395        id: &NativeId,
12396        input: Value,
12397    ) -> Result<(), SourceError> {
12398        let data = self.graphql(operation, json!({"input":input})).await?;
12399        let returned = data
12400            .pointer(pointer)
12401            .ok_or_else(|| SourceError::Malformed {
12402                message: "GitHub item update returned no item".into(),
12403            })?;
12404        if required_str(returned, "id")? != id.0 {
12405            return Err(SourceError::Malformed {
12406                message: "GitHub item update returned the wrong item".into(),
12407            });
12408        }
12409        Ok(())
12410    }
12411
12412    /// Creates one issue, files it on the board, and reports what a read of it would say:
12413    /// its content id, its board item id, and the web address GitHub gave it.
12414    ///
12415    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
12416    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
12417    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
12418    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
12419    /// "Content already exists in this project". A terminal status is not written here:
12420    /// `finish_write` selects its option first and closes the issue after, so a close never
12421    /// lands on an item whose board cannot show it.
12422    ///
12423    /// The address and the number come back here because this is the only place either is
12424    /// known before GitHub's own board read catches up — an item this run created answers
12425    /// the reads that follow it out of the record below, and one remembered without them
12426    /// would report no location and no key for the rest of the run.
12427    async fn create_and_file_issue(
12428        &self,
12429        board_id: &str,
12430        repository: &RepositoryTarget,
12431        incoming: &Incoming<'_>,
12432        body: &Option<String>,
12433    ) -> Result<Landed, SourceError> {
12434        let repository_id = self.repository_id(repository, incoming).await?;
12435        let data = self
12436            .graphql(
12437                graphql::CREATE_ISSUE,
12438                json!({"input":{
12439                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
12440                }}),
12441            )
12442            .await?;
12443        let created = data
12444            .pointer("/createIssue/issue")
12445            .filter(|value| !value.is_null())
12446            .ok_or_else(|| SourceError::Malformed {
12447                message: "GitHub issue creation returned no issue".into(),
12448            })?;
12449        let content_id = NativeId(required_str(created, "id")?.to_owned());
12450        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
12451        // a response without it is not worth failing a landed write over — the item simply
12452        // reports no location until the board read catches up, which is what it did before.
12453        let url = optional_str(created, "url")?.map(str::to_owned);
12454        // The issue exists from here on, so an unreadable number and a refused board
12455        // filing below each try, best effort, to take it back: an issue in the repository
12456        // that is on no board is an item nobody asked for and nothing here would find again.
12457        //
12458        // Its number is optional on the same terms its address is — a landed write is not
12459        // worth failing over a member that came back missing, and such an item reports no
12460        // handle until a board read catches up. A number that is *present* and is not an
12461        // unsigned integer is still a response this source cannot read.
12462        let number = match created_issue_number(created) {
12463            Ok(number) => number,
12464            Err(error) => {
12465                let _ = self.delete_issue(&content_id).await;
12466                return Err(error);
12467            }
12468        };
12469        let added = match self
12470            .graphql(
12471                graphql::ADD_TO_BOARD,
12472                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
12473            )
12474            .await
12475        {
12476            Ok(added) => added,
12477            Err(error) => {
12478                let _ = self.delete_issue(&content_id).await;
12479                return Err(error);
12480            }
12481        };
12482        let item = added
12483            .pointer("/addProjectV2ItemById/item")
12484            .filter(|value| !value.is_null())
12485            .ok_or_else(|| SourceError::Malformed {
12486                message: "GitHub board addition returned no project item".into(),
12487            })?;
12488        self.note_written(&content_id, url.as_deref());
12489        Ok(Landed {
12490            content_id,
12491            item_id: Some(required_str(item, "id")?.to_owned()),
12492            url,
12493            number,
12494        })
12495    }
12496
12497    /// Move one issue under the project it now belongs to, or out of the one it left.
12498    async fn reparent(
12499        &self,
12500        held: Option<NativeId>,
12501        child: &NativeId,
12502        wanted: Option<&NativeId>,
12503    ) -> Result<(), SourceError> {
12504        if held.as_ref() == wanted {
12505            return Ok(());
12506        }
12507        if let Some(held) = &held {
12508            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
12509                .await?;
12510        }
12511        if let Some(wanted) = wanted {
12512            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
12513                .await?;
12514        }
12515        Ok(())
12516    }
12517
12518    async fn sub_issue(
12519        &self,
12520        operation: &str,
12521        parent: &NativeId,
12522        child: &NativeId,
12523        root: &str,
12524    ) -> Result<(), SourceError> {
12525        let data = self
12526            .graphql(
12527                operation,
12528                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
12529            )
12530            .await?;
12531        let issue =
12532            data.pointer(&format!("/{root}/issue"))
12533                .ok_or_else(|| SourceError::Malformed {
12534                    message: "GitHub sub-issue update returned no issue".into(),
12535                })?;
12536        let sub =
12537            data.pointer(&format!("/{root}/subIssue"))
12538                .ok_or_else(|| SourceError::Malformed {
12539                    message: "GitHub sub-issue update returned no sub-issue".into(),
12540                })?;
12541        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
12542            return Err(SourceError::Malformed {
12543                message: "GitHub sub-issue update returned the wrong issues".into(),
12544            });
12545        }
12546        Ok(())
12547    }
12548
12549    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
12550    /// whether there was one.
12551    ///
12552    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
12553    /// relationships are not read: there is nothing a read of them could find.
12554    async fn reconcile_blocked_by(
12555        &self,
12556        content_id: &NativeId,
12557        native: &[String],
12558        issue: Issue<'_>,
12559    ) -> Result<bool, SourceError> {
12560        let current = match issue {
12561            Issue::Created => Vec::new(),
12562            Issue::Existing(Some(held)) => held
12563                .iter()
12564                .map(|far| required_str(far, "id").map(str::to_owned))
12565                .collect::<Result<Vec<_>, _>>()?,
12566            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
12567        };
12568        let mut changed = false;
12569        for (operation, far_id) in current
12570            .iter()
12571            .filter(|id| !native.contains(id))
12572            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
12573            .chain(
12574                native
12575                    .iter()
12576                    .filter(|id| !current.contains(id))
12577                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
12578            )
12579        {
12580            let data = self
12581                .graphql(
12582                    operation,
12583                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
12584                )
12585                .await?;
12586            let root = if operation == graphql::ADD_BLOCKED_BY {
12587                "addBlockedBy"
12588            } else {
12589                "removeBlockedBy"
12590            };
12591            let issue =
12592                data.pointer(&format!("/{root}/issue"))
12593                    .ok_or_else(|| SourceError::Malformed {
12594                        message: "GitHub dependency update returned no issue".into(),
12595                    })?;
12596            let blocker = data
12597                .pointer(&format!("/{root}/blockingIssue"))
12598                .ok_or_else(|| SourceError::Malformed {
12599                    message: "GitHub dependency update returned no blocking issue".into(),
12600                })?;
12601            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
12602            {
12603                return Err(SourceError::Malformed {
12604                    message: "GitHub dependency update returned the wrong issues".into(),
12605                });
12606            }
12607            changed = true;
12608        }
12609        Ok(changed)
12610    }
12611}
12612
12613/// The board field values one write sends, in the order GitHub runs them, and the fields it
12614/// clears after them: the origin field when it moves, the `Status` option `column` names, the
12615/// priority, then each projected field.
12616fn field_writes<'a>(
12617    existing: Option<&Resolved>,
12618    origin_field: Option<&str>,
12619    origin: &str,
12620    column: Option<(String, String)>,
12621    priority: Option<&'a PriorityWrite>,
12622    projections: &'a [ProjectionWrite],
12623) -> (Vec<(String, Value)>, Vec<&'a str>) {
12624    let mut fields = Vec::new();
12625    if let Some(field_id) = origin_field
12626        && existing.map_or(!origin.is_empty(), |item| {
12627            item.origin.as_deref().unwrap_or("") != origin
12628        })
12629    {
12630        fields.push((field_id.to_owned(), json!({"text":origin})));
12631    }
12632    if let Some((field_id, option_id)) = column {
12633        fields.push((field_id, json!({"singleSelectOptionId":option_id})));
12634    }
12635    let mut clears = Vec::new();
12636    match priority {
12637        Some(PriorityWrite::Select { field, option }) => {
12638            fields.push((field.clone(), json!({"singleSelectOptionId":option})));
12639        }
12640        Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
12641        None => {}
12642    }
12643    for projection in projections {
12644        projection.join(&mut fields, &mut clears);
12645    }
12646    (fields, clears)
12647}
12648
12649/// Everything one write decided before sending anything: see
12650/// [`GitHubProjectsSource::prepare_write`].
12651struct Prepared {
12652    /// What the write lands on.
12653    destination: WriteDestination,
12654    board: BoardFields,
12655    status_target: Option<StatusTarget>,
12656    /// The `Status` field, the option and the option's name the status lands on.
12657    // 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.
12658    column: Option<(String, String, String)>,
12659    priority_write: Option<PriorityWrite>,
12660    projections: Vec<ProjectionWrite>,
12661    projected: BTreeMap<String, String>,
12662    content_kind: ContentKind,
12663    own_repository: Option<Repository>,
12664    /// The far ends of the item's native blocked-by relationships.
12665    // 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.
12666    native: Vec<String>,
12667    slot: BTreeMap<String, Value>,
12668    content: Option<String>,
12669    body: Option<String>,
12670    /// The copy origin the item records, empty for none.
12671    // 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.
12672    origin: String,
12673    origin_field: Option<String>,
12674}
12675
12676/// One write a copy held back of the kinds sent together: a board field value, or a blocked-by
12677/// edge, each its input.
12678enum HeldWrite<'a> {
12679    Field(&'a Value),
12680    Edge(&'a Value),
12681}
12682
12683/// Whether `error` is GitHub refusing to file an issue on a board because it is filed there
12684/// already — what `addProjectV2ItemById` answers once `createIssue`'s `projectV2Ids` has placed
12685/// it, rather than answering with the item it holds.
12686fn already_on_the_board(error: &SourceError) -> bool {
12687    error
12688        .to_string()
12689        .contains("Content already exists in this project")
12690}
12691
12692/// Hold every alias `inputs` sent under `prefix` in `data` to answering with what its input
12693/// named — each `(root, named)` of `answers` — and refuse one that answered nothing or another
12694/// item.
12695fn check_aliased_answers(
12696    document: &str,
12697    data: &Value,
12698    prefix: &str,
12699    inputs: &[&Value],
12700    answers: &[(&str, &str)],
12701) -> Result<(), SourceError> {
12702    for (slot, input) in inputs.iter().enumerate() {
12703        for (root, named) in answers {
12704            let answered = data
12705                .pointer(&format!("/{prefix}{slot}/{root}/id"))
12706                .and_then(Value::as_str)
12707                .ok_or_else(|| SourceError::Malformed {
12708                    message: format!(
12709                        "GitHub answered {} with nothing at {prefix}{slot}",
12710                        operation_description(document)
12711                    ),
12712                })?;
12713            let asked = input.get(named).and_then(Value::as_str).unwrap_or_default();
12714            if answered != asked {
12715                return Err(SourceError::Malformed {
12716                    message: format!(
12717                        "GitHub answered {} at {prefix}{slot} with {answered}, which is not \
12718                         {asked}, the item it was asked to write",
12719                        operation_description(document)
12720                    ),
12721                });
12722            }
12723        }
12724    }
12725    Ok(())
12726}
12727
12728/// `error`, which stopped a batch of creates, and — when GitHub refused to take any of them
12729/// back — each issue of `left` it created that is still in the repository, which its caller,
12730/// never handed their ids, could not name.
12731fn left_behind(error: SourceError, left: &[&str]) -> SourceError {
12732    if left.is_empty() {
12733        return error;
12734    }
12735    SourceError::Refused {
12736        message: format!(
12737            "{error}; GitHub then refused to take back {}, which this write created and which \
12738             are still in the repository; next: delete {} before writing again",
12739            left.join(", "),
12740            if left.len() == 1 { "it" } else { "them" }
12741        ),
12742    }
12743}
12744
12745/// What one write lands on: the item it updates, as it stood, or where it creates a new one.
12746enum WriteDestination {
12747    /// The item as it stood, for an update.
12748    Existing(Box<Resolved>),
12749    /// Where a new item is created.
12750    New(RepositoryTarget),
12751}
12752
12753impl WriteDestination {
12754    /// The item an update writes over, as it stood; `None` for a create.
12755    fn existing(&self) -> Option<&Resolved> {
12756        match self {
12757            Self::Existing(item) => Some(item),
12758            Self::New(_) => None,
12759        }
12760    }
12761}
12762
12763/// One existing issue's update held back by [`GitHubProjectsSource::hold_update`]: the issue,
12764/// and the `UpdateIssueInput` its own [`graphql::UPDATE_ISSUE`] would have sent — the members
12765/// it writes beside that issue's id, which only [`Self::new`] sets.
12766struct HeldUpdate {
12767    issue: NativeId,
12768    // 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.
12769    input: Value,
12770}
12771
12772impl HeldUpdate {
12773    /// The update writing `members` — an `UpdateIssueInput` without its id — to `issue`.
12774    fn new(issue: &NativeId, members: &Value) -> Self {
12775        let mut input = members.clone();
12776        input["id"] = json!(issue.0);
12777        Self {
12778            issue: issue.clone(),
12779            input,
12780        }
12781    }
12782}
12783
12784/// What a copy has held back of the items it created; see
12785/// [`GitHubProjectsSource::hold_back`].
12786#[derive(Debug, Default)]
12787enum Deferred {
12788    /// Writes are being held back, and nothing has been held yet.
12789    #[default]
12790    Nothing,
12791    /// The items held back, every one of them on `board`.
12792    Held {
12793        /// The board every one of them is on.
12794        board: BoardId,
12795        /// Each created item, in the order it was created.
12796        created: Vec<HeldBack>,
12797    },
12798}
12799
12800impl Deferred {
12801    /// Whether `content` is one of the items held back.
12802    fn holds(&self, content: &NativeId) -> bool {
12803        match self {
12804            Self::Nothing => false,
12805            Self::Held { created, .. } => created.iter().any(|held| held.content == *content),
12806        }
12807    }
12808}
12809
12810/// One created item's parts that land once [`TaskSource::finish_writes`] is called.
12811#[derive(Debug)]
12812struct HeldBack {
12813    /// The issue.
12814    content: NativeId,
12815    /// Its board field values other than its `Status`, in the order they are written.
12816    fields: Vec<(String, Value)>,
12817    /// The `Status` field, and the option and its name that the item's status lands on.
12818    // llmlint: ignore[invalid_states_unrepresentable] The same triple `column_for` returns and `Prepared::column` carries, held back unchanged until it is sent.
12819    status: Option<(String, String, String)>,
12820    /// The fields it clears after those are written.
12821    clears: Vec<String>,
12822    /// The reason it is closed for, once its option is selected; `None` leaves it open.
12823    close: Option<ClosedState>,
12824    /// The issues it is blocked by.
12825    edges: Vec<NativeId>,
12826}
12827
12828/// The member [`answer_with_errors`] carries a response's GraphQL errors under, beside its data:
12829/// no GitHub type has a field of this name.
12830const ANSWER_ERRORS: &str = "onetaskgraphErrors";
12831
12832/// A response's `data` with its GraphQL errors beside it under [`ANSWER_ERRORS`], when it has
12833/// both — or `None`, for an answer [`GitHubProjectsSource::answer`] reads as it always has.
12834///
12835/// What [`graphql::CREATE_ISSUES`] is answered with: GitHub runs a document's mutation fields in
12836/// order and answers each one that ran, so a refusal of one alias leaves the issues the others
12837/// created named in the data beside it — the ids a copy needs to take them back.
12838fn answer_with_errors(body: &str) -> Option<Value> {
12839    let parsed: Value = serde_json::from_str(body).ok()?;
12840    let errors = parsed
12841        .get("errors")?
12842        .as_array()
12843        .filter(|errors| !errors.is_empty())?;
12844    let mut data = parsed.get("data").filter(|data| data.is_object())?.clone();
12845    data[ANSWER_ERRORS] = Value::Array(errors.clone());
12846    Some(data)
12847}
12848
12849/// The `createIssue` input that files the new issue on `board` and, when `incoming` names one,
12850/// under its project — see [`GitHubProjectsSource::create_filed_issue`].
12851fn filed_input(
12852    repository: &str,
12853    board: &str,
12854    incoming: &Incoming<'_>,
12855    body: &Option<String>,
12856) -> Value {
12857    let mut input = json!({"repositoryId":repository,"title":incoming.written_title(),
12858                           "body":body,"projectV2Ids":[board]});
12859    if let Some(parent) = incoming.parent {
12860        input["parentIssueId"] = json!(parent.0);
12861    }
12862    input
12863}
12864
12865/// The name of option `option` of field `field` among a board's `fields`.
12866fn option_name<'a>(fields: &'a Value, field: &str, option: &str) -> Option<&'a str> {
12867    fields
12868        .get("nodes")?
12869        .as_array()?
12870        .iter()
12871        .find(|node| node.get("id").and_then(Value::as_str) == Some(field))?
12872        .get("options")?
12873        .as_array()?
12874        .iter()
12875        .find(|candidate| candidate.get("id").and_then(Value::as_str) == Some(option))?
12876        .get("name")?
12877        .as_str()
12878}
12879
12880/// What a write needs to know of one far end it names: which kind of item it is, and whether
12881/// it is an issue a native relationship can name.
12882struct FarEnd {
12883    kind: BoardKind,
12884    content_kind: ContentKind,
12885}
12886
12887/// Whether the issue one write reconciles was created by that write or was already there.
12888#[derive(Clone, Copy, PartialEq, Eq)]
12889enum Issue<'a> {
12890    /// Created by this write, so it holds no relationships yet.
12891    Created,
12892    /// On the board before this write, holding whatever relationships it holds — the far
12893    /// ends of its whole `blockedBy`, when the read that reached it carried them.
12894    Existing(Option<&'a [Value]>),
12895}
12896
12897/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
12898enum Reached {
12899    /// An issue this board holds, resolved into everything this source reports about it.
12900    Held(Box<Resolved>),
12901    /// Nothing this board holds: no such node, or a node on some other board.
12902    Nothing,
12903    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
12904    /// again by [`GitHubProjectsSource::draft_by_id`].
12905    Draft,
12906}
12907
12908/// What GitHub says when a string is not a node id it can resolve.
12909///
12910/// Matched because it is the ordinary answer to a project selector naming a project by its
12911/// *name*, and reporting that as a failure would make naming one impossible. It is read
12912/// off the refusal GitHub sent, never guessed from the shape of the string: this source
12913/// does not define the syntax of a GitHub node id and would be wrong about it.
12914const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
12915
12916/// `error` with `note` added to the end of what it says, its kind and every other member
12917/// unchanged — so a caller still branches on the failure that happened, and reads beside it
12918/// what that failure left behind.
12919fn noting(error: SourceError, note: &str) -> SourceError {
12920    match error {
12921        SourceError::Config { message } => SourceError::Config {
12922            message: message + note,
12923        },
12924        SourceError::Auth { message } => SourceError::Auth {
12925            message: message + note,
12926        },
12927        SourceError::Refused { message } => SourceError::Refused {
12928            message: message + note,
12929        },
12930        SourceError::RateLimited {
12931            retry_after_seconds,
12932            message,
12933        } => SourceError::RateLimited {
12934            retry_after_seconds,
12935            message: Some(message.unwrap_or_default() + note),
12936        },
12937        SourceError::Unavailable { message } => SourceError::Unavailable {
12938            message: message + note,
12939        },
12940        SourceError::Malformed { message } => SourceError::Malformed {
12941            message: message + note,
12942        },
12943    }
12944}
12945
12946/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
12947/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
12948fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest, children: bool) -> Value {
12949    let mut variables = serde_json::Map::new();
12950    for slot in 0..COPY_DETAIL_BATCH {
12951        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12952        variables.insert(format!("id{slot}"), json!(id));
12953    }
12954    variables.insert("project".to_owned(), json!(batch.first().map(|id| &id.0)));
12955    variables.insert("children".to_owned(), json!(children));
12956    variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
12957    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12958    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12959    variables.insert("duplicates".to_owned(), json!(true));
12960    Value::Object(variables)
12961}
12962
12963/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
12964/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
12965/// for them.
12966///
12967/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
12968/// is read again at no added price.
12969fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
12970    let mut variables = serde_json::Map::new();
12971    for slot in 0..DETAIL_BATCH {
12972        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12973        variables.insert(format!("id{slot}"), json!(id));
12974    }
12975    variables.insert(
12976        "first".to_owned(),
12977        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
12978    );
12979    variables.insert("comments".to_owned(), json!(comments.is_some()));
12980    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12981    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12982    variables.insert("duplicates".to_owned(), json!(true));
12983    Value::Object(variables)
12984}
12985
12986/// Whether this refusal is GitHub saying the id names no node at all.
12987fn unresolvable_node(error: &SourceError) -> bool {
12988    matches!(error, SourceError::Refused { message }
12989        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
12990}
12991
12992/// One project name, as a search qualifier which filters on it at the server.
12993///
12994/// Quoted so the whole title is one phrase rather than a bag of words, with the two
12995/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
12996/// the way it documents. A title matched here is still compared for equality afterwards:
12997/// the qualifier narrows what the server sends, and this source decides what it names.
12998fn title_qualifier(name: &str) -> String {
12999    format!("in:title {}", quoted(name))
13000}
13001
13002/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
13003/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
13004/// it documents — so a value holding a qualifier's spelling is searched for rather than
13005/// obeyed.
13006fn quoted(value: &str) -> String {
13007    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
13008    format!("\"{escaped}\"")
13009}
13010
13011/// The search qualifier for the issues updated at or after `since`.
13012///
13013/// Written to the second, rounded down, which can only widen what the search returns.
13014fn updated_qualifier(since: DateTime<Utc>) -> String {
13015    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
13016}
13017
13018/// The search terms that narrow a board-scoped issue search to a task query's text and
13019/// metadata predicates, or `None` when it carries neither.
13020///
13021/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
13022/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
13023/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
13024/// matches each in any field the `in:` qualifier names, so a query naming a title search and
13025/// a metadata value searches both fields for both — wider than asked, never narrower, and
13026/// every candidate is confirmed in process afterwards.
13027///
13028/// **This narrows a text search, and that is this source's declared semantics.** GitHub
13029/// matches whole tokens where a substring rule would match inside a word, so an item holding
13030/// the text only inside a longer word is not returned. A text of nothing but whitespace
13031/// matches every item, so it narrows nothing and is not sent.
13032fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
13033    let text = query
13034        .text
13035        .as_ref()
13036        .filter(|text| !text.terms.trim().is_empty());
13037    if text.is_none() && query.metadata.is_empty() {
13038        return None;
13039    }
13040    let (title, body) = match text.map(|text| text.fields) {
13041        None => (false, true),
13042        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
13043        Some(TextFields::Content) => (false, true),
13044        Some(TextFields::TitleOrContent) => (true, true),
13045    };
13046    let fields = match (title, body) {
13047        (true, true) => "in:title,body",
13048        (true, false) => "in:title",
13049        _ => "in:body",
13050    };
13051    let phrases = text
13052        .map(|text| text.terms.clone())
13053        .into_iter()
13054        .chain(
13055            query
13056                .metadata
13057                .iter()
13058                .map(|wanted| as_stored(wanted.value())),
13059        )
13060        .map(|phrase| quoted(&phrase))
13061        .collect::<Vec<_>>();
13062    Some(format!("{fields} {}", phrases.join(" ")))
13063}
13064
13065/// Whether every status category `query` keeps reads only off an open issue or a draft: a
13066/// closed issue reads `done`, `cancelled` or — closed for a reason that implies no category —
13067/// `unknown`, so a status filter naming none of those three can ask every issue search it sends
13068/// for open issues alone; each candidate's category is still confirmed from its own item.
13069fn open_only(query: &TaskQuery) -> bool {
13070    !query.statuses.is_empty()
13071        && query.statuses.iter().all(|category| {
13072            !matches!(
13073                category,
13074                StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
13075            )
13076        })
13077}
13078
13079/// What a task query is narrowed by beside a board-scoped issue search's scope — its comment
13080/// activity and its text and metadata, with `is:open` when [`open_only`] — or `None` for a query
13081/// carrying none of those, which no narrowed search answers.
13082///
13083/// A narrowed search never answers a draft, which is no issue, so narrowing it to open issues
13084/// too leaves out nothing such a search could have answered.
13085fn search_qualifiers(query: &TaskQuery) -> Option<String> {
13086    let mut qualifiers: Vec<String> = [
13087        query.commented_since.map(updated_qualifier),
13088        narrowing_qualifiers(query),
13089    ]
13090    .into_iter()
13091    .flatten()
13092    .collect();
13093    if qualifiers.is_empty() {
13094        return None;
13095    }
13096    if open_only(query) {
13097        qualifiers.push(OPEN_QUALIFIER.to_owned());
13098    }
13099    Some(qualifiers.join(" "))
13100}
13101
13102/// The search terms that narrow a board-scoped issue search to a project or document query's
13103/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
13104/// carrying that text alone is sent as by [`narrowing_qualifiers`].
13105fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
13106    narrowing_qualifiers(&TaskQuery {
13107        text: text.cloned(),
13108        ..TaskQuery::default()
13109    })
13110}
13111
13112/// Refuses a project or document query's text GitHub's issue search cannot find, before
13113/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
13114/// query's.
13115fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
13116    refuse_unsearchable(&TaskQuery {
13117        text: text.cloned(),
13118        ..TaskQuery::default()
13119    })
13120}
13121
13122/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
13123/// before anything is asked of GitHub.
13124///
13125/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
13126/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
13127/// left out, the search is every issue of the board. So this source says it cannot answer
13128/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
13129/// nothing GitHub could search for, and keeps the board read it always had.
13130fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
13131    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
13132                       letter or digit with a bounded query";
13133    if let Some(text) = &query.text
13134        && !text.terms.trim().is_empty()
13135        && !has_words(&text.terms)
13136    {
13137        return Err(SourceError::Refused {
13138            message: format!(
13139                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
13140                text.terms
13141            ),
13142        });
13143    }
13144    if let Some(wanted) = query
13145        .metadata
13146        .iter()
13147        .find(|wanted| !has_words(wanted.value()))
13148    {
13149        return Err(SourceError::Refused {
13150            message: format!(
13151                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
13152                wanted.value(),
13153                std::iter::once(wanted.key())
13154                    .chain(wanted.path().iter().map(String::as_str))
13155                    .collect::<Vec<_>>()
13156                    .join("/"),
13157            ),
13158        });
13159    }
13160    Ok(())
13161}
13162
13163/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
13164fn has_words(phrase: &str) -> bool {
13165    phrase.chars().any(char::is_alphanumeric)
13166}
13167
13168/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
13169///
13170/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
13171/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
13172/// which GitHub's word match would read as different words.
13173fn as_stored(value: &str) -> String {
13174    let encoded = Value::String(value.to_owned()).to_string();
13175    encoded[1..encoded.len() - 1].to_owned()
13176}
13177
13178/// The one narrower question a task query carrying a text, metadata or origin predicate is
13179/// sent as.
13180enum Narrowing {
13181    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
13182    Origin(String),
13183    /// The board-scoped issue search narrowed by these qualifiers.
13184    Search(String),
13185}
13186
13187impl Narrowing {
13188    /// What this question is remembered under for the length of one command.
13189    fn key(&self) -> String {
13190        match self {
13191            Self::Origin(origin) => format!("origin {origin}"),
13192            Self::Search(also) => format!("search {also}"),
13193        }
13194    }
13195}
13196
13197/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
13198enum Resumed {
13199    /// It reported another page, which starts after this cursor.
13200    More(String),
13201    /// It has ended. Sending this cursor again — the page's own end when it had one, and
13202    /// otherwise the cursor it was reached from — answers an empty page, so the one document
13203    /// can go on walking the other connection.
13204    Ended(Option<String>),
13205}
13206
13207impl Resumed {
13208    /// Whether the connection has another page.
13209    const fn has_more(&self) -> bool {
13210        matches!(self, Self::More(_))
13211    }
13212
13213    /// The cursor to send this connection next.
13214    fn cursor(self) -> Option<String> {
13215        match self {
13216            Self::More(next) => Some(next),
13217            Self::Ended(last) => last,
13218        }
13219    }
13220}
13221
13222/// Where `connection`, reached from `after`, resumes — refused when it reports another page
13223/// with no cursor to it, or from a cursor that does not advance.
13224fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
13225    let info = connection
13226        .get("pageInfo")
13227        .ok_or_else(|| SourceError::Malformed {
13228            message: "GitHub connection has no pageInfo".into(),
13229        })?;
13230    let end = optional_str(info, "endCursor")?;
13231    if required_bool(info, "hasNextPage")? {
13232        let next = end.ok_or_else(|| SourceError::Malformed {
13233            message: "GitHub connection reports another page and no endCursor".into(),
13234        })?;
13235        validate_cursor_progress(after, next)?;
13236        return Ok(Resumed::More(next.to_owned()));
13237    }
13238    Ok(Resumed::Ended(
13239        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
13240    ))
13241}
13242
13243/// The board, and every item on it this source reports.
13244#[derive(Clone)]
13245struct Board {
13246    id: String,
13247    fields: Value,
13248    items: Vec<Resolved>,
13249}
13250
13251/// What a write needs of the board and nothing more: its node id and its field
13252/// definitions, in the shape a read of the board's own `fields` gives them.
13253///
13254/// Deliberately no items. A write decides which item it writes, which parent it files
13255/// under and which far ends it names by reading each of them by its own id; this is the
13256/// half of the board those reads cannot carry, and holding no item is what keeps it from
13257/// ever being asked whether an item is there.
13258#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13259struct BoardFields {
13260    id: BoardId,
13261    fields: Value,
13262}
13263
13264/// A board's node id: what a field write and `addProjectV2ItemById` address.
13265///
13266/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
13267/// refused where it is read, and one an item names blank is read as not named at all.
13268#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
13269#[serde(try_from = "String")]
13270struct BoardId(String);
13271
13272impl TryFrom<String> for BoardId {
13273    type Error = String;
13274
13275    /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
13276    fn try_from(id: String) -> Result<Self, Self::Error> {
13277        Self::parse(&id).map_err(|error| error.to_string())
13278    }
13279}
13280
13281/// Where one write left its item, for the record the rest of the command reads it out of.
13282///
13283/// A named record rather than a tuple because the update arm and the create arm each fill
13284/// all four, and two `Option`s of different meaning side by side in a tuple are two
13285/// positions a reader has to count.
13286struct Landed {
13287    /// The issue's own node id, which is the [`NativeId`] this source reports.
13288    content_id: NativeId,
13289    /// The board item's id, which is what a field write addresses — `None` for an issue its
13290    /// create filed on the board itself, whose board item GitHub adds after answering and
13291    /// [`TaskSource::finish_writes`] reads.
13292    // 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.
13293    item_id: Option<String>,
13294    /// The web address GitHub gave the issue, when it gave one.
13295    // 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.
13296    url: Option<String>,
13297    /// The issue's number on its repository, when GitHub reported one.
13298    number: Option<u64>,
13299}
13300
13301impl BoardId {
13302    fn parse(id: &str) -> Result<Self, SourceError> {
13303        if id.trim().is_empty() {
13304            return Err(SourceError::Malformed {
13305                message: "GitHub named a board with a blank node id".into(),
13306            });
13307        }
13308        Ok(Self(id.to_owned()))
13309    }
13310
13311    fn as_str(&self) -> &str {
13312        &self.0
13313    }
13314}
13315
13316impl Board {
13317    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
13318        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
13319        let nodes = fields
13320            .get("nodes")
13321            .and_then(Value::as_array)
13322            .ok_or_else(|| SourceError::Malformed {
13323                message: "GitHub project fields.nodes is not an array".into(),
13324            })?;
13325        Ok(nodes
13326            .iter()
13327            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
13328    }
13329}
13330
13331/// One board item, resolved into everything this source reports about it.
13332///
13333/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
13334/// [`TaskSource::prime_write`] takes back — see [`expected`].
13335#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13336struct Resolved {
13337    item_id: String,
13338    id: NativeId,
13339    content_kind: ContentKind,
13340    kind: BoardKind,
13341    title: String,
13342    body: Option<String>,
13343    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
13344    /// that changes the slot alone has to keep byte for byte outside it.
13345    raw_body: Option<String>,
13346    status: Status,
13347    /// The name of the board `Status` option this item sits in, as the board spells it.
13348    option: Option<String>,
13349    /// What its `Priority` field says, read through this instance's mapping.
13350    priority: HeldPriority,
13351    /// Whether this item's issue is closed. A draft has no such state and is never closed.
13352    closed: bool,
13353    /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
13354    /// issue, a draft, and an issue closed before GitHub recorded reasons.
13355    #[serde(default)]
13356    reason: Option<String>,
13357    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
13358    delivers: Vec<TaskRef>,
13359    /// Every task that delivers this one, read out of its slot. Empty for anything not a
13360    /// task.
13361    delivered_by: Vec<TaskRef>,
13362    labels: Vec<Label>,
13363    parent: Option<NativeId>,
13364    /// The issue's GitHub parent on a source reading sub-issues as children (`sub_issues:
13365    /// parent`), where [`Self::parent`] — the project it is filed under — is always `None`.
13366    #[serde(default)]
13367    issue_parent: Option<NativeId>,
13368    // 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.
13369    origin: Option<String>,
13370    /// What each board text field this instance projects metadata onto holds for this item,
13371    /// by field name — only the fields holding a value, an empty text included. What a write compares the
13372    /// item's metadata against, so a field already holding its value is not written again;
13373    /// never read back as metadata, which the slot alone reports.
13374    projected: BTreeMap<String, String>,
13375    /// The issue's own number on its repository, as GitHub reports it.
13376    ///
13377    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
13378    /// declares none, and a draft is not filed in a repository to be numbered by one — and
13379    /// an issue this run created whose creating mutation answered without one, which is a
13380    /// response GitHub's own schema says cannot happen and which a landed write is not
13381    /// worth failing over. An `Issue` read off the board always has one.
13382    number: Option<u64>,
13383    url: Option<String>,
13384    created_at: Option<DateTime<Utc>>,
13385    updated_at: Option<DateTime<Utc>>,
13386    /// When the board item itself last changed — what a board field write moves — when the
13387    /// read that reached it said.
13388    item_updated_at: Option<DateTime<Utc>>,
13389    /// How many comments the issue holds, when the read that reached it said.
13390    comment_count: Option<u64>,
13391    /// How many relationship events the issue's timeline holds — every dependency, as
13392    /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
13393    /// removed — when the read that reached it said.
13394    relationship_events: Option<u64>,
13395    /// The server's clock on the response that read it — its `Date` header — when the
13396    /// response carried one.
13397    served_at: Option<DateTime<Utc>>,
13398    /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
13399    /// its labels — when the read that reached it carried all three whole.
13400    #[serde(default)]
13401    facets: Option<change_token::FacetsDigest>,
13402    own_repository: Option<Repository>,
13403    repositories: Vec<Repository>,
13404    classification: Classification,
13405    slot: BTreeMap<String, Value>,
13406    /// The node id of the board this item sits on, when the read that reached it said.
13407    board_id: Option<String>,
13408    /// The definition of every board field this item holds a value of, in the shape a read
13409    /// of the board's own `fields` gives one.
13410    ///
13411    /// Only the fields this item has a value in: a field it holds nothing of is not here,
13412    /// which says nothing about whether the board has it.
13413    fields: Vec<Value>,
13414    /// Every field the board this item sits on defines, as its own read of the board's
13415    /// `fields` gives them — when the read that reached the item carried them, which a read
13416    /// of it by its own id does. What a write of it needs of the board, then, needs no read
13417    /// of the board.
13418    board_fields: Option<Value>,
13419    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
13420    /// selects one — when the read that reached it carried the connection to its end, which a
13421    /// read of it by its own id does for any issue blocked by no more than a page. What a
13422    /// write reconciles that relationship against, and what a read of its forward edges in
13423    /// the same command answers with.
13424    blocked_by: Option<Vec<Value>>,
13425}
13426
13427impl Resolved {
13428    /// The board this item's own read names it on, when that read named one this source can
13429    /// address.
13430    fn named_board(&self) -> Option<BoardId> {
13431        self.board_id
13432            .as_deref()
13433            .and_then(|id| BoardId::parse(id).ok())
13434    }
13435
13436    /// The board's id and every field it defines, when the read that reached this item
13437    /// carried both — which a read of it by its own id does.
13438    fn carried_board(&self) -> Option<BoardFields> {
13439        Some(BoardFields {
13440            id: self.named_board()?,
13441            fields: self.board_fields.clone()?,
13442        })
13443    }
13444
13445    /// Whether this item holds a value of the board field called `name`, and so carries
13446    /// that field's definition. `false` says nothing about whether the board has the field.
13447    fn defines(&self, name: &str) -> bool {
13448        self.fields
13449            .iter()
13450            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
13451    }
13452
13453    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
13454    /// in a field of its own, and none of the five keys that are only an encoding.
13455    ///
13456    /// The two delivery keys are left out for every kind, not only for a task: they are
13457    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
13458    /// document carrying one holds nothing a caller's own metadata could mean by it.
13459    fn metadata(&self) -> BTreeMap<String, Value> {
13460        let mut metadata = self.slot.clone();
13461        metadata.remove(Repository::METADATA_KEY);
13462        metadata.remove(DependencyEdge::RECORDED_KEY);
13463        metadata.remove(ItemKind::METADATA_KEY);
13464        metadata.remove(TaskRef::DELIVERS_KEY);
13465        metadata.remove(TaskRef::DELIVERED_BY_KEY);
13466        metadata.remove(Classification::METADATA_KEY);
13467        // The board field is the origin, and the body's copy of it is only a mirror for the
13468        // issue search to find: an item whose field holds none has none, whatever its body
13469        // says, so no reader ever sees two answers.
13470        metadata.remove(ORIGIN_KEY);
13471        if let Some(origin) = &self.origin {
13472            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
13473        }
13474        metadata
13475    }
13476
13477    /// Where this item is, as a link a reader can open.
13478    ///
13479    /// A board is a hosted place and every issue on it has a web address, so that address
13480    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
13481    /// of place it is, so a reader knows to open it rather than to read a file out. It
13482    /// does not replace or derive from `url`: the field goes on reporting exactly what it
13483    /// reported before, and this says what that address *is*.
13484    ///
13485    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
13486    /// rather than a third variant, which is the contract's "the source did not say". An
13487    /// issue this run created is not one of those: its address comes back from the
13488    /// creating mutation, so it is somewhere a reader can open from the moment it exists
13489    /// rather than from whenever the board read catches up.
13490    fn location(&self) -> Option<Location> {
13491        self.url.clone().map(Location::Url)
13492    }
13493
13494    /// The short handle this board's backend shows people for a task: the issue's number
13495    /// alone, as a decimal string.
13496    ///
13497    /// The number alone rather than `owner/repo#1043`, because that is the contract's
13498    /// value for this backend. A draft has no number and so no handle, which is the
13499    /// contract's *absent* rather than a handle of some other shape — and the native
13500    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
13501    /// derives from.
13502    fn key(&self) -> Option<String> {
13503        self.number.map(|number| number.to_string())
13504    }
13505
13506    /// This item's change token — see [`change_token`] — or `None` when the read that
13507    /// reached it did not carry all six of its parts, which a draft never does, did not say
13508    /// when GitHub served it, or found it changed too recently to have settled.
13509    fn version(&self) -> Option<String> {
13510        let parts = self.token_parts()?;
13511        change_token::settled(parts.issue, parts.item, self.served_at?)
13512            .then(|| change_token::spell(&parts))
13513    }
13514
13515    /// The change token this item, read just after a write of it, holds now — whether or not it
13516    /// has settled: what a written task's `version` answers (see
13517    /// [`GitHubProjectsSource::written_tokens`]), spelled from the parts a later read spells its
13518    /// token from.
13519    fn written_version(&self) -> Option<String> {
13520        self.token_parts().map(|parts| change_token::spell(&parts))
13521    }
13522
13523    /// Every part of this item's change token, or `None` when the read that reached it did not
13524    /// carry them all — a draft never does.
13525    fn token_parts(&self) -> Option<change_token::Parts> {
13526        match self.content_kind {
13527            ContentKind::Issue => Some(change_token::Parts {
13528                issue: self.updated_at?,
13529                item: self.item_updated_at?,
13530                comments: self.comment_count?,
13531                relationships: self.relationship_events?,
13532                facets: self.facets.clone()?,
13533                repository: self.own_repository.clone()?,
13534            }),
13535            ContentKind::DraftIssue => None,
13536        }
13537    }
13538
13539    /// Whether its `Priority` field holds a value at all, mapped or not.
13540    fn holds_priority(&self) -> bool {
13541        self.priority != HeldPriority::Read(Priority::None)
13542    }
13543
13544    /// The task this item is.
13545    ///
13546    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
13547    /// reading that as a level would be a guess, and reading it as `none` would let the next
13548    /// copy clear a priority a person set.
13549    fn task(&self) -> Result<Task, SourceError> {
13550        let priority = match &self.priority {
13551            HeldPriority::Read(priority) => *priority,
13552            HeldPriority::Unmapped(option) => {
13553                return Err(SourceError::Malformed {
13554                    message: format!(
13555                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
13556                         this source's priority_mapping does not name, so its priority cannot be \
13557                         read; next: name {option:?} under priority_mapping, or move the item to \
13558                         a mapped option",
13559                        self.id,
13560                        self.number
13561                            .map(|number| format!(" (#{number})"))
13562                            .unwrap_or_default()
13563                    ),
13564                });
13565            }
13566        };
13567        let metadata = self.metadata();
13568        Ok(Task {
13569            version: self.version(),
13570            id: self.id.clone(),
13571            key: self.key(),
13572            title: self.title.clone(),
13573            // The `## Links` section the engine rendered is stripped, so content round-trips.
13574            content: onetaskgraph_plugin_api::read_content(self.body.clone(), &metadata),
13575            status: self.status.clone(),
13576            priority,
13577            labels: self.labels.clone(),
13578            project: self.parent.clone(),
13579            url: self.url.clone(),
13580            location: self.location(),
13581            created_at: self.created_at,
13582            updated_at: self.updated_at,
13583            metadata,
13584            repositories: self.repositories.clone(),
13585            delivers: self.delivers.clone(),
13586            delivered_by: self.delivered_by.clone(),
13587            classification: self.classification,
13588        })
13589    }
13590
13591    fn project(&self) -> Project {
13592        Project {
13593            version: self.version(),
13594            id: self.id.clone(),
13595            title: self.title.clone(),
13596            content: self.body.clone(),
13597            status: self.status.clone(),
13598            labels: self.labels.clone(),
13599            url: self.url.clone(),
13600            location: self.location(),
13601            created_at: self.created_at,
13602            updated_at: self.updated_at,
13603            metadata: self.metadata(),
13604            repositories: self.repositories.clone(),
13605            classification: self.classification,
13606        }
13607    }
13608
13609    /// The same issue as a document: the project it is filed under, and no status and no
13610    /// dependencies, because a document is not work.
13611    fn document(&self) -> Document {
13612        Document {
13613            version: self.version(),
13614            id: self.id.clone(),
13615            title: self.title.clone(),
13616            content: self.body.clone(),
13617            project: self.parent.clone(),
13618            labels: self.labels.clone(),
13619            url: self.url.clone(),
13620            location: self.location(),
13621            created_at: self.created_at,
13622            updated_at: self.updated_at,
13623            metadata: self.metadata(),
13624            repositories: self.repositories.clone(),
13625            classification: self.classification,
13626        }
13627    }
13628}
13629
13630/// Where one targeted update moves an item's status, and which of its two halves move.
13631struct StatusMove {
13632    /// The board the item's `Status` field is on.
13633    board: BoardId,
13634    /// The `Status` field's id.
13635    field: String,
13636    /// The option's id.
13637    option: String,
13638    /// The option's name, as the board spells it.
13639    name: String,
13640    /// What the status asks of the issue's state.
13641    target: StatusTarget,
13642    /// The status the item reads as once it is there.
13643    landed: Status,
13644    /// Which of the status's two halves differ from what the item holds.
13645    moves: Moves,
13646}
13647
13648/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
13649/// closed state of its issue, or both. A status neither half of which differs is no move at all,
13650/// and is not a value of this type.
13651#[derive(Clone, Copy, PartialEq, Eq)]
13652enum Moves {
13653    /// The option alone.
13654    Option,
13655    /// The issue's state alone: open, closed, or closed with another reason.
13656    State,
13657    /// Both.
13658    Both,
13659}
13660
13661impl Moves {
13662    /// What differs, or `None` when nothing does.
13663    const fn of(option: bool, state: bool) -> Option<Self> {
13664        match (option, state) {
13665            (true, true) => Some(Self::Both),
13666            (true, false) => Some(Self::Option),
13667            (false, true) => Some(Self::State),
13668            (false, false) => None,
13669        }
13670    }
13671
13672    /// Whether the option moves.
13673    const fn option(self) -> bool {
13674        matches!(self, Self::Option | Self::Both)
13675    }
13676
13677    /// Whether the issue's state moves.
13678    const fn state(self) -> bool {
13679        matches!(self, Self::State | Self::Both)
13680    }
13681}
13682
13683/// What one write is, and the status that comes with being it.
13684///
13685/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
13686/// status and a task or a project always has one, so "a document carrying a status" and
13687/// "a task carrying none" are states a write cannot be in rather than states every use
13688/// site below has to defend against.
13689enum Written<'a> {
13690    /// A document, which is not work and so has no status at all.
13691    Document,
13692    /// A task or a project, and the status it is being written with.
13693    Work(ItemKind, &'a Status),
13694}
13695
13696impl Written<'_> {
13697    /// Which of the board's three kinds this write is.
13698    const fn kind(&self) -> BoardKind {
13699        match self {
13700            Self::Document => BoardKind::Document,
13701            Self::Work(kind, _) => BoardKind::Work(*kind),
13702        }
13703    }
13704
13705    /// The status this write carries. A document carries none, so a write of one says
13706    /// nothing about the issue's open or closed state and selects no board `Status`
13707    /// option.
13708    const fn status(&self) -> Option<&Status> {
13709        match self {
13710            Self::Document => None,
13711            Self::Work(_, status) => Some(status),
13712        }
13713    }
13714
13715    /// The status this write carries with the kind whose half of `status_mapping` it is
13716    /// written through.
13717    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
13718        match self {
13719            Self::Document => None,
13720            Self::Work(kind, status) => Some((*kind, status)),
13721        }
13722    }
13723}
13724
13725/// The item being written, in the one shape all three write methods reach.
13726struct Incoming<'a> {
13727    written: Written<'a>,
13728    /// The title a person wrote. A document's goes onto the issue with
13729    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
13730    title: &'a str,
13731    content: Option<&'a str>,
13732    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
13733    labels: &'a [Label],
13734    metadata: &'a BTreeMap<String, Value>,
13735    repositories: &'a [Repository],
13736    /// Recorded in the slot while private, and nowhere while public.
13737    classification: Classification,
13738    parent: Option<&'a NativeId>,
13739    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
13740    /// what keeps either key out of their slot.
13741    delivers: &'a [TaskRef],
13742    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
13743    delivered_by: &'a [TaskRef],
13744    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
13745    /// project, a document, and every write to an instance with no `priority_mapping` —
13746    /// which is what keeps such a write's requests exactly what they were before.
13747    priority: Option<Priority>,
13748}
13749
13750/// What one write does to one projected metadata text field, by the field's id.
13751#[derive(Debug, Clone)]
13752enum ProjectionWrite {
13753    /// Write this text.
13754    Set {
13755        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.
13756        text: String,
13757    },
13758    /// Clear the field's value.
13759    Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
13760}
13761
13762impl ProjectionWrite {
13763    /// Add this write to a field write's list of values and list of clears.
13764    fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
13765        match self {
13766            Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
13767            Self::Clear { field } => clears.push(field),
13768        }
13769    }
13770}
13771
13772/// What one write does to an item's `Priority` field.
13773enum PriorityWrite {
13774    /// Select this option of this field.
13775    Select {
13776        /// The `Priority` field's id.
13777        field: String,
13778        /// The mapped option's id.
13779        option: String,
13780    },
13781    /// Clear the field's value, which is what `none` is.
13782    Clear {
13783        /// The `Priority` field's id.
13784        field: String,
13785    },
13786}
13787
13788impl Incoming<'_> {
13789    /// The title this write puts on the issue.
13790    fn written_title(&self) -> String {
13791        match self.written {
13792            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
13793            Written::Work(..) => self.title.to_owned(),
13794        }
13795    }
13796}
13797
13798#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13799enum ContentKind {
13800    DraftIssue,
13801    Issue,
13802}
13803
13804/// What one board issue is: a document, or the work an [`ItemKind`] names.
13805///
13806/// A type of this source's own rather than an `ItemKind` with a third variant, because
13807/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
13808/// document — the contract keeps a document out of that enum deliberately. Holding the
13809/// board's three answers in one value is what makes every place that asks "which is this?"
13810/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
13811/// two thirds of the board.
13812#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13813enum BoardKind {
13814    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
13815    Document,
13816    /// Every other issue, and every draft.
13817    Work(ItemKind),
13818}
13819
13820impl BoardKind {
13821    /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
13822    /// sub-issues and carrying the kind marker `marked` in its slot.
13823    ///
13824    /// The design prefix is read *first*, before either of the two rules that separate a
13825    /// project from a task. A document is not work whatever sub-issues it has and whatever
13826    /// marker it carries, and reading the prefix later would make a design issue with none of
13827    /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
13828    /// overrides it: an issue filed under a project is that project's task even when it has
13829    /// sub-issues of its own.
13830    fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
13831        if title.starts_with(DESIGN_TITLE_PREFIX) {
13832            Self::Document
13833        } else if filed {
13834            Self::Work(ItemKind::Task)
13835        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
13836            Self::Work(ItemKind::Project)
13837        } else {
13838            Self::Work(ItemKind::Task)
13839        }
13840    }
13841
13842    /// What the change token's facets call this kind.
13843    const fn facet(self) -> change_token::FacetKind {
13844        match self {
13845            Self::Document => change_token::FacetKind::Document,
13846            Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
13847            Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
13848        }
13849    }
13850
13851    /// Whose half of `status_mapping` an item of this kind reads its status through. A
13852    /// document has no status of its own, so the task half stands in for whatever the issue
13853    /// holds; nothing reports it.
13854    const fn status_kind(self) -> ItemKind {
13855        match self {
13856            Self::Document => ItemKind::Task,
13857            Self::Work(kind) => kind,
13858        }
13859    }
13860
13861    /// How a refusal names this kind to the person reading it.
13862    const fn describes(self) -> &'static str {
13863        match self {
13864            Self::Document => "document",
13865            Self::Work(kind) => kind.marker(),
13866        }
13867    }
13868}
13869
13870/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
13871///
13872/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
13873/// the shared cross-source journeys assert one answer to one question, so two sources
13874/// that disagree about what "carries the label bug" means fail them.
13875fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
13876    let holds = |name: &String| {
13877        labels
13878            .iter()
13879            .any(|label| label.name.eq_ignore_ascii_case(name))
13880    };
13881    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
13882        && filter.all_of.iter().all(holds)
13883        && !filter.none_of.iter().any(holds)
13884}
13885
13886/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
13887/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
13888fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
13889    statuses.is_empty() || statuses.contains(&category)
13890}
13891
13892/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
13893///
13894/// `content` is the item's own prose — the body with this source's trailing metadata
13895/// comment already taken off — so a search never matches an encoding the author of the
13896/// issue never wrote.
13897fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
13898    let terms = query.terms.to_lowercase();
13899    let in_title = title.to_lowercase().contains(&terms);
13900    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
13901    match query.fields {
13902        TextFields::Title => in_title,
13903        TextFields::Content => in_content,
13904        TextFields::TitleOrContent => in_title || in_content,
13905    }
13906}
13907
13908/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
13909///
13910/// The project predicate is passed separately because a read narrowed to one project has
13911/// already answered it by asking *that project* for its own items — and re-applying it
13912/// there would compare the caller's selector, which may be a project's **name**, against
13913/// the id of the project that name resolved to, and keep nothing. Every other read passes
13914/// `query.project` and applies it here, which is what keeps `projects` a predicate this
13915/// source really does apply.
13916fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
13917    labels_match(&task.labels, &query.labels)
13918        && status_matches(task.status.category, &query.statuses)
13919        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
13920        && match project {
13921            ProjectFilter::Any => true,
13922            ProjectFilter::Orphans => task.project.is_none(),
13923            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
13924        }
13925        && query
13926            .text
13927            .as_ref()
13928            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
13929        // Against the parsed metadata slot, and against the origin field, which is where
13930        // `Resolved::metadata` reads each of them from.
13931        && query.metadata_matches(&task.metadata)
13932        && query.origin_matches(&task.metadata)
13933}
13934
13935fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
13936    labels_match(&project.labels, &query.labels)
13937        && status_matches(project.status.category, &query.statuses)
13938        && query
13939            .text
13940            .as_ref()
13941            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
13942}
13943
13944/// The same three predicates a task query carries, minus the status filter.
13945///
13946/// A document is not work, so it has no status for one to compare against and the query
13947/// type carries none. The project predicate is the same one — a design issue filed under a
13948/// project issue is in that project, and one filed under nothing is in none — so it is
13949/// spelled the same way here rather than answered differently.
13950fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
13951    labels_match(&document.labels, &query.labels)
13952        && match project {
13953            ProjectFilter::Any => true,
13954            ProjectFilter::Orphans => document.project.is_none(),
13955            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
13956        }
13957        && query
13958            .text
13959            .as_ref()
13960            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
13961}
13962
13963/// Why a GitHub item's change token does not vouch for its forward edges.
13964///
13965/// A forward edge names its far end and that far end's kind, and the near item's token moves
13966/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
13967/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
13968/// nothing the near token reads, so an edge kept at the near version could name a far end that
13969/// is no longer what it said.
13970const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
13971     whether it became a document, or its deletion, so its forward edges are always read from \
13972     github";
13973
13974/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
13975/// categories an open issue reads as narrows a board-scoped search with.
13976const OPEN_QUALIFIER: &str = "is:open";
13977
13978/// The board filter `task status reconcile` reads the board through: closed items alone.
13979const CLOSED_FILTER: &str = "is:closed";
13980
13981/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
13982/// one reference, and a slot the batch has none for names its last one again.
13983fn reference_batch(batch: &[IssueRef]) -> Value {
13984    let mut variables = serde_json::Map::new();
13985    for slot in 0..REFERENCE_BATCH {
13986        let issue = batch.get(slot).or(batch.last());
13987        variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
13988        variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
13989        variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
13990    }
13991    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13992    variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
13993    variables.insert("duplicates".to_owned(), json!(true));
13994    Value::Object(variables)
13995}
13996
13997/// One issue an issue read by reference answered, in the shape every other read of an issue
13998/// hands its resolver: each board membership's `Status` value, read by name, as the one field
13999/// value that membership carries.
14000///
14001/// A membership's `status` is `null` when its card sits at no option — or on a board with no
14002/// field of that name — and otherwise an option value naming its option; anything else is not
14003/// an answer to this read, and is refused rather than read as a card at no option.
14004fn held_issue(issue: &Value) -> Result<Value, SourceError> {
14005    let malformed = |what: &str| SourceError::Malformed {
14006        message: format!("GitHub answered an issue read by reference with {what}"),
14007    };
14008    let mut issue = issue.clone();
14009    let nodes = issue
14010        .pointer_mut("/projectItems/nodes")
14011        .and_then(Value::as_array_mut)
14012        .ok_or_else(|| malformed("no list of board memberships"))?;
14013    for node in nodes {
14014        let node = node
14015            .as_object_mut()
14016            .ok_or_else(|| malformed("a board membership that is not an object"))?;
14017        let status = match node.get("status") {
14018            None => return Err(malformed("a board membership with no status member")),
14019            Some(Value::Null) => None,
14020            Some(status) if status.get("name").is_some_and(Value::is_string) => {
14021                Some(status.clone())
14022            }
14023            // A single-select value of another field type answers `{}`; only an option value
14024            // carries a name.
14025            Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
14026            Some(_) => return Err(malformed("a membership status that names no option")),
14027        };
14028        node.insert(
14029            "fieldValues".to_owned(),
14030            json!({"nodes": status.into_iter().collect::<Vec<_>>(),
14031                   "pageInfo": {"hasNextPage": false}}),
14032        );
14033    }
14034    Ok(issue)
14035}
14036
14037/// The issue one slot of a read by reference answered, `None` when GitHub answered that
14038/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
14039/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
14040/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
14041/// than a reference nothing holds.
14042fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
14043    let alias = format!("r{slot}");
14044    let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
14045        message: format!("GitHub answered a read by reference with no {alias}"),
14046    })?;
14047    if repository.is_null() {
14048        return Ok(None);
14049    }
14050    let issue = repository
14051        .get("issue")
14052        .ok_or_else(|| SourceError::Malformed {
14053            message: format!("GitHub answered {alias} of a read by reference with no issue"),
14054        })?;
14055    if issue.is_null() {
14056        return Ok(None);
14057    }
14058    // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
14059    if optional_str(issue, "__typename")? != Some("Issue") {
14060        return Err(SourceError::Malformed {
14061            message: format!(
14062                "GitHub answered {alias} of a read by reference with an issue whose __typename \
14063                 is not Issue"
14064            ),
14065        });
14066    }
14067    Ok(Some(issue))
14068}
14069
14070/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
14071fn spoken_reason(reason: &str) -> String {
14072    reason.to_ascii_lowercase().replace('_', " ")
14073}
14074
14075impl GitHubProjectsSource {
14076    /// Every task and project of this board whose card contradicts its issue's closure, each
14077    /// judged against its own kind's half of `status_mapping`, read through the board's own
14078    /// `is:closed` filter, page by page — never the board's unfiltered `items`.
14079    ///
14080    /// Each item the filter answers is confirmed from its own node: an open one, one whose
14081    /// closure reads `unknown`, and one whose card is already in line are left out whatever
14082    /// the filter let through. The board's fields ride along and are held for the option
14083    /// writes that follow, so those need no read of the board.
14084    async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
14085        let mut after: Option<String> = None;
14086        let mut found = Vec::new();
14087        loop {
14088            let data = self
14089                .graphql(
14090                    graphql::CLOSED_ITEMS,
14091                    json!({"owner":self.owner,"number":self.project_number,
14092                           "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
14093                           "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
14094                )
14095                .await?;
14096            let board = data
14097                .pointer("/closedItems/projectV2")
14098                .filter(|value| !value.is_null())
14099                .ok_or_else(|| SourceError::Refused {
14100                    message: format!(
14101                        "GitHub project {}/{} was not found or is not visible to the token",
14102                        self.owner, self.project_number
14103                    ),
14104                })?;
14105            *self.fields_cache()? = Some(BoardFields {
14106                id: BoardId::parse(required_str(board, "id")?)?,
14107                fields: board.get("fields").cloned().unwrap_or(Value::Null),
14108            });
14109            let items = board.get("items").ok_or_else(|| SourceError::Malformed {
14110                message: "GitHub project closed items are missing".into(),
14111            })?;
14112            for item in items
14113                .get("nodes")
14114                .and_then(Value::as_array)
14115                .ok_or_else(|| SourceError::Malformed {
14116                    message: "GitHub project items.nodes is not an array".into(),
14117                })?
14118            {
14119                let Some(resolved) = self.resolve(item)? else {
14120                    continue;
14121                };
14122                let BoardKind::Work(kind) = resolved.kind else {
14123                    continue;
14124                };
14125                let Some((closed_as, name)) = self.statuses.contradicted(
14126                    kind,
14127                    resolved.option.as_deref(),
14128                    resolved.closed,
14129                    resolved.reason.as_deref(),
14130                ) else {
14131                    continue;
14132                };
14133                found.push(StatusContradiction {
14134                    closed_as,
14135                    to: name.to_owned(),
14136                    option: resolved.option.clone(),
14137                    item: match kind {
14138                        ItemKind::Task => ContradictedItem::Task(resolved.task()?),
14139                        ItemKind::Project => ContradictedItem::Project(resolved.project()),
14140                    },
14141                });
14142            }
14143            let info = items
14144                .get("pageInfo")
14145                .ok_or_else(|| SourceError::Malformed {
14146                    message: "GitHub project items have no pageInfo".into(),
14147                })?;
14148            if !required_bool(info, "hasNextPage")? {
14149                return Ok(found);
14150            }
14151            let next = required_str(info, "endCursor")?;
14152            validate_cursor_progress(after.as_deref(), next)?;
14153            after = Some(next.to_owned());
14154        }
14155    }
14156
14157    /// What this board holds for each issue in `issues`, once each in the order given, read
14158    /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
14159    ///
14160    /// An issue GitHub cannot find for this source's credential, and one with no item on this
14161    /// board, are both absent. A closure that already says the work will not be done here —
14162    /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
14163    /// completed whose card sits at its kind's `done` option is unchanged, and every other item
14164    /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
14165    /// tasks as sub-issues — is held under exactly these rules, judged against the project
14166    /// kind's half of `status_mapping`.
14167    async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
14168        let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
14169        for issue in issues {
14170            if !distinct.contains(issue) {
14171                distinct.push(issue.clone());
14172            }
14173        }
14174        let done = |kind: ItemKind| {
14175            self.statuses
14176                .target(kind, StatusCategory::Done)
14177                .option()
14178                .unwrap_or("Done")
14179                .to_owned()
14180        };
14181        // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
14182        // its range names no issue any repository can hold: it is answered absent and never sent,
14183        // where binding it would have GitHub refuse the whole batch.
14184        let readable: Vec<IssueRef> = distinct
14185            .iter()
14186            .filter(|issue| i32::try_from(issue.number).is_ok())
14187            .cloned()
14188            .collect();
14189        let mut holdings = Vec::with_capacity(readable.len());
14190        for batch in readable.chunks(REFERENCE_BATCH) {
14191            let data = self
14192                .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
14193                .await?;
14194            for (slot, issue) in batch.iter().enumerate() {
14195                let node = referenced_issue(&data, slot)?;
14196                let resolved = match node {
14197                    Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
14198                    None => None,
14199                };
14200                // A task and a project are both held; a document is not work, so nothing is.
14201                let Some((item, kind)) = resolved.and_then(|item| match item.kind {
14202                    BoardKind::Work(kind) => Some((item, kind)),
14203                    BoardKind::Document => None,
14204                }) else {
14205                    holdings.push(IssueHolding {
14206                        issue: issue.clone(),
14207                        completion: Completion::Absent,
14208                    });
14209                    continue;
14210                };
14211                let task = match kind {
14212                    ItemKind::Task => {
14213                        let held = item.task()?;
14214                        HeldTask {
14215                            id: held.id,
14216                            kind,
14217                            option: item.option.clone(),
14218                            classification: held.classification,
14219                            repositories: held.repositories,
14220                            delivers: held.delivers,
14221                        }
14222                    }
14223                    // A project delivers no task, so what its write keeps in step is nothing.
14224                    ItemKind::Project => {
14225                        let held = item.project();
14226                        HeldTask {
14227                            id: held.id,
14228                            kind,
14229                            option: item.option.clone(),
14230                            classification: held.classification,
14231                            repositories: held.repositories,
14232                            delivers: Vec::new(),
14233                        }
14234                    }
14235                };
14236                let done = done(kind);
14237                let completion = match (item.closed, item.reason.as_deref()) {
14238                    (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
14239                        task,
14240                        reason: format!("closed as {}", spoken_reason(reason)),
14241                    },
14242                    (true, None | Some("COMPLETED"))
14243                        if item
14244                            .option
14245                            .as_deref()
14246                            .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
14247                    {
14248                        Completion::Unchanged { task }
14249                    }
14250                    _ => {
14251                        let kept = item.clone();
14252                        self.held_cache
14253                            .lock()
14254                            .map_err(|_| SourceError::Unavailable {
14255                                message: "items read by issue reference were left \
14256                                          inconsistent; run the command again"
14257                                    .into(),
14258                            })?
14259                            .insert(kept.id.clone(), kept);
14260                        Completion::Due {
14261                            task,
14262                            to: done.clone(),
14263                        }
14264                    }
14265                };
14266                holdings.push(IssueHolding {
14267                    issue: issue.clone(),
14268                    completion,
14269                });
14270            }
14271        }
14272        let mut read = holdings.into_iter();
14273        let mut answered = Vec::with_capacity(distinct.len());
14274        for issue in distinct {
14275            if i32::try_from(issue.number).is_ok() {
14276                answered.push(read.next().ok_or_else(|| SourceError::Malformed {
14277                    message: format!("no holding was read for {issue}"),
14278                })?);
14279            } else {
14280                answered.push(IssueHolding {
14281                    issue,
14282                    completion: Completion::Absent,
14283                });
14284            }
14285        }
14286        Ok(answered)
14287    }
14288}
14289
14290#[async_trait::async_trait]
14291impl TaskSource for GitHubProjectsSource {
14292    fn kind(&self) -> &'static str {
14293        KIND
14294    }
14295    /// GitHub's issue search, which narrows a read of the comments made since an instant, is
14296    /// an index behind the writes it indexes — the credentialed lane has watched it miss a newly
14297    /// commented issue for thirty seconds — so a watermark is set back by
14298    /// [`COMMENT_SEARCH_LAG`], four times that.
14299    fn comment_consistency_window(&self) -> std::time::Duration {
14300        COMMENT_SEARCH_LAG
14301    }
14302    /// The `Date` of the last answer GitHub gave this source.
14303    fn served_at(&self) -> Option<DateTime<Utc>> {
14304        *self
14305            .last_served
14306            .lock()
14307            .unwrap_or_else(std::sync::PoisonError::into_inner)
14308    }
14309    fn attach_body_markup(&self, markup: onetaskgraph_plugin_api::SharedBodyMarkup) {
14310        let _ = self.body_markup.set(markup);
14311    }
14312    fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
14313        change_token::order(a, b)
14314    }
14315    /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
14316    /// member, the change each part of the token moves for. Not its forward edges — see
14317    /// [`FAR_ENDS_UNCOVERED`].
14318    fn version_coverage(&self) -> VersionCoverage {
14319        VersionCoverage {
14320            forward_edges: Coverage::Uncovered {
14321                reason: FAR_ENDS_UNCOVERED.to_owned(),
14322            },
14323            ..VersionCoverage::whole()
14324        }
14325    }
14326    /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
14327    /// and for a far end deleted, so the token sees every change to a task's edges but one — a
14328    /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
14329    /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
14330    /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
14331    // 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.
14332    fn copy_version_coverage(&self) -> VersionCoverage {
14333        VersionCoverage::whole()
14334    }
14335    /// A project's fields, and not its forward edges, for the same reason a task's are not.
14336    fn project_version_coverage(&self) -> ProjectVersionCoverage {
14337        ProjectVersionCoverage {
14338            fields: Coverage::Covered,
14339            forward_edges: Coverage::Uncovered {
14340                reason: FAR_ENDS_UNCOVERED.to_owned(),
14341            },
14342        }
14343    }
14344    /// A document's fields and the manifest of its image assets, which its metadata slot
14345    /// records: both are the issue's body, whose every edit moves the token's issue time.
14346    fn document_version_coverage(&self) -> DocumentVersionCoverage {
14347        DocumentVersionCoverage::whole()
14348    }
14349    fn write_context(&self, id: &NativeId) -> Option<Value> {
14350        self.context_of(id)
14351    }
14352    fn board_context(&self) -> Option<Value> {
14353        self.board_context_held()
14354    }
14355    fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
14356        self.prime(id, context, board)
14357    }
14358    fn unprime(&self, id: &NativeId) {
14359        self.unprime_held(id);
14360    }
14361    fn placement_context(&self) -> Option<Value> {
14362        self.placement_held()
14363    }
14364    fn prime_placement(&self, context: &Value) -> bool {
14365        self.prime_placement_held(context)
14366    }
14367    fn written_context(&self, id: &NativeId) -> Option<Value> {
14368        self.written_held(id)
14369    }
14370    fn prime_written(&self, id: &NativeId, context: &Value, task: &Task) -> bool {
14371        self.prime_written_held(id, context, task)
14372    }
14373    fn begin_journal(&self) {
14374        self.expected.begin();
14375    }
14376    fn end_journal(&self) -> Option<WriteJournal> {
14377        Some(self.expected.end())
14378    }
14379    fn capabilities(&self) -> Capabilities {
14380        Capabilities {
14381            projects: Support::Native,
14382            documents: Support::Native,
14383            comments: Support::Native,
14384            assets: Support::Native,
14385            priority: if self.priorities.is_some() {
14386                Support::Native
14387            } else {
14388                Support::Unsupported
14389            },
14390            filter_by_priority: Support::Native,
14391            filter_by_comment_activity: Support::Native,
14392            filter_by_metadata: Support::Native,
14393            filter_by_origin: Support::Native,
14394            reconcile_status: Support::Native,
14395            complete_by_issue: Support::Native,
14396            orphan_tasks: Support::Native,
14397            filter_by_label: Support::Native,
14398            filter_by_status: Support::Native,
14399            search_title: Support::Native,
14400            search_content: Support::Native,
14401            task_dependencies: DependencySupport::BothDirections,
14402            project_dependencies: DependencySupport::BothDirections,
14403            max_page_size: MAX_PAGE_SIZE,
14404        }
14405    }
14406    async fn visibility(
14407        &self,
14408        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14409    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14410        self.write_visibility(target).await
14411    }
14412    async fn visibility_with_project(
14413        &self,
14414        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14415        project: &onetaskgraph_plugin_api::WriteTarget<'_>,
14416    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14417        self.write_visibility_with_project(target, project).await
14418    }
14419    fn declare_visibility(&self, declared: onetaskgraph_plugin_api::Visibility) {
14420        self.declared_private.store(
14421            declared == onetaskgraph_plugin_api::Visibility::Private,
14422            std::sync::atomic::Ordering::SeqCst,
14423        );
14424    }
14425    fn scope_visibility<'a>(
14426        &'a self,
14427        operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
14428    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
14429        self.scope_visibility_reads(operation)
14430    }
14431
14432    /// Where a create of `target` would file its issue, by [`Self::creation_rule`] — the rule
14433    /// the create applies — reading the parent project's issue only when `target` names one.
14434    async fn creation_repository(
14435        &self,
14436        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14437    ) -> Result<Option<Repository>, SourceError> {
14438        let onetaskgraph_plugin_api::WriteTarget::New {
14439            repositories,
14440            project,
14441        } = target
14442        else {
14443            return Ok(None);
14444        };
14445        let target = self
14446            .creation_rule(repositories, *project, "a new item")
14447            .await?;
14448        Repository::try_from(target.origin())
14449            .map(Some)
14450            .map_err(|message| SourceError::Malformed { message })
14451    }
14452    /// Every issue of the repository a create of `target` files in whose body carries `key` in
14453    /// its metadata slot, read off that repository's issues newest first back to `since` —
14454    /// never through search, whose index lags a fresh issue by seconds.
14455    async fn created_with_key(
14456        &self,
14457        record: onetaskgraph_plugin_api::MetadataRecord,
14458        key: &str,
14459        since: Option<DateTime<Utc>>,
14460        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14461    ) -> Result<Vec<NativeId>, SourceError> {
14462        let _ = record;
14463        let onetaskgraph_plugin_api::WriteTarget::New {
14464            repositories,
14465            project,
14466        } = target
14467        else {
14468            return Ok(Vec::new());
14469        };
14470        let repository = self
14471            .creation_rule(repositories, *project, "a new item")
14472            .await?;
14473        let wanted = Value::String(key.to_owned());
14474        let mut found = Vec::new();
14475        let mut after: Option<String> = None;
14476        let mut followed = std::collections::HashSet::new();
14477        loop {
14478            let data = self
14479                .graphql(
14480                    graphql::ISSUES_BY_CREATION,
14481                    json!({"owner": repository.owner, "name": repository.name,
14482                           "first": MAX_PAGE_SIZE, "after": after}),
14483                )
14484                .await?;
14485            let issues =
14486                data.pointer("/repository/issues")
14487                    .ok_or_else(|| SourceError::Malformed {
14488                        message: format!(
14489                            "GitHub answered no issues for repository {}",
14490                            repository.slug()
14491                        ),
14492                    })?;
14493            let malformed = |what: &str| SourceError::Malformed {
14494                message: format!(
14495                    "GitHub answered a page of repository {}'s issues {what}",
14496                    repository.slug()
14497                ),
14498            };
14499            let nodes = issues
14500                .get("nodes")
14501                .and_then(Value::as_array)
14502                .ok_or_else(|| malformed("with no list of issues"))?;
14503            let mut older = false;
14504            for node in nodes {
14505                let id = node
14506                    .get("id")
14507                    .and_then(Value::as_str)
14508                    .filter(|id| !id.is_empty())
14509                    .ok_or_else(|| malformed("holding an issue with no id"))?;
14510                let created = optional_time(node, "createdAt")?
14511                    .ok_or_else(|| malformed("holding an issue with no creation time"))?;
14512                if since.is_some_and(|since| created < since) {
14513                    older = true;
14514                    break;
14515                }
14516                let body = match node.get("body") {
14517                    Some(Value::String(body)) => Some(body.clone()),
14518                    Some(Value::Null) => None,
14519                    _ => return Err(malformed("holding an issue with no body member")),
14520                };
14521                let (_, metadata) = metadata_body(body)?;
14522                if metadata.get(MetadataKey::CREATE_KEY) == Some(&wanted) {
14523                    found.push(NativeId(id.to_owned()));
14524                }
14525            }
14526            let next = issues
14527                .pointer("/pageInfo/hasNextPage")
14528                .and_then(Value::as_bool)
14529                .ok_or_else(|| malformed("that does not say whether another follows"))?;
14530            if older || !next {
14531                return Ok(found);
14532            }
14533            // A page that says another follows names where it starts, and never a page already
14534            // read: anything else would ask for the same pages again for ever.
14535            let cursor = issues
14536                .pointer("/pageInfo/endCursor")
14537                .and_then(Value::as_str)
14538                .filter(|cursor| !cursor.is_empty())
14539                .map(str::to_owned);
14540            if cursor
14541                .as_ref()
14542                .is_none_or(|cursor| !followed.insert(cursor.clone()))
14543            {
14544                return Err(SourceError::Malformed {
14545                    message: format!(
14546                        "GitHub answered a page of repository {}'s issues that says another \
14547                         follows without a new cursor to it",
14548                        repository.slug()
14549                    ),
14550                });
14551            }
14552            after = cursor;
14553        }
14554    }
14555
14556    /// `Repository.issues` lists an issue GitHub committed only once its listing catches up.
14557    fn create_key_lags(&self) -> bool {
14558        true
14559    }
14560
14561    /// The task as this process's own latest write of it left it, from the item it recorded
14562    /// that write as — never a request. Its `version` is always `None`: a board's change token
14563    /// is given only once an item has gone unchanged for [`change_token::SETTLED`], which an
14564    /// item this command has just written has not, so no write's own answer can vouch for one.
14565    async fn written_task(&self, id: &NativeId) -> Option<Task> {
14566        let held = self
14567            .created()
14568            .ok()?
14569            .iter()
14570            .find(|held| held.id == *id)
14571            .cloned()
14572            .or_else(|| {
14573                self.updated()
14574                    .ok()?
14575                    .iter()
14576                    .find(|held| held.id == *id)
14577                    .cloned()
14578            })?;
14579        let mut task = held.task().ok()?;
14580        task.version = self.written_token(id);
14581        Some(task)
14582    }
14583
14584    /// Why `creation_owners` refuses a create of `target`, by [`Self::creation_rule`] — read
14585    /// only when the list is not empty, so a source restricting nothing reads nothing.
14586    async fn creation_refusal(
14587        &self,
14588        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14589    ) -> Result<Option<String>, SourceError> {
14590        let onetaskgraph_plugin_api::WriteTarget::New {
14591            repositories,
14592            project,
14593        } = target
14594        else {
14595            return Ok(None);
14596        };
14597        if self.creation_owners.is_empty() {
14598            return Ok(None);
14599        }
14600        let target = self
14601            .creation_rule(repositories, *project, "a new item")
14602            .await?;
14603        Ok(self.owner_refusal(&target))
14604    }
14605
14606    async fn health(&self) -> Result<Health, SourceError> {
14607        let board = self.board_page(None, 1).await?;
14608        Ok(Health {
14609            reachable: true,
14610            detail: Some(format!(
14611                "reading GitHub project {}/{} ({})",
14612                self.owner,
14613                self.project_number,
14614                required_str(&board, "title")?
14615            )),
14616        })
14617    }
14618    /// The board's own node id, digested: the one request reading its fields, or none when
14619    /// this command has read them already.
14620    async fn designator(&self) -> Result<Option<Designator>, SourceError> {
14621        let board = self.board_fields().await?;
14622        Designator::of_backend(KIND, board.id.as_str())
14623            .map(Some)
14624            .map_err(|message| SourceError::Malformed { message })
14625    }
14626    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
14627        self.item_by_id(id)
14628            .await?
14629            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14630            .map(|item| item.task())
14631            .transpose()
14632    }
14633    async fn task_assets(
14634        &self,
14635        id: &NativeId,
14636    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14637        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
14638    }
14639    async fn task_asset(
14640        &self,
14641        id: &NativeId,
14642        name: &onetaskgraph_plugin_api::AssetName,
14643    ) -> Result<Option<Vec<u8>>, SourceError> {
14644        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
14645            .await
14646    }
14647    async fn set_task_rendering_with_assets(
14648        &self,
14649        id: &NativeId,
14650        content: &str,
14651        provenance: &Value,
14652        _answers: &BTreeMap<String, Value>,
14653        assets: &onetaskgraph_plugin_api::AssetWrite,
14654    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14655        self.replace_rendering(
14656            id,
14657            BoardKind::Work(ItemKind::Task),
14658            content,
14659            provenance,
14660            Some(assets),
14661        )
14662        .await
14663    }
14664    async fn document_assets(
14665        &self,
14666        id: &NativeId,
14667    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14668        self.held_assets(id, BoardKind::Document).await
14669    }
14670    async fn document_asset(
14671        &self,
14672        id: &NativeId,
14673        name: &onetaskgraph_plugin_api::AssetName,
14674    ) -> Result<Option<Vec<u8>>, SourceError> {
14675        self.held_asset(id, BoardKind::Document, name).await
14676    }
14677    async fn set_document_rendering_with_assets(
14678        &self,
14679        id: &NativeId,
14680        content: &str,
14681        provenance: &Value,
14682        _answers: &BTreeMap<String, Value>,
14683        assets: &onetaskgraph_plugin_api::AssetWrite,
14684    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14685        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
14686            .await
14687    }
14688    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
14689        Ok(self
14690            .item_by_id(id)
14691            .await?
14692            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14693            .map(|item| item.project()))
14694    }
14695    /// Every narrowed search and origin lookup asked, a page at a time, as aliased fields of one
14696    /// [`graphql::SEARCH_BATCH`] request a round — a search's issue search in an `s<n>` field,
14697    /// an origin's board filter in an `o<n>` field beside its issue search — and every other
14698    /// query by [`Self::query_tasks`] on its own: one narrowed by a project or by comment
14699    /// activity, one carrying no narrowing, one resumed from a cursor that read handed out, and
14700    /// every query of a process that has written this board, whose own writes complete a
14701    /// narrowed answer. A lone search is its own read too, which costs a batch's point at a
14702    /// page of its own size.
14703    ///
14704    /// Each search's first page asks [`SEARCH_BATCH_FIRST_PAGE`] rows and each later page
14705    /// [`SEARCH_PAGE_SIZE`]. GitHub answers an error about one aliased field at that field's
14706    /// path, beside the others' answers, so a refused page fails that query's answer alone; a
14707    /// request it refuses whole is every query of that request's answer.
14708    async fn query_tasks_batch(
14709        &self,
14710        asks: &[(TaskQuery, PageRequest)],
14711    ) -> Vec<Result<Page<Task>, SourceError>> {
14712        self.search_batched(asks).await
14713    }
14714    async fn query_tasks(
14715        &self,
14716        query: &TaskQuery,
14717        page: &PageRequest,
14718    ) -> Result<Page<Task>, SourceError> {
14719        validate_page(page)?;
14720        refuse_unsearchable(query)?;
14721        // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
14722        // category — `unknown`; every other category reads only off an open issue or a draft. So
14723        // a status filter naming none of those three asks every issue search it sends for open
14724        // issues alone; each candidate's category is still confirmed from its own item below.
14725        let open_only = open_only(query);
14726        if query.origin.is_none()
14727            && !matches!(query.project, ProjectFilter::Is(_))
14728            && let Some(also) = search_qualifiers(query)
14729        {
14730            return self.search_tasks(query, page, &also).await;
14731        }
14732
14733        // A read narrowed to one project asks that project for its own tasks, so nothing
14734        // about it costs what the rest of the board holds. A read carrying a text, metadata
14735        // or origin predicate asks GitHub the narrower question those predicates are, and a
14736        // read narrowed to comment activity alone asks the board's own issue search for the
14737        // issues updated since, which is every issue a comment could have been written or
14738        // edited on since. Every other task read is a question about the whole board and is
14739        // answered by reading it.
14740        let (held, membership) = match (&query.project, query.commented_since) {
14741            (ProjectFilter::Is(project), _) => (
14742                match self.held_task_children(project)? {
14743                    // Taken as `project_children` takes the project's own sub-issue read: with
14744                    // every item this process wrote under it since, and none it moved away.
14745                    Some(held) => self.completed_with_written(
14746                        held.into_iter()
14747                            .filter(|child| child.parent.as_ref() == Some(project))
14748                            .collect(),
14749                        |own| own.parent.as_ref() == Some(project),
14750                    )?,
14751                    None => self.project_children(project).await?,
14752                },
14753                // Answered by where these items came from; see `task_matches`.
14754                &ProjectFilter::Any,
14755            ),
14756            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
14757                match (self.narrowed(query).await?, since) {
14758                    (Some(narrowed), _) => (narrowed, &query.project),
14759                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
14760                    // The board's own items list every draft; only the search half, which
14761                    // completes them with issues they lag behind on, is narrowed — a closed
14762                    // issue that half leaves out is at no category the filter keeps.
14763                    (None, None) if open_only => (
14764                        self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
14765                        &query.project,
14766                    ),
14767                    (None, None) => (self.board().await?.items, &query.project),
14768                }
14769            }
14770        };
14771        // Every candidate this read confirms by its comments is read together.
14772        let candidates = self.comment_candidates(held.iter(), query, membership)?;
14773        self.hold_comment_pages(&candidates).await?;
14774        // Filtered before paged: a page of a filtered result is a page of the survivors,
14775        // never the survivors of a page.
14776        let mut tasks = Vec::new();
14777        for item in held
14778            .iter()
14779            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14780        {
14781            let task = item.task()?;
14782            if task_matches(&task, query, membership)
14783                && self.commented_since(item, query.commented_since).await?
14784            {
14785                tasks.push(task);
14786            }
14787        }
14788        Ok(offset_page(
14789            tasks,
14790            numeric_cursor(page.cursor.as_ref())?,
14791            page.limit.min(MAX_PAGE_SIZE) as usize,
14792        ))
14793    }
14794    /// Read board pages directly, at the board connection's consistency. Unlike
14795    /// `query_tasks`, this does not complete a lagging board with issue search: a complete
14796    /// sweep needs one request per listing page, including the first comments.
14797    async fn query_task_details(
14798        &self,
14799        query: &TaskQuery,
14800        page: &PageRequest,
14801        comments: Option<&PageRequest>,
14802    ) -> Result<Page<TaskDetailRead>, SourceError> {
14803        let Some(comments) = comments else {
14804            let tasks = self.query_tasks(query, page).await?;
14805            return Ok(Page {
14806                items: tasks
14807                    .items
14808                    .into_iter()
14809                    .map(|task| TaskDetailRead {
14810                        task,
14811                        comments: None,
14812                    })
14813                    .collect(),
14814                next: tasks.next,
14815            });
14816        };
14817        validate_page(page)?;
14818        validate_page(comments)?;
14819        refuse_unsearchable(query)?;
14820        if comments.cursor.is_some() || query.commented_since.is_some() {
14821            return Err(SourceError::Refused {
14822                message: "a board listing with comments requires a first comment page and cannot filter by comment activity".into(),
14823            });
14824        }
14825        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
14826        let data = self
14827            .graphql(
14828                graphql::BOARD_WITH_COMMENTS,
14829                json!({
14830                    "owner":self.owner,"number":self.project_number,
14831                    "first":page.limit.min(MAX_PAGE_SIZE),"after":after,
14832                    "commentFirst":comments.limit.min(MAX_PAGE_SIZE),
14833                    "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true,
14834                }),
14835            )
14836            .await?;
14837        let connection = data
14838            .pointer("/owner/projectV2/items")
14839            .filter(|value| !value.is_null())
14840            .ok_or_else(|| SourceError::Refused {
14841                message: "GitHub project was not found or is not visible to the token".into(),
14842            })?;
14843        let mut items = Vec::new();
14844        for node in optional_nodes(Some(connection), "board items")?
14845            .into_iter()
14846            .flatten()
14847        {
14848            let Some(item) = self.resolve(node)? else {
14849                continue;
14850            };
14851            if item.kind != BoardKind::Work(ItemKind::Task) {
14852                continue;
14853            }
14854            let task = item.task()?;
14855            if !task_matches(&task, query, &query.project) {
14856                continue;
14857            }
14858            let read = if item.content_kind == ContentKind::DraftIssue {
14859                Err(self.draft_has_no_comments(&task.id))
14860            } else {
14861                self.expected.learn_comments(&task.id, &node["content"]);
14862                comment_page(&node["content"], &task.id.0, None).map(Some)
14863            };
14864            items.push(TaskDetailRead {
14865                task,
14866                comments: Some(read),
14867            });
14868        }
14869        let next = next_cursor(connection)?;
14870        if let Some(next) = &next {
14871            validate_cursor_progress(after, &next.0)?;
14872        }
14873        Ok(Page { items, next })
14874    }
14875
14876    async fn query_projects(
14877        &self,
14878        query: &ProjectQuery,
14879        page: &PageRequest,
14880    ) -> Result<Page<Project>, SourceError> {
14881        validate_page(page)?;
14882        refuse_unsearchable_text(query.text.as_ref())?;
14883        // The projects a board holds are found by an issue search scoped to that board,
14884        // never by walking the board's own item connection: what tells a project from a
14885        // task is the `parent` each issue carries, which costs nothing to read. A query
14886        // carrying a text asks that search for the text too, so it reads the issues that
14887        // hold it rather than every issue of the board.
14888        let held = match self.text_searched(query.text.as_ref()).await? {
14889            Some(searched) => searched,
14890            None => self.board_issues().await?,
14891        };
14892        let projects = held
14893            .iter()
14894            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14895            .map(Resolved::project)
14896            .filter(|project| project_matches(project, query))
14897            .collect();
14898        Ok(offset_page(
14899            projects,
14900            numeric_cursor(page.cursor.as_ref())?,
14901            page.limit.min(MAX_PAGE_SIZE) as usize,
14902        ))
14903    }
14904    async fn query_projects_by_origin(
14905        &self,
14906        origin: &str,
14907        page: &PageRequest,
14908    ) -> Result<Page<Project>, SourceError> {
14909        validate_page(page)?;
14910        let projects = self
14911            .copied_from(origin, BoardKind::Work(ItemKind::Project))
14912            .await?
14913            .iter()
14914            .map(Resolved::project)
14915            .collect();
14916        Ok(offset_page(
14917            projects,
14918            numeric_cursor(page.cursor.as_ref())?,
14919            page.limit.min(MAX_PAGE_SIZE) as usize,
14920        ))
14921    }
14922    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
14923        let Some(item) = self
14924            .document_by_id(id)
14925            .await?
14926            .filter(|item| item.kind == BoardKind::Document)
14927        else {
14928            return Ok(None);
14929        };
14930        self.expected.read_document(&item);
14931        Ok(Some(item.document()))
14932    }
14933    async fn query_documents_by_origin(
14934        &self,
14935        origin: &str,
14936        page: &PageRequest,
14937    ) -> Result<Page<Document>, SourceError> {
14938        validate_page(page)?;
14939        let documents = self
14940            .copied_from(origin, BoardKind::Document)
14941            .await?
14942            .iter()
14943            .map(Resolved::document)
14944            .collect();
14945        Ok(offset_page(
14946            documents,
14947            numeric_cursor(page.cursor.as_ref())?,
14948            page.limit.min(MAX_PAGE_SIZE) as usize,
14949        ))
14950    }
14951    async fn query_documents(
14952        &self,
14953        query: &DocumentQuery,
14954        page: &PageRequest,
14955    ) -> Result<Page<Document>, SourceError> {
14956        validate_page(page)?;
14957        // Narrowed to one project, this is the same sub-issue read a task list scoped to
14958        // that project makes — a document filed under a project is a sub-issue of it too,
14959        // and which of them come back is the kind this caller asked for. Unscoped, a query
14960        // carrying a text asks the board-scoped issue search for it, as a task query does,
14961        // and only one carrying none reads the board.
14962        let (held, membership) = match &query.project {
14963            ProjectFilter::Is(project) => (
14964                self.project_children(project).await?,
14965                // Answered by where these items came from; see `task_matches`.
14966                &ProjectFilter::Any,
14967            ),
14968            ProjectFilter::Any | ProjectFilter::Orphans => {
14969                refuse_unsearchable_text(query.text.as_ref())?;
14970                match self.text_searched(query.text.as_ref()).await? {
14971                    Some(searched) => (searched, &query.project),
14972                    None => (self.board().await?.items, &query.project),
14973                }
14974            }
14975        };
14976        // Filtered before paged, exactly as a task read is: a page of a filtered result is
14977        // a page of the survivors, never the survivors of a page.
14978        let documents = held
14979            .iter()
14980            .filter(|item| item.kind == BoardKind::Document)
14981            .map(Resolved::document)
14982            .filter(|document| document_matches(document, query, membership))
14983            .collect();
14984        Ok(offset_page(
14985            documents,
14986            numeric_cursor(page.cursor.as_ref())?,
14987            page.limit.min(MAX_PAGE_SIZE) as usize,
14988        ))
14989    }
14990    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
14991        validate_page(page)?;
14992        let offset = numeric_cursor(page.cursor.as_ref())?;
14993        let mut labels = self
14994            .board()
14995            .await?
14996            .items
14997            .into_iter()
14998            .flat_map(|item| item.labels)
14999            .fold(Vec::new(), |mut all, label| {
15000                if !all.iter().any(|x: &Label| x.id == label.id) {
15001                    all.push(label);
15002                }
15003                all
15004            });
15005        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
15006        Ok(offset_page(
15007            labels,
15008            offset,
15009            page.limit.min(MAX_PAGE_SIZE) as usize,
15010        ))
15011    }
15012    async fn task_dependencies(
15013        &self,
15014        id: &NativeId,
15015        direction: Direction,
15016        page: &PageRequest,
15017    ) -> Result<Page<DependencyEdge>, SourceError> {
15018        self.dependencies(id, ItemKind::Task, direction, page).await
15019    }
15020    async fn project_dependencies(
15021        &self,
15022        id: &NativeId,
15023        direction: Direction,
15024        page: &PageRequest,
15025    ) -> Result<Page<DependencyEdge>, SourceError> {
15026        self.dependencies(id, ItemKind::Project, direction, page)
15027            .await
15028    }
15029
15030    fn writes(&self) -> WriteSupport {
15031        WriteSupport::Supported
15032    }
15033
15034    /// Create or update one task.
15035    ///
15036    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
15037    /// neither may name the task itself or name one task twice — and land in the body's
15038    /// metadata slot under their reserved keys, in place of any caller metadata of those
15039    /// names.
15040    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
15041        if write.item.project.is_some() {
15042            self.projectless()?;
15043        }
15044        self.write_task_assets(write, None)
15045            .await
15046            .map(|written| written.id)
15047    }
15048
15049    async fn read_ahead_origins_for_copy(&self, origins: &[String]) -> Result<(), SourceError> {
15050        self.origins_ahead(origins).await
15051    }
15052
15053    async fn member_classifications(
15054        &self,
15055        project: &NativeId,
15056    ) -> Result<Option<Vec<(Classification, Vec<Repository>)>>, SourceError> {
15057        Ok(self
15058            .member_classes
15059            .lock()
15060            .map_err(|_| SourceError::Unavailable {
15061                message: "a project's members held for its classification were left \
15062                          inconsistent; run the command again"
15063                    .into(),
15064            })?
15065            .remove(project))
15066    }
15067
15068    /// The issue's own web address, as the `createIssue` that made it in this command answered
15069    /// it: a task, a project and a document are each an issue here.
15070    fn written_location(
15071        &self,
15072        _record: onetaskgraph_plugin_api::MetadataRecord,
15073        id: &NativeId,
15074    ) -> Option<Location> {
15075        self.written
15076            .lock()
15077            .unwrap_or_else(std::sync::PoisonError::into_inner)
15078            .get(id)
15079            .cloned()
15080            .map(Location::Url)
15081    }
15082
15083    /// [`MUTATION_BATCH`], while writes are held back: [`graphql::CREATE_ISSUES`] creates that many
15084    /// in one request, and a copy's tasks are created together only then, since their fields
15085    /// and edges are what is held back.
15086    fn create_batch_limit(&self) -> usize {
15087        if self.deferring().unwrap_or(false) {
15088            MUTATION_BATCH
15089        } else {
15090            1
15091        }
15092    }
15093
15094    // 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.
15095    async fn write_tasks(&self, writes: &[ItemWrite<Task>]) -> Result<Vec<NativeId>, SourceError> {
15096        if writes.iter().any(|write| write.item.project.is_some()) {
15097            self.projectless()?;
15098        }
15099        if let Some(write) = writes.iter().find(|write| write.target.is_some()) {
15100            return Err(SourceError::Refused {
15101                message: format!(
15102                    "write_tasks creates tasks, and the write of {} names the existing task {}; \
15103                     next: update it with write_task",
15104                    write.item.id,
15105                    write.target.as_ref().map_or("", |target| target.0.as_str())
15106                ),
15107            });
15108        }
15109        if !self.deferring()? {
15110            let mut created = Vec::with_capacity(writes.len());
15111            for write in writes {
15112                match self.write_task(write).await {
15113                    Ok(id) => created.push(id),
15114                    Err(error) => {
15115                        let mut left: Vec<&str> = Vec::new();
15116                        for id in &created {
15117                            if self.delete_issue(id).await.is_err() {
15118                                left.push(id.0.as_str());
15119                            }
15120                        }
15121                        return Err(left_behind(error, &left));
15122                    }
15123                }
15124            }
15125            return Ok(created);
15126        }
15127        self.create_together(writes).await
15128    }
15129
15130    fn begin_deferred_writes(&self) {
15131        if let Ok(mut deferred) = self.deferred.lock() {
15132            deferred.get_or_insert_with(Deferred::default);
15133        }
15134    }
15135
15136    async fn finish_writes(&self) -> Result<(), SourceError> {
15137        self.send_held_updates().await?;
15138        self.send_held_back().await
15139    }
15140
15141    async fn write_task_with_assets(
15142        &self,
15143        write: &ItemWrite<Task>,
15144        _answers: Option<&BTreeMap<String, Value>>,
15145        assets: &onetaskgraph_plugin_api::AssetWrite,
15146    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15147        self.write_task_assets(write, Some(assets)).await
15148    }
15149
15150    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
15151        self.projectless()?;
15152        self.write_item(
15153            &Incoming {
15154                written: Written::Work(ItemKind::Project, &write.item.status),
15155                title: &write.item.title,
15156                content: write.item.content.as_deref(),
15157                assets: None,
15158                labels: &write.item.labels,
15159                metadata: &write.item.metadata,
15160                repositories: &write.item.repositories,
15161                classification: write.item.classification,
15162                parent: None,
15163                delivers: &[],
15164                delivered_by: &[],
15165                priority: None,
15166            },
15167            write.target.as_ref(),
15168            &write.depends_on,
15169        )
15170        .await
15171        .map(|written| written.id)
15172    }
15173
15174    fn task_body_size(
15175        &self,
15176        write: &ItemWrite<Task>,
15177        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15178    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15179        Some(self.body_size(&self.task_incoming(write, assets).ok()?))
15180    }
15181
15182    fn project_body_size(
15183        &self,
15184        write: &ItemWrite<Project>,
15185    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15186        Some(self.body_size(&Incoming {
15187            written: Written::Work(ItemKind::Project, &write.item.status),
15188            title: &write.item.title,
15189            content: write.item.content.as_deref(),
15190            assets: None,
15191            labels: &write.item.labels,
15192            metadata: &write.item.metadata,
15193            repositories: &write.item.repositories,
15194            classification: write.item.classification,
15195            parent: None,
15196            delivers: &[],
15197            delivered_by: &[],
15198            priority: None,
15199        }))
15200    }
15201
15202    fn document_body_size(
15203        &self,
15204        write: &ItemWrite<Document>,
15205        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15206    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15207        Some(self.body_size(&Incoming {
15208            written: Written::Document,
15209            title: &write.item.title,
15210            content: write.item.content.as_deref(),
15211            assets,
15212            labels: &write.item.labels,
15213            metadata: &write.item.metadata,
15214            repositories: &write.item.repositories,
15215            classification: write.item.classification,
15216            parent: write.item.project.as_ref(),
15217            delivers: &[],
15218            delivered_by: &[],
15219            priority: None,
15220        }))
15221    }
15222
15223    /// Create or update one document, which is one issue titled the way this board spells
15224    /// a document.
15225    ///
15226    /// Everything else is exactly a task write: caller metadata goes to the same canonical
15227    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
15228    /// or a field this board cannot carry is refused by name rather than dropped, a target
15229    /// naming an issue this board does not hold is refused rather than created, and an
15230    /// issue this call created is taken back when the rest of the write fails.
15231    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
15232        self.write_document_assets(write, None)
15233            .await
15234            .map(|written| written.id)
15235    }
15236
15237    async fn write_document_with_assets(
15238        &self,
15239        write: &ItemWrite<Document>,
15240        _answers: Option<&BTreeMap<String, Value>>,
15241        assets: &onetaskgraph_plugin_api::AssetWrite,
15242    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15243        self.write_document_assets(write, Some(assets)).await
15244    }
15245
15246    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
15247    /// which reads nothing; then the board's `Status` option. Over an existing item that is
15248    /// read off the item, as the write reads it, and the item is held among this command's
15249    /// resolved records so the write that follows reuses that read rather than repeating it;
15250    /// an item that does not carry the field takes the board's fields, which are held once
15251    /// read. A create is checked against the board's fields only when this command already
15252    /// holds them, because a create reads them together with its repository, in one request,
15253    /// and refuses a missing option before it writes anything.
15254    async fn check_status_write(
15255        &self,
15256        kind: ItemKind,
15257        category: StatusCategory,
15258        target: Option<&NativeId>,
15259    ) -> Result<(), SourceError> {
15260        let status = self.resolved_target(kind, category)?;
15261        if status.option().is_none() {
15262            return Ok(());
15263        }
15264        let fields = match target {
15265            Some(target) => {
15266                // A target this board does not hold is the write's own refusal to make.
15267                let Some(item) = self.bound_item(target).await? else {
15268                    return Ok(());
15269                };
15270                self.resolved_cache()?.insert(target.clone(), item.clone());
15271                // Only the `Status` field decides whether the write can land.
15272                self.fields_for(Some(&item), false, true, false)
15273                    .await?
15274                    .fields
15275            }
15276            None => {
15277                let held = self
15278                    .board_cache()?
15279                    .as_ref()
15280                    .map(|board| board.fields.clone());
15281                match held.or_else(|| {
15282                    self.fields_cache()
15283                        .ok()
15284                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
15285                }) {
15286                    Some(fields) => fields,
15287                    None => return Ok(()),
15288                }
15289            }
15290        };
15291        self.column_for(&fields, kind, category, &status)
15292            .map(|_| ())
15293    }
15294
15295    /// What a write of `status` reads back as: the `Status` option its category maps to for
15296    /// `kind`, and for a closing category the closure that option and its fixed reason read
15297    /// as — exactly what [`BoardStatuses::status`] reports of the item once written. A category
15298    /// this instance has no option for is answered as given, since writing it is refused.
15299    fn status_as_written(&self, kind: ItemKind, status: &Status) -> Status {
15300        match self.statuses.target(kind, status.category) {
15301            StatusTarget::Column(name) => {
15302                self.statuses.status(kind, Some(name.as_str()), false, None)
15303            }
15304            StatusTarget::Terminal(name, reason) => {
15305                self.statuses
15306                    .status(kind, Some(name.as_str()), true, Some(reason.reason()))
15307            }
15308            StatusTarget::Disabled(_) => status.clone(),
15309        }
15310    }
15311
15312    /// Set one task's status alone.
15313    ///
15314    /// An open target reopens a closed issue with an `updateIssue` carrying only its
15315    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
15316    /// terminal target selects its mapped option, then closes with its fixed reason. No
15317    /// request carries a title, a body or a label. The status
15318    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
15319    /// what a re-read reports.
15320    async fn set_task_status(
15321        &self,
15322        id: &NativeId,
15323        category: StatusCategory,
15324    ) -> Result<Option<Status>, SourceError> {
15325        self.set_status(id, ItemKind::Task, category).await
15326    }
15327
15328    /// Set one task's status and answer the task with it, reading the item once: the read the
15329    /// task is answered from is handed to the status write, which would otherwise read the
15330    /// same issue again — two requests on a fresh process, the read and the field write,
15331    /// where the default's `get_task` before `set_task_status` sent three.
15332    async fn set_task_status_reading(
15333        &self,
15334        id: &NativeId,
15335        category: StatusCategory,
15336    ) -> Result<Option<Task>, SourceError> {
15337        // Refused before anything is read, in the words the write itself refuses it.
15338        self.resolved_target(ItemKind::Task, category)?;
15339        let Some(item) = self
15340            .bound_item(id)
15341            .await?
15342            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15343        else {
15344            return Ok(None);
15345        };
15346        let mut task = item.task()?;
15347        self.held_cache
15348            .lock()
15349            .map_err(|_| SourceError::Unavailable {
15350                message: "items read by issue reference were left inconsistent; run the \
15351                          command again"
15352                    .into(),
15353            })?
15354            .insert(id.clone(), item);
15355        let Some(status) = self.set_status(id, ItemKind::Task, category).await? else {
15356            return Ok(None);
15357        };
15358        task.status = status;
15359        Ok(Some(task))
15360    }
15361
15362    /// Set one project's status alone, by exactly the paired write a task's status takes, for
15363    /// the project kind: a terminal category selects the project kind's mapped option and then
15364    /// closes the issue with the category's fixed reason — an issue already closed with a
15365    /// reason that reads that category is left as it is — and an open category reopens a closed
15366    /// issue and then selects its option. A board draft is refused a terminal category as a
15367    /// task's is.
15368    async fn set_project_status(
15369        &self,
15370        id: &NativeId,
15371        category: StatusCategory,
15372    ) -> Result<Option<Status>, SourceError> {
15373        self.projectless()?;
15374        self.set_status(id, ItemKind::Project, category).await
15375    }
15376
15377    /// Every task and project whose card contradicts its issue's closure, read through the
15378    /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
15379    async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
15380        self.contradictions().await
15381    }
15382
15383    /// What this board holds for each issue reference, one request per fifty of them; see
15384    /// [`TaskSource::issue_holdings`].
15385    async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
15386        self.holdings(issues).await
15387    }
15388
15389    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
15390    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
15391    /// for `none`. Refused by an instance with no `priority_mapping`.
15392    async fn set_task_priority(
15393        &self,
15394        id: &NativeId,
15395        priority: Priority,
15396    ) -> Result<Option<Priority>, SourceError> {
15397        self.set_priority(id, priority).await
15398    }
15399
15400    /// Replace one task's content with a single body update that keeps the metadata slot
15401    /// byte for byte.
15402    async fn set_task_content(
15403        &self,
15404        id: &NativeId,
15405        content: &str,
15406    ) -> Result<Option<()>, SourceError> {
15407        self.replace_content(id, content).await
15408    }
15409
15410    /// Replace one task issue's content and its provenance slot entry with a single body
15411    /// update. The answers are not kept: see `replace_rendering`.
15412    async fn set_task_rendering(
15413        &self,
15414        id: &NativeId,
15415        content: &str,
15416        provenance: &Value,
15417        _answers: &BTreeMap<String, Value>,
15418    ) -> Result<Option<()>, SourceError> {
15419        self.replace_rendering(
15420            id,
15421            BoardKind::Work(ItemKind::Task),
15422            content,
15423            provenance,
15424            None,
15425        )
15426        .await
15427        .map(|written| written.map(|_| ()))
15428    }
15429
15430    /// Replace one design-document issue's content and its provenance slot entry, on exactly
15431    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15432    async fn set_document_rendering(
15433        &self,
15434        id: &NativeId,
15435        content: &str,
15436        provenance: &Value,
15437        _answers: &BTreeMap<String, Value>,
15438    ) -> Result<Option<()>, SourceError> {
15439        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
15440            .await
15441            .map(|written| written.map(|_| ()))
15442    }
15443
15444    /// Replace one project issue's content and its provenance slot entry, on exactly the
15445    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15446    async fn set_project_rendering(
15447        &self,
15448        id: &NativeId,
15449        content: &str,
15450        provenance: &Value,
15451        _answers: &BTreeMap<String, Value>,
15452    ) -> Result<Option<()>, SourceError> {
15453        self.projectless()?;
15454        self.replace_rendering(
15455            id,
15456            BoardKind::Work(ItemKind::Project),
15457            content,
15458            provenance,
15459            None,
15460        )
15461        .await
15462        .map(|written| written.map(|_| ()))
15463    }
15464
15465    /// Apply a targeted update with one read of the item and a write only for what differs:
15466    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
15467    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
15468    async fn update_task(
15469        &self,
15470        id: &NativeId,
15471        update: &TaskUpdate,
15472    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
15473        self.targeted_update(id, update).await
15474    }
15475
15476    /// Each issue's sub-issue parent and children and its links, read in batches.
15477    async fn read_relations(
15478        &self,
15479        ids: &[NativeId],
15480    ) -> Vec<Result<Option<onetaskgraph_plugin_api::Relations>, SourceError>> {
15481        self.relations_of(ids).await
15482    }
15483
15484    async fn set_parents(
15485        &self,
15486        writes: &[onetaskgraph_plugin_api::ParentWrite],
15487    ) -> Vec<Result<onetaskgraph_plugin_api::ParentAnswer, SourceError>> {
15488        self.parents_set(writes).await
15489    }
15490
15491    fn link_types(&self) -> onetaskgraph_plugin_api::LinkTypes {
15492        self.link_types.clone()
15493    }
15494
15495    async fn add_native_links(
15496        &self,
15497        links: &[onetaskgraph_plugin_api::NativeLink],
15498    ) -> Vec<Result<(), SourceError>> {
15499        self.native_links(links, relations::NativeWrite::Add).await
15500    }
15501
15502    async fn remove_native_links(
15503        &self,
15504        links: &[onetaskgraph_plugin_api::NativeLink],
15505    ) -> Vec<Result<(), SourceError>> {
15506        self.native_links(links, relations::NativeWrite::Remove)
15507            .await
15508    }
15509
15510    /// Every task read together — [`DETAIL_BATCH`] to a [`graphql::ISSUE_DETAILS`] request —
15511    /// and each update's hold and guard decided on that read; then each update the read lets
15512    /// through composed exactly as [`Self::update_task`] composes one, its board field writes
15513    /// and its issue update held back and sent in [`graphql::WRITE_TASKS`] requests: every
15514    /// field write first, then the issue update of every task whose field writes all landed.
15515    ///
15516    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15517    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15518    /// that batch is read by itself, the missing one answered missing and the rest as
15519    /// themselves. A batch of one, an update of a board draft, one naming dependencies and one
15520    /// of an item whose body this process has not read are each made on their own, as
15521    /// [`Self::update_task`] makes them, because each sends writes of its own in an order the
15522    /// batch does not hold. A field write GitHub refuses fails that task's update alone and
15523    /// sends nothing of its body.
15524    async fn update_tasks(
15525        &self,
15526        updates: &[onetaskgraph_plugin_api::BatchUpdate],
15527    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<TaskUpdateOutcome>, SourceError>> {
15528        self.updates_batched(updates).await
15529    }
15530
15531    /// Replace one task's `delivered_by` with a single body update that changes the
15532    /// metadata slot and nothing outside it.
15533    async fn set_delivered_by(
15534        &self,
15535        id: &NativeId,
15536        delivered_by: &[TaskRef],
15537    ) -> Result<Option<()>, SourceError> {
15538        self.replace_delivered_by(id, delivered_by).await
15539    }
15540
15541    /// Set one key of one task issue's metadata with a single body update that changes the
15542    /// metadata slot and nothing outside it — no title, label, state or board field request —
15543    /// and sends nothing when the task already holds that value under the key.
15544    async fn set_task_metadata(
15545        &self,
15546        id: &NativeId,
15547        key: &MetadataKey,
15548        value: &Value,
15549    ) -> Result<Option<Task>, SourceError> {
15550        Ok(self
15551            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
15552            .await?
15553            .map(|item| item.task())
15554            .transpose()?)
15555    }
15556
15557    /// Set one key of one project issue's metadata, on exactly the terms of
15558    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15559    async fn set_project_metadata(
15560        &self,
15561        id: &NativeId,
15562        key: &MetadataKey,
15563        value: &Value,
15564    ) -> Result<Option<Project>, SourceError> {
15565        self.projectless()?;
15566        Ok(self
15567            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
15568            .await?
15569            .map(|item| item.project()))
15570    }
15571
15572    /// Set one key of one design-document issue's metadata, on exactly the terms of
15573    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15574    async fn set_document_metadata(
15575        &self,
15576        id: &NativeId,
15577        key: &MetadataKey,
15578        value: &Value,
15579    ) -> Result<Option<Document>, SourceError> {
15580        Ok(self
15581            .set_slot_key(id, BoardKind::Document, key, value)
15582            .await?
15583            .map(|item| item.document()))
15584    }
15585
15586    /// Take one key out of one issue's metadata slot with a single body update that changes the
15587    /// slot and nothing outside it, sending nothing when the slot does not hold the key.
15588    async fn remove_record_metadata(
15589        &self,
15590        record: onetaskgraph_plugin_api::MetadataRecord,
15591        id: &NativeId,
15592        key: &MetadataKey,
15593    ) -> Result<Option<Option<String>>, SourceError> {
15594        let kind = match record {
15595            onetaskgraph_plugin_api::MetadataRecord::Task => BoardKind::Work(ItemKind::Task),
15596            onetaskgraph_plugin_api::MetadataRecord::Project => BoardKind::Work(ItemKind::Project),
15597            onetaskgraph_plugin_api::MetadataRecord::Document => BoardKind::Document,
15598        };
15599        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
15600            return Ok(None);
15601        };
15602        if item.slot.contains_key(key.as_str()) {
15603            let mut slot = item.slot.clone();
15604            slot.remove(key.as_str());
15605            self.write_slot(&mut item, &slot).await?;
15606            self.remember_written(item.clone(), false)?;
15607        }
15608        Ok(Some(match record {
15609            onetaskgraph_plugin_api::MetadataRecord::Task => item.task()?.version,
15610            onetaskgraph_plugin_api::MetadataRecord::Project => item.project().version,
15611            onetaskgraph_plugin_api::MetadataRecord::Document => item.document().version,
15612        }))
15613    }
15614
15615    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
15616        self.delete_item(id).await
15617    }
15618
15619    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
15620        self.projectless()?;
15621        self.delete_item(id).await
15622    }
15623
15624    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
15625        self.delete_item(id).await
15626    }
15627
15628    /// One page of the task issue's own comments, walked by GitHub's own cursor.
15629    ///
15630    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
15631    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
15632    ///
15633    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
15634    /// board is the read of its comments. A draft this process already resolved is refused
15635    /// without one.
15636    async fn task_comments(
15637        &self,
15638        task: &NativeId,
15639        page: &PageRequest,
15640    ) -> Result<Option<Page<Comment>>, SourceError> {
15641        validate_page(page)?;
15642        let cached = self.resolved_cache()?.get(task).cloned();
15643        if let Some(item) = cached {
15644            if item.kind != BoardKind::Work(ItemKind::Task) {
15645                return Ok(None);
15646            }
15647            if item.content_kind == ContentKind::DraftIssue {
15648                return Err(self.draft_has_no_comments(task));
15649            }
15650        }
15651        // The first page a comment-activity read just read of this task, at the size asked, is
15652        // that read's answer: taken, so it answers once.
15653        if page.cursor.is_none()
15654            && page.limit >= MAX_PAGE_SIZE
15655            && let Some(held) = self.held_comments()?.remove(task)
15656        {
15657            return Ok(Some(held));
15658        }
15659        match self.issue_detail(task, page).await? {
15660            Some(TaskDetailRead {
15661                comments: Some(comments),
15662                ..
15663            }) => comments,
15664            _ => Ok(None),
15665        }
15666    }
15667
15668    /// Every id's task, with the first page of its comments when `comments` names it:
15669    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
15670    /// comments in one [`graphql::ISSUE_DETAIL`] request.
15671    async fn get_task_details(
15672        &self,
15673        ids: &[NativeId],
15674        comments: Option<&PageRequest>,
15675    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15676        if let Some(page) = comments
15677            && let Err(error) = validate_page(page)
15678        {
15679            return ids.iter().map(|_| Err(error.clone())).collect();
15680        }
15681        match (ids, comments) {
15682            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
15683            ([id], None) => vec![self.task_read(id).await],
15684            _ => self.issue_details(ids, comments).await,
15685        }
15686    }
15687
15688    /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
15689    /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
15690    /// request, so the dependency read a copy makes of each next is answered from this one.
15691    async fn get_task_details_for_copy(
15692        &self,
15693        ids: &[NativeId],
15694        comments: &PageRequest,
15695    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15696        // A page past the first is a page of one task's comments, which the detail read of it
15697        // alone answers.
15698        if comments.cursor.is_some() {
15699            return self.get_task_details(ids, Some(comments)).await;
15700        }
15701        if let Err(error) = validate_page(comments) {
15702            return ids.iter().map(|_| Err(error.clone())).collect();
15703        }
15704        let mut read = Vec::with_capacity(ids.len());
15705        for batch in ids.chunks(COPY_DETAIL_BATCH) {
15706            match self
15707                .graphql(
15708                    graphql::ISSUE_COPY_DETAILS,
15709                    copy_detail_batch(batch, comments, false),
15710                )
15711                .await
15712            {
15713                Ok(data) => {
15714                    for (slot, id) in batch.iter().enumerate() {
15715                        let read_one = match data.get(format!("i{slot}")) {
15716                            Some(node) => self.detail_of(id, node, true, None).await,
15717                            None => Err(SourceError::Malformed {
15718                                message: format!(
15719                                    "GitHub answered a batch read with no item for {}",
15720                                    id.0
15721                                ),
15722                            }),
15723                        };
15724                        read.push(read_one);
15725                    }
15726                }
15727                Err(error) if unresolvable_node(&error) => {
15728                    for id in batch {
15729                        read.push(self.issue_detail(id, comments).await);
15730                    }
15731                }
15732                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
15733            }
15734        }
15735        read
15736    }
15737
15738    /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
15739    /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
15740    /// and held for the next read of each by id; see `read_ahead`.
15741    ///
15742    /// What this run wrote is never read: its own record answers, as it does for a read by id.
15743    /// Nor is an item a listing of its project carried whole earlier in this command — see
15744    /// `carried` — which is held in place of reading it.
15745    /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
15746    /// so each of its ids is read on its own and answered as that read answers; a draft, and an
15747    /// answer naming another issue than the one asked for, are left to their own reads too.
15748    async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
15749        // The origins asked just before go out now, beside the members of the one project this
15750        // names when it names one; a project those origins answered whole is held for its next
15751        // read by id rather than read again here.
15752        let hinted = match ids {
15753            [one] => Some(one),
15754            _ => None,
15755        };
15756        self.send_pending_origins(hinted).await?;
15757        if let Some(project) = hinted {
15758            let answered = self.resolved_cache()?.get(project).cloned();
15759            if let Some(answered) = answered
15760                && answered.kind == BoardKind::Work(ItemKind::Project)
15761            {
15762                self.read_ahead()?.insert(project.clone(), Some(answered));
15763                return Ok(());
15764            }
15765        }
15766        let mut unread: Vec<NativeId> = Vec::new();
15767        // Read whole by a listing of its project in this command, which a mutation naming it
15768        // would have dropped: held as a batch would have held it, once every batch is answered.
15769        let mut listed: Vec<NativeId> = Vec::new();
15770        {
15771            let created = self.created()?;
15772            let held = self.read_ahead()?;
15773            let carried = self.carried()?;
15774            for id in ids {
15775                if unread.contains(id)
15776                    || listed.contains(id)
15777                    || held.contains_key(id)
15778                    || created.iter().any(|own| own.id == *id)
15779                {
15780                    continue;
15781                }
15782                // An issue a listing carried without the whole of what blocks it is read in a
15783                // batch too, which carries that, rather than having its dependency read sent
15784                // on its own once the copy reconciles its edges.
15785                let whole = carried.get(id).is_some_and(|item| {
15786                    item.content_kind != ContentKind::Issue || item.blocked_by.is_some()
15787                });
15788                if whole {
15789                    listed.push(id.clone());
15790                } else {
15791                    unread.push(id.clone());
15792                }
15793            }
15794        }
15795        // Nothing of an item's comments is wanted here: the smallest page keeps the read to
15796        // what the item itself costs.
15797        let comments = PageRequest {
15798            cursor: None,
15799            limit: 1,
15800        };
15801        // Held only once every batch has been answered, so a read-ahead that fails part way
15802        // holds nothing — what a listing carried included, which stays carried — and every
15803        // item is read on its own, as the method promises.
15804        let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
15805        // The issues the first id names as sub-issues, as `ISSUE_COPY_DETAILS`'s `children`
15806        // answered them: what a project copy's orphan check asks, when that first id is its
15807        // project.
15808        let mut children: Option<(NativeId, Vec<(String, String)>)> = None;
15809        for (index, batch) in unread.chunks(COPY_DETAIL_BATCH).enumerate() {
15810            let data = match self
15811                .graphql(
15812                    graphql::ISSUE_COPY_DETAILS,
15813                    copy_detail_batch(batch, &comments, index == 0),
15814                )
15815                .await
15816            {
15817                Ok(data) => data,
15818                Err(error) if unresolvable_node(&error) => continue,
15819                Err(error) => return Err(error),
15820            };
15821            for (slot, id) in batch.iter().enumerate() {
15822                let Some(node) = data.get(format!("i{slot}")) else {
15823                    continue;
15824                };
15825                let held = if node.is_null() {
15826                    None
15827                } else {
15828                    match optional_str(node, "__typename")? {
15829                        Some("DraftIssue") => continue,
15830                        Some("Issue") if required_str(node, "id")? != id.0 => continue,
15831                        _ => {}
15832                    }
15833                    // Remembered below with the rest, so a later batch refused leaves no
15834                    // dependency read answered from this one either.
15835                    self.resolve_issue_as(node, false).await?
15836                };
15837                read.push((id.clone(), held));
15838            }
15839            if index == 0
15840                && let Some(node) = data.get("children").filter(|node| !node.is_null())
15841                && node.get("id").and_then(Value::as_str) == batch.first().map(|id| id.0.as_str())
15842                && node.pointer("/subIssues/pageInfo/hasNextPage") == Some(&Value::Bool(false))
15843            {
15844                // A list GitHub did not answer is not an empty one: it would answer the project's
15845                // task listing with nothing, and the orphan check with no orphans.
15846                let listed = node
15847                    .pointer("/subIssues/nodes")
15848                    .and_then(Value::as_array)
15849                    .ok_or_else(|| SourceError::Malformed {
15850                        message: format!(
15851                            "GitHub answered the sub-issues of project {} with no list of them",
15852                            batch[0].0
15853                        ),
15854                    })?
15855                    .iter()
15856                    .map(|child| {
15857                        Ok((
15858                            required_str(child, "id")?.to_owned(),
15859                            required_str(child, "title")?.to_owned(),
15860                        ))
15861                    })
15862                    .collect::<Result<Vec<_>, SourceError>>()?;
15863                children = Some((batch[0].clone(), listed));
15864            }
15865        }
15866        // The project's tasks are known from this read alone when every child it named is held
15867        // from it, but for a document, which its title says is one and which a task listing
15868        // never answers: then a listing of the project's tasks is answered from them.
15869        if let Some((project, listed)) = children
15870            && read.iter().any(|(id, held)| {
15871                *id == project
15872                    && held
15873                        .as_ref()
15874                        .is_some_and(|held| held.kind == BoardKind::Work(ItemKind::Project))
15875            })
15876        {
15877            let mut tasks = Vec::new();
15878            let mut whole = true;
15879            for (child, title) in &listed {
15880                match read.iter().find(|(id, _)| id.0 == *child) {
15881                    Some((_, Some(held))) if held.parent.as_ref() == Some(&project) => {
15882                        if held.kind == BoardKind::Work(ItemKind::Task) {
15883                            tasks.push(held.clone());
15884                        }
15885                    }
15886                    _ if title.starts_with(DESIGN_TITLE_PREFIX) => {}
15887                    _ => whole = false,
15888                }
15889            }
15890            if whole {
15891                self.task_children()?.insert(project, tasks);
15892            }
15893        }
15894        let mut resolved = self.resolved_cache()?;
15895        let mut ahead = self.read_ahead()?;
15896        let mut carried = self.carried()?;
15897        for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
15898            resolved.insert(item.id.clone(), item.clone());
15899        }
15900        // Taken only if still carried: a mutation naming it while the batches ran dropped it,
15901        // and its next read is then its own.
15902        for id in &listed {
15903            let Some(item) = carried.remove(id) else {
15904                continue;
15905            };
15906            // The whole of what it is blocked by came with it, so the dependency read a copy
15907            // makes next is answered from this one, as a copy's detail read is.
15908            if item.blocked_by.is_some() {
15909                resolved.insert(item.id.clone(), item.clone());
15910            }
15911            read.push((id.clone(), Some(item)));
15912        }
15913        for (id, held) in read {
15914            ahead.insert(id, held);
15915        }
15916        Ok(())
15917    }
15918
15919    /// Add one comment to the task's issue, as the account the token belongs to.
15920    ///
15921    /// The author is refused before anything is sent — not even the task is read — because
15922    /// no answer GitHub could give would make posting under another name than the one asked
15923    /// for the right outcome.
15924    async fn add_comment(
15925        &self,
15926        task: &NativeId,
15927        comment: &NewComment,
15928    ) -> Result<Option<Comment>, SourceError> {
15929        if let Some(author) = &comment.author {
15930            return Err(SourceError::Refused {
15931                message: format!(
15932                    "source {} cannot post a comment as {author:?}: GitHub records the account \
15933                     the token signs in as the author of every comment; next: leave --author \
15934                     out, and the comment is posted as that account",
15935                    self.name
15936                ),
15937            });
15938        }
15939        let Some(issue) = self.commented_issue(task).await? else {
15940            return Ok(None);
15941        };
15942        let data = self
15943            .graphql(
15944                graphql::ADD_COMMENT,
15945                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
15946            )
15947            .await?;
15948        let subject = data
15949            .pointer("/addComment/subject")
15950            .filter(|value| !value.is_null())
15951            .ok_or_else(|| SourceError::Malformed {
15952                message: "GitHub comment addition returned no subject".into(),
15953            })?;
15954        if required_str(subject, "id")? != issue.0 {
15955            return Err(SourceError::Malformed {
15956                message: "GitHub comment addition answered about another issue".into(),
15957            });
15958        }
15959        let added = data
15960            .pointer("/addComment/commentEdge/node")
15961            .filter(|value| !value.is_null())
15962            .ok_or_else(|| SourceError::Malformed {
15963                message: "GitHub comment addition returned no comment".into(),
15964            })?;
15965        let added = comment_from(added)?;
15966        self.remember_commented(&issue)?;
15967        Ok(Some(added))
15968    }
15969
15970    /// Every task commented on read together — [`DETAIL_BATCH`] to a
15971    /// [`graphql::ISSUE_DETAILS`] request — and each comment's hold decided on that read; then
15972    /// every comment it lets through added by aliased `addComment`s of one
15973    /// [`graphql::ADD_COMMENTS`] request per [`COMMENT_BATCH`]. GitHub answers a refusal of one
15974    /// alias at that alias's path, so it fails that comment alone.
15975    ///
15976    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15977    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15978    /// that batch is read by itself, the missing one answered missing. A batch of one is the
15979    /// comment alone, as [`Self::add_comment`] adds it.
15980    async fn add_comments(
15981        &self,
15982        comments: &[onetaskgraph_plugin_api::BatchComment],
15983    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<Comment>, SourceError>> {
15984        self.comments_batched(comments).await
15985    }
15986
15987    async fn edit_comment(
15988        &self,
15989        task: &NativeId,
15990        comment: &NativeId,
15991        body: &CommentBody,
15992    ) -> Result<Option<Comment>, SourceError> {
15993        let Some(issue) = self.commented_issue(task).await? else {
15994            return Ok(None);
15995        };
15996        let Some(held) = self.comment_on(&issue, comment).await? else {
15997            return Ok(None);
15998        };
15999        // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
16000        let body = CommentOrigin::kept(&held, body)?;
16001        let data = self
16002            .graphql(
16003                graphql::UPDATE_COMMENT,
16004                json!({"input":{"id":comment.0,"body":body.as_str()}}),
16005            )
16006            .await?;
16007        let edited = data
16008            .pointer("/updateIssueComment/issueComment")
16009            .filter(|value| !value.is_null())
16010            .ok_or_else(|| SourceError::Malformed {
16011                message: "GitHub comment update returned no comment".into(),
16012            })?;
16013        let edited = comment_from(edited)?;
16014        if edited.id != *comment {
16015            return Err(SourceError::Malformed {
16016                message: "GitHub comment update returned the wrong comment".into(),
16017            });
16018        }
16019        self.remember_commented(&issue)?;
16020        Ok(Some(edited))
16021    }
16022
16023    async fn delete_comment(
16024        &self,
16025        task: &NativeId,
16026        comment: &NativeId,
16027    ) -> Result<Option<NativeId>, SourceError> {
16028        let Some(issue) = self.commented_issue(task).await? else {
16029            return Ok(None);
16030        };
16031        if !self.comment_is_on(&issue, comment).await? {
16032            return Ok(None);
16033        }
16034        self.held_comments()?.remove(&issue);
16035        let data = self
16036            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
16037            .await?;
16038        // The payload says nothing about the comment it removed, so what is checked is that
16039        // GitHub answered the mutation at all rather than leaving it unanswered.
16040        data.get("deleteIssueComment")
16041            .filter(|value| !value.is_null())
16042            .ok_or_else(|| SourceError::Malformed {
16043                message: "GitHub comment deletion returned no payload".into(),
16044            })?;
16045        Ok(Some(comment.clone()))
16046    }
16047
16048    /// Every request this source has recorded, and what each of GitHub's two budgets was
16049    /// attributed — read off the same accounting the session report is rendered from, so
16050    /// the two cannot count one request two ways.
16051    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
16052        Ok(Some(self.ledger.snapshot().metering()))
16053    }
16054
16055    /// Drop every item, search answer and board read this source holds, so the next command
16056    /// reads the board as a person has since left it.
16057    ///
16058    /// Every one of those is held on the assumption that nothing but this source writes the
16059    /// board while a command runs, which stops being true the moment the command is over: a
16060    /// body a person edited would be overwritten from the record held here, and a card they
16061    /// moved would be read as still where this source left it. The board's own field
16062    /// definitions go too, because a person can add or delete a `Status` option and a write
16063    /// resolved against the held list would not re-read on a miss. What stays is what stays
16064    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
16065    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
16066    /// accounting [`metering`](TaskSource::metering) answers from.
16067    ///
16068    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
16069    /// refused, because clearing it is what puts it right.
16070    async fn end_command(&self) -> Result<(), SourceError> {
16071        fn clear<T: Default>(held: &Mutex<T>) {
16072            *held
16073                .lock()
16074                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
16075            held.clear_poison();
16076        }
16077        clear(&self.created);
16078        clear(&self.updated);
16079        clear(&self.commented);
16080        clear(&self.board_cache);
16081        clear(&self.search_cache);
16082        clear(&self.narrowed_cache);
16083        clear(&self.search_next);
16084        clear(&self.resolved_cache);
16085        clear(&self.read_ahead);
16086        clear(&self.pending_origins);
16087        clear(&self.member_classes);
16088        clear(&self.carried);
16089        clear(&self.held_cache);
16090        clear(&self.children_cache);
16091        clear(&self.task_children);
16092        clear(&self.written);
16093        clear(&self.fields_cache);
16094        clear(&self.held_comments);
16095        clear(&self.task_writes);
16096        clear(&self.written_tokens);
16097        self.expected.end_command();
16098        Ok(())
16099    }
16100}
16101
16102/// One member of a project as a classification-only read answers it: its classification and
16103/// the repositories its work changes.
16104type MemberClass = (Classification, Vec<Repository>);
16105
16106/// Each project's sub-issues as one command read them, keyed by the selector they were asked
16107/// for under, beside the project that selector named; see `children_cache`.
16108type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
16109
16110/// One issue comment as the contract carries it.
16111///
16112/// `author` is absent both when GitHub answers `null` for an account that no longer exists
16113/// and when it answers an actor with no login, because either way the source did not say who
16114/// wrote it — which is what an absent author means, rather than an author called nothing.
16115///
16116/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
16117/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
16118/// and is read back with the marker taken out and reported as its origin.
16119fn comment_from(value: &Value) -> Result<Comment, SourceError> {
16120    let actor = value.get("author").unwrap_or(&Value::Null);
16121    // A bot's login is spelled as GitHub's own pages spell it, `<login>[bot]`, so a reader —
16122    // and every copy of the comment — can tell it from a person's.
16123    let bot = actor.get("__typename").and_then(Value::as_str) == Some("Bot");
16124    Ok(Comment {
16125        origin: None,
16126        id: NativeId(required_str(value, "id")?.to_owned()),
16127        author: optional_str(actor, "login")?.map(|login| {
16128            if bot && !login.ends_with("[bot]") {
16129                format!("{login}[bot]")
16130            } else {
16131                login.to_owned()
16132            }
16133        }),
16134        created_at: optional_time(value, "createdAt")?,
16135        updated_at: optional_time(value, "updatedAt")?,
16136        body: required_str(value, "body")?.to_owned(),
16137        url: optional_str(value, "url")?.map(str::to_owned),
16138    }
16139    .unmarked())
16140}
16141
16142/// The page of comments one issue node carries, resumed from `after`.
16143fn comment_page(
16144    node: &Value,
16145    issue: &str,
16146    after: Option<&str>,
16147) -> Result<Page<Comment>, SourceError> {
16148    let connection = node
16149        .get("comments")
16150        .filter(|value| !value.is_null())
16151        .ok_or_else(|| SourceError::Malformed {
16152            message: format!("GitHub issue {issue} answered with no comments connection"),
16153        })?;
16154    let items = optional_nodes(Some(connection), "issue comments")?
16155        .into_iter()
16156        .flatten()
16157        .map(comment_from)
16158        .collect::<Result<Vec<_>, _>>()?;
16159    let next = next_cursor(connection)?;
16160    if let Some(next) = &next {
16161        validate_cursor_progress(after, &next.0)?;
16162    }
16163    Ok(Page { items, next })
16164}
16165
16166/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
16167/// end — `None` when it carried none, or a page with more past it.
16168fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
16169    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
16170        return Ok(None);
16171    };
16172    if next_cursor(connection)?.is_some() {
16173        return Ok(None);
16174    }
16175    Ok(Some(
16176        optional_nodes(Some(connection), "blocked-by issues")?
16177            .into_iter()
16178            .flatten()
16179            .cloned()
16180            .collect(),
16181    ))
16182}
16183
16184/// Where the recorded tail of a dependency walk resumes; see
16185/// [`GitHubProjectsSource::recorded_edges`].
16186const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
16187
16188/// The board text field this source keeps a copy's origin in.
16189///
16190/// Named after the key it holds, and held to that name by the guard below rather than by
16191/// a reader noticing.
16192const ORIGIN_FIELD: &str = "onetaskgraph.origin";
16193
16194/// The metadata key that field holds.
16195///
16196/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
16197/// constructs or interprets the qualified id it carries. This source names it only to
16198/// route it — a short, typed value belongs in a typed field rather than in the body slot
16199/// a caller's own prose shares.
16200///
16201/// Restated rather than imported, because no plugin crate may depend on the engine. What
16202/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
16203/// target in `check`: it reads the engine's own literal and fails naming the file and the
16204/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
16205/// that creates a second item every run instead of finding the one it wrote — and that is
16206/// too late to learn it.
16207const ORIGIN_KEY: &str = "onetaskgraph.origin";
16208
16209/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
16210///
16211/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
16212/// is derived from the far end, never written down on the near item — so only a forward
16213/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
16214/// it did not come from, and it is told so rather than answered with an empty page that
16215/// reads as a walk which ended.
16216fn recorded_offset(
16217    cursor: Option<&str>,
16218    direction: Direction,
16219) -> Result<Option<usize>, SourceError> {
16220    cursor
16221        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
16222        .map(|offset| {
16223            if direction != Direction::DependsOn {
16224                return Err(SourceError::Config {
16225                    message: format!(
16226                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
16227                         reverse dependency read never issues; resume it in the direction \
16228                         that reported it"
16229                    ),
16230                });
16231            }
16232            offset.parse().map_err(|_| SourceError::Config {
16233                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
16234            })
16235        })
16236        .transpose()
16237}
16238
16239/// What a dependency read's own answer says of a board draft: `None` when the item is not a
16240/// draft, or the answer did not carry its board item; `Some(None)` for a draft whose one board
16241/// item is not on this board, which this source does not hold; and `Some(Some(slot))`, the
16242/// draft's metadata, for one it does — its board item naming this board by its number and by
16243/// the id the same answer read of the board, as [`GitHubProjectsSource::draft_by_id`] decides.
16244fn draft_slot(
16245    data: &Value,
16246    id: &NativeId,
16247    project_number: u32,
16248) -> Result<Option<Option<BTreeMap<String, Value>>>, SourceError> {
16249    let Some(draft) = data
16250        .get("node")
16251        .filter(|node| node.get("__typename").and_then(Value::as_str) == Some("DraftIssue"))
16252    else {
16253        return Ok(None);
16254    };
16255    let Some(items) = data.pointer("/draft/projectV2Items") else {
16256        return Ok(None);
16257    };
16258    if required_str(draft, "id")? != id.0 {
16259        return Err(SourceError::Malformed {
16260            message: format!("GitHub answered a different draft for {}", id.0),
16261        });
16262    }
16263    let nodes = items
16264        .get("nodes")
16265        .and_then(Value::as_array)
16266        .ok_or_else(|| SourceError::Malformed {
16267            message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
16268        })?;
16269    let info = items
16270        .get("pageInfo")
16271        .ok_or_else(|| SourceError::Malformed {
16272            message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
16273        })?;
16274    if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
16275        return Err(SourceError::Malformed {
16276            message: format!(
16277                "GitHub draft {} reports more board items than the one GitHub links a draft to",
16278                id.0
16279            ),
16280        });
16281    }
16282    let board = data
16283        .pointer("/board/projectV2/id")
16284        .and_then(Value::as_str)
16285        .ok_or_else(|| SourceError::Malformed {
16286            message: format!(
16287                "GitHub answered draft {} without this board's own id beside it",
16288                id.0
16289            ),
16290        })?;
16291    let mut held = false;
16292    for node in nodes {
16293        let project = node.get("project").ok_or_else(|| SourceError::Malformed {
16294            message: format!("GitHub draft {} board item has no project", id.0),
16295        })?;
16296        let number = project
16297            .get("number")
16298            .and_then(Value::as_u64)
16299            .ok_or_else(|| SourceError::Malformed {
16300                message: format!(
16301                    "GitHub draft {} board item has no numeric project number",
16302                    id.0
16303                ),
16304            })?;
16305        let project_id = required_str(project, "id")?;
16306        held |= number == u64::from(project_number) && project_id == board;
16307    }
16308    if !held {
16309        return Ok(Some(None));
16310    }
16311    Ok(Some(Some(
16312        metadata_body(optional_str(draft, "body")?.map(str::to_owned))?.1,
16313    )))
16314}
16315
16316fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
16317    let mut page = offset_page(edges, offset, limit.max(1));
16318    page.next = page
16319        .next
16320        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
16321    page
16322}
16323
16324/// The kind of one issue reached through a dependency connection.
16325///
16326/// The same questions the board scan asks, over the fields the dependency document
16327/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
16328/// then anything with sub-issues or the marker is a project.
16329///
16330/// # Errors
16331///
16332/// A far end this board holds as a document is refused rather than reported. The two
16333/// answers that are not refusals would both be wrong: reporting it as a task names an id
16334/// no task read of this source can find, and reporting it as a project names one no
16335/// project read can. There is no third value to return — `ItemKind` has no document
16336/// variant, because nothing may point at a document — so the relationship itself is what
16337/// the person is told about.
16338fn related_kind(value: &Value, meaning: SubIssues) -> Result<ItemKind, SourceError> {
16339    let id = required_str(value, "id")?;
16340    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
16341        return Err(SourceError::Refused {
16342            message: format!(
16343                "GitHub issue {id} is a document of this board — its title begins \
16344                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
16345                 on by one; next: remove that issue's blocking relationship on this board"
16346            ),
16347        });
16348    }
16349    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
16350    if parent.is_some() || meaning == SubIssues::Parent {
16351        return Ok(ItemKind::Task);
16352    }
16353    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
16354    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
16355        message: format!("GitHub issue {id}: {message}"),
16356    })?;
16357    let sub_issues = sub_issue_total(value)?;
16358    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
16359        ItemKind::Project
16360    } else {
16361        ItemKind::Task
16362    })
16363}
16364
16365/// The `stateInput` that closes an issue for `reason`.
16366fn closed_input(reason: ClosedState) -> Value {
16367    json!({"value":"CLOSED","stateReason":reason.reason()})
16368}
16369
16370/// The `IssueStateUpdateInput` one status target asks for.
16371///
16372/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
16373/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
16374/// a currently-closed issue: without that the item would read back `Unknown` and a copy
16375/// would report a change forever. A document has no status at all, and asks for neither.
16376fn state_input(target: Option<&StatusTarget>) -> Value {
16377    match target {
16378        Some(StatusTarget::Terminal(_, reason)) => closed_input(*reason),
16379        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
16380        // A document has no status, so a write of one says nothing about the issue's open
16381        // or closed state rather than forcing it open: `stateInput` is what carries that
16382        // instruction, and an explicit null asks for no change to it.
16383        None => Value::Null,
16384    }
16385}
16386
16387/// The metadata one write stores in the item's body slot.
16388///
16389/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
16390/// rather than carried: the kind marker so an empty project stays readable, the
16391/// repository list only when it is not exactly the issue's own repository, and the far
16392/// ends no relationship here can name.
16393///
16394/// The copy origin is the one typed field that is also mirrored here, and only as a
16395/// mirror: it lands in the board's origin field as well, which stays the one every reader
16396/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
16397/// and catches up with a write in seconds rather than minutes — can find the item by it.
16398/// A reader of the release before this one drops the slot's copy and reads the field, so an
16399/// item written here still reads with exactly one origin there.
16400fn slot_metadata(
16401    incoming: &Incoming<'_>,
16402    own_repository: Option<&Repository>,
16403    fallback: &[DependencyEdge],
16404) -> BTreeMap<String, Value> {
16405    let mut metadata = incoming.metadata.clone();
16406    match metadata.remove(ORIGIN_KEY) {
16407        Some(Value::String(origin)) if !origin.is_empty() => {
16408            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
16409        }
16410        _ => {}
16411    }
16412    match incoming.written.kind() {
16413        BoardKind::Work(kind) => metadata.insert(
16414            ItemKind::METADATA_KEY.to_owned(),
16415            Value::String(kind.marker().to_owned()),
16416        ),
16417        // A document is told by its title, so it carries no kind marker: that key names
16418        // what a dependency endpoint points at, and nothing may point at a document.
16419        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
16420    };
16421    incoming.classification.record(&mut metadata);
16422    let derivable = own_repository
16423        .map(|own| incoming.repositories == [own.clone()])
16424        .unwrap_or(incoming.repositories.is_empty());
16425    if derivable {
16426        metadata.remove(Repository::METADATA_KEY);
16427    } else {
16428        metadata.insert(
16429            Repository::METADATA_KEY.to_owned(),
16430            Value::Array(
16431                incoming
16432                    .repositories
16433                    .iter()
16434                    .map(|repository| Value::String(repository.as_str().to_owned()))
16435                    .collect(),
16436            ),
16437        );
16438    }
16439    // The typed lists are what land, whatever the caller's own metadata held under their
16440    // keys: a key of either name travelling beside the field would otherwise be a second
16441    // answer to the same question, and the field is the one the contract names.
16442    for (key, entries) in [
16443        (TaskRef::DELIVERS_KEY, incoming.delivers),
16444        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
16445    ] {
16446        set_task_list(&mut metadata, key, entries);
16447    }
16448    record_edges(&mut metadata, fallback);
16449    metadata
16450}
16451
16452/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
16453/// one slot's metadata, or no such key when there are none.
16454fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
16455    if fallback.is_empty() {
16456        metadata.remove(DependencyEdge::RECORDED_KEY);
16457    } else {
16458        metadata.insert(
16459            DependencyEdge::RECORDED_KEY.to_owned(),
16460            Value::Array(
16461                fallback
16462                    .iter()
16463                    .map(|edge| {
16464                        serde_json::to_value(&edge.to).expect("an endpoint renders as JSON")
16465                    })
16466                    .collect(),
16467            ),
16468        );
16469    }
16470}
16471
16472/// Every label one item carries, from its content's own connection and nowhere else.
16473///
16474/// There is no second place to read one from: no document this source sends selects the
16475/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
16476/// cannot carry one at all. The module documentation records the three schema facts that
16477/// settle it.
16478fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
16479    optional_nodes(content.get("labels"), "content labels")?
16480        .into_iter()
16481        .flatten()
16482        .map(|v| {
16483            Ok(Label {
16484                id: NativeId(required_str(v, "id")?.to_owned()),
16485                name: required_str(v, "name")?.to_owned(),
16486                color: optional_str(v, "color")?.map(str::to_owned),
16487            })
16488        })
16489        .collect()
16490}
16491
16492/// Each board field value one item holds, as the change token's facets name it: the field's
16493/// name, with the single-select option's name or the text. A value of a type no read here
16494/// selects, and one naming no field, says nothing a person can rename and is left out.
16495fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
16496    field_values
16497        .iter()
16498        .filter_map(|value| {
16499            let field = value.pointer("/field/name")?.as_str()?;
16500            let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
16501            Some((field.to_owned(), held.to_owned()))
16502        })
16503        .collect()
16504}
16505
16506/// The digest of the facets of the issue `content`, whose board item's values are
16507/// `field_values` — or `None` when the response did not carry, whole, every member they are read
16508/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
16509/// label, and every field value.
16510///
16511/// What a write's own response spells its token from, by exactly the rules a read takes them by.
16512fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
16513    let ended = |connection: &Value| {
16514        connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
16515    };
16516    let labels = content.get("labels").filter(|labels| ended(labels))?;
16517    if !ended(field_values) {
16518        return None;
16519    }
16520    let parent = content.get("parent")?;
16521    let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
16522    let kind = BoardKind::of(
16523        content.get("title")?.as_str()?,
16524        !parent.is_null(),
16525        sub_issue_total(content).ok()?,
16526        ItemKind::from_metadata(&slot).ok()?,
16527    );
16528    let labels = self::labels(&json!({"labels": labels})).ok()?;
16529    let nodes = field_values.get("nodes")?.as_array()?;
16530    Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
16531}
16532
16533/// The definition of each board field one item's values are values of, in the shape a read
16534/// of the board's own `fields` gives one.
16535///
16536/// A value names its field through a fragment on that field's own type, so the type is
16537/// known from which kind of value it is: a single-select value's field is a
16538/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
16539/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
16540fn field_definitions(field_values: &[Value]) -> Vec<Value> {
16541    field_values
16542        .iter()
16543        .filter_map(|value| {
16544            let field = value.get("field")?.as_object()?;
16545            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
16546            let typename = if value.get("text").is_some() {
16547                "ProjectV2Field"
16548            } else if value.get("name").is_some() {
16549                "ProjectV2SingleSelectField"
16550            } else {
16551                return None;
16552            };
16553            let mut defined = field.clone();
16554            defined.insert("__typename".to_owned(), json!(typename));
16555            // Only a text field holds a text value, so the value says the field's type too.
16556            if typename == "ProjectV2Field" {
16557                defined.insert("dataType".to_owned(), json!("TEXT"));
16558            }
16559            Some(Value::Object(defined))
16560        })
16561        .collect()
16562}
16563
16564fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
16565    let Some(node) = field_values
16566        .iter()
16567        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
16568    else {
16569        return Ok(None);
16570    };
16571    Ok(optional_str(node, "text")?.map(str::to_owned))
16572}
16573
16574fn valid_github_owner(owner: &str) -> bool {
16575    !owner.is_empty()
16576        && owner.len() <= 39
16577        && !owner.starts_with('-')
16578        && !owner.ends_with('-')
16579        && !owner.contains("--")
16580        && owner
16581            .bytes()
16582            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
16583}
16584
16585/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
16586/// neither of the two names a path segment already means.
16587fn valid_github_repository_name(name: &str) -> bool {
16588    !name.is_empty()
16589        && name.len() <= 100
16590        && name != "."
16591        && name != ".."
16592        && name
16593            .bytes()
16594            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
16595}
16596
16597fn valid_environment_name(name: &str) -> bool {
16598    let mut bytes = name.bytes();
16599    bytes
16600        .next()
16601        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
16602        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
16603}
16604
16605/// How many sub-issues one issue has.
16606///
16607/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
16608/// absent or non-integer one is a response this source cannot read — and reading it as
16609/// zero would classify a project as a task, which is exactly the mistake the marker
16610/// exists to keep from happening quietly.
16611fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
16612    let summary = issue
16613        .get("subIssuesSummary")
16614        .ok_or_else(|| SourceError::Malformed {
16615            message: "GitHub issue is missing subIssuesSummary".into(),
16616        })?;
16617    summary
16618        .get("total")
16619        .and_then(Value::as_u64)
16620        .ok_or_else(|| SourceError::Malformed {
16621            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
16622        })
16623}
16624
16625/// One issue's own `number`.
16626///
16627/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
16628/// an issue in this module asks for it. So a read of one that comes back without it, or
16629/// with something that is not an unsigned integer, is a response this source cannot read —
16630/// absence here is **not** "this issue has no number". A draft is the content that has
16631/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
16632/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
16633fn issue_number(issue: &Value) -> Result<u64, SourceError> {
16634    issue
16635        .get("number")
16636        .and_then(Value::as_u64)
16637        .ok_or_else(|| SourceError::Malformed {
16638            message: "GitHub issue number is missing or is not an unsigned integer".into(),
16639        })
16640}
16641
16642/// The `number` a creating mutation answered with, and `None` when it answered without one;
16643/// why a missing one is tolerated is at the call in `create_and_file_issue`.
16644fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
16645    match created.get("number") {
16646        None | Some(Value::Null) => Ok(None),
16647        Some(value) => value
16648            .as_u64()
16649            .map(Some)
16650            .ok_or_else(|| SourceError::Malformed {
16651                message: "GitHub created issue number is not an unsigned integer".into(),
16652            }),
16653    }
16654}
16655
16656fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16657    value
16658        .get(field)
16659        .and_then(Value::as_str)
16660        .ok_or_else(|| SourceError::Malformed {
16661            message: format!("GitHub response is missing string field {field}"),
16662        })
16663}
16664
16665fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16666    let found = required_str(value, field)?;
16667    if found.trim().is_empty() {
16668        return Err(SourceError::Malformed {
16669            message: format!("GitHub response has blank string field {field}"),
16670        });
16671    }
16672    Ok(found)
16673}
16674
16675/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
16676/// needs one — Linear spells them too, in its own description field.
16677///
16678/// Restated rather than shared, because a plugin crate depends on the contract crate and
16679/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
16680/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
16681/// source round-trips its own writes perfectly well under its own spelling.
16682const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
16683const METADATA_CLOSE: &str = "\n-->";
16684
16685/// What the composer puts between a non-empty visible body and the slot, and the one thing
16686/// the parser takes off the visible body when it takes the slot off — exactly once, so every
16687/// other trailing byte of the body comes back as it was written.
16688// 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.
16689const METADATA_SEPARATOR: &str = "\n\n";
16690
16691/// The visible body and the metadata slot at the end of it.
16692///
16693/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
16694/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
16695/// own content and is left alone. The visible body is everything before the slot less the
16696/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
16697fn metadata_body(
16698    body: Option<String>,
16699) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
16700    let Some(body) = body else {
16701        return Ok((None, BTreeMap::new()));
16702    };
16703    let Some(slot) = slot_span(&body)? else {
16704        return Ok((Some(body), BTreeMap::new()));
16705    };
16706    let metadata =
16707        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
16708            SourceError::Malformed {
16709                message: format!(
16710                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
16711                ),
16712            }
16713        })?;
16714    let before = &body[..slot.start];
16715    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
16716    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
16717}
16718
16719/// Where the metadata slot sits in one body, as byte offsets into it.
16720struct SlotSpan {
16721    /// Where [`METADATA_OPEN`] begins.
16722    start: usize,
16723    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
16724    encoded_start: usize,
16725    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
16726    encoded_end: usize,
16727    /// Just past [`METADATA_CLOSE`].
16728    end: usize,
16729}
16730
16731/// The slot at the very end of `body`, or `None` when it has none.
16732///
16733/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
16734/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
16735/// slot.
16736fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
16737    let Some(start) = body.rfind(METADATA_OPEN) else {
16738        return Ok(None);
16739    };
16740    let encoded_start = start + METADATA_OPEN.len();
16741    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
16742        return Err(SourceError::Malformed {
16743            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
16744        });
16745    };
16746    let encoded_end = encoded_start + relative_end;
16747    let end = encoded_end + METADATA_CLOSE.len();
16748    if !body[end..].trim().is_empty() {
16749        return Ok(None);
16750    }
16751    Ok(Some(SlotSpan {
16752        start,
16753        encoded_start,
16754        encoded_end,
16755        end,
16756    }))
16757}
16758
16759/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
16760/// slot as it was.
16761///
16762/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
16763/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
16764/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
16765/// or alone in an empty body — and a body with no slot that is given no metadata is
16766/// returned as it is.
16767fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
16768    let encoded = if metadata.is_empty() {
16769        None
16770    } else {
16771        Some(
16772            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16773                message: error.to_string(),
16774            })?,
16775        )
16776    };
16777    Ok(match (slot_span(body)?, encoded) {
16778        (Some(slot), Some(encoded)) => format!(
16779            "{}{encoded}{}",
16780            &body[..slot.encoded_start],
16781            &body[slot.encoded_end..]
16782        ),
16783        (Some(slot), None) => {
16784            let before = &body[..slot.start];
16785            format!(
16786                "{}{}",
16787                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
16788                &body[slot.end..]
16789            )
16790        }
16791        (None, None) => body.to_owned(),
16792        (None, Some(encoded)) if body.is_empty() => {
16793            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16794        }
16795        (None, Some(encoded)) => {
16796            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16797        }
16798    })
16799}
16800
16801/// `body` with everything before its metadata slot replaced by `content`, and the slot
16802/// itself kept byte for byte.
16803///
16804/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
16805/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
16806/// `content` is empty — so a read of the result reports `content` as the visible body and
16807/// the slot's metadata exactly as it was.
16808fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
16809    let Some(slot) = slot_span(body)? else {
16810        return Ok(content.to_owned());
16811    };
16812    let kept = &body[slot.start..];
16813    Ok(if content.is_empty() {
16814        kept.to_owned()
16815    } else {
16816        format!("{content}{METADATA_SEPARATOR}{kept}")
16817    })
16818}
16819
16820/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
16821fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
16822    if entries.is_empty() {
16823        metadata.remove(key);
16824    } else {
16825        metadata.insert(
16826            key.to_owned(),
16827            Value::Array(
16828                entries
16829                    .iter()
16830                    .map(|entry| Value::String(entry.as_str().to_owned()))
16831                    .collect(),
16832            ),
16833        );
16834    }
16835}
16836
16837fn compose_body(
16838    content: Option<&str>,
16839    metadata: &BTreeMap<String, Value>,
16840) -> Result<Option<String>, SourceError> {
16841    let visible = content.unwrap_or_default();
16842    if metadata.is_empty() {
16843        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
16844    }
16845    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16846        message: error.to_string(),
16847    })?;
16848    Ok(Some(if visible.is_empty() {
16849        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16850    } else {
16851        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16852    }))
16853}
16854
16855fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
16856    value
16857        .get(field)
16858        .and_then(Value::as_bool)
16859        .ok_or_else(|| SourceError::Malformed {
16860            message: format!("GitHub response is missing boolean field {field}"),
16861        })
16862}
16863fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
16864    match value.get(field) {
16865        None | Some(Value::Null) => Ok(None),
16866        Some(value) => value
16867            .as_str()
16868            .map(Some)
16869            .ok_or_else(|| SourceError::Malformed {
16870                message: format!("GitHub response field {field} is not a string or null"),
16871            }),
16872    }
16873}
16874fn optional_nodes<'a>(
16875    connection: Option<&'a Value>,
16876    name: &str,
16877) -> Result<Option<&'a Vec<Value>>, SourceError> {
16878    match connection {
16879        None | Some(Value::Null) => Ok(None),
16880        Some(value) => value
16881            .get("nodes")
16882            .and_then(Value::as_array)
16883            .map(Some)
16884            .ok_or_else(|| SourceError::Malformed {
16885                message: format!("GitHub {name}.nodes is not an array"),
16886            }),
16887    }
16888}
16889fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
16890    let page_info = connection
16891        .get("pageInfo")
16892        .ok_or_else(|| SourceError::Malformed {
16893            message: format!("GitHub {name} has no pageInfo"),
16894        })?;
16895    if required_bool(page_info, "hasNextPage")? {
16896        return Err(SourceError::Malformed {
16897            message: format!(
16898                "GitHub {name} exceeds the supported nested connection size of {size}"
16899            ),
16900        });
16901    }
16902    Ok(())
16903}
16904fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
16905    optional_str(value, field)?
16906        .map(|timestamp| {
16907            timestamp.parse().map_err(|error| SourceError::Malformed {
16908                message: format!("GitHub response field {field} is not a timestamp: {error}"),
16909            })
16910        })
16911        .transpose()
16912}
16913fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
16914    if page.limit == 0 {
16915        Err(SourceError::Config {
16916            message: "page limit must be at least 1".into(),
16917        })
16918    } else {
16919        Ok(())
16920    }
16921}
16922fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
16923    let page = connection
16924        .get("pageInfo")
16925        .filter(|value| value.is_object())
16926        .ok_or_else(|| SourceError::Malformed {
16927            message: "GitHub connection is missing pageInfo".into(),
16928        })?;
16929    if required_bool(page, "hasNextPage")? {
16930        let cursor = required_str(page, "endCursor")?;
16931        validate_cursor_progress(None, cursor)?;
16932        Ok(Some(Cursor(cursor.into())))
16933    } else {
16934        Ok(None)
16935    }
16936}
16937fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
16938    if next.is_empty() || previous == Some(next) {
16939        Err(SourceError::Malformed {
16940            message: "GitHub pagination cursor is empty or did not advance".into(),
16941        })
16942    } else {
16943        Ok(())
16944    }
16945}
16946/// The version of this plugin's opaque narrowing-search cursor.
16947pub const SEARCH_CURSOR_VERSION: u32 = 4;
16948
16949#[derive(Serialize, Deserialize)]
16950#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
16951enum SearchConnection {
16952    Initial {},
16953    Continuing { after: Cursor },
16954    Exhausted {},
16955}
16956impl SearchConnection {
16957    fn after(&self) -> Option<&str> {
16958        match self {
16959            Self::Continuing { after } => Some(&after.0),
16960            _ => None,
16961        }
16962    }
16963    fn exhausted(&self) -> bool {
16964        matches!(self, Self::Exhausted { .. })
16965    }
16966    /// Whether a cursor naming this position, `offset` rows into its page, is one this
16967    /// plugin could have handed out: a page is resumed only part of the way through it — an
16968    /// offset of a whole page or more would skip rows nobody was given — an initial page
16969    /// only once some of it was handed out, and an exhausted connection has no page to be
16970    /// part of the way through.
16971    fn valid_resume(&self, offset: usize) -> bool {
16972        let within = offset < SEARCH_PAGE_SIZE as usize;
16973        match self {
16974            Self::Initial { .. } => offset > 0 && within,
16975            Self::Continuing { after } => !after.0.is_empty() && within,
16976            Self::Exhausted { .. } => offset == 0,
16977        }
16978    }
16979}
16980
16981/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
16982#[derive(Serialize, Deserialize)]
16983#[serde(deny_unknown_fields)]
16984struct SearchPosition {
16985    version: u32,
16986    connection: SearchConnection,
16987    /// How many rows of the page `connection` starts were already handed out.
16988    #[serde(default, skip_serializing_if = "is_zero")]
16989    offset: usize,
16990    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16991    seen: Vec<NativeId>,
16992    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16993    own: Vec<NativeId>,
16994}
16995impl Default for SearchPosition {
16996    fn default() -> Self {
16997        Self {
16998            version: SEARCH_CURSOR_VERSION,
16999            connection: SearchConnection::Initial {},
17000            offset: 0,
17001            seen: Vec::new(),
17002            own: Vec::new(),
17003        }
17004    }
17005}
17006
17007fn is_zero(offset: &usize) -> bool {
17008    *offset == 0
17009}
17010
17011fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
17012    cursor.map_or(Ok(0), |c| {
17013        c.0.parse().map_err(|_| SourceError::Config {
17014            message: "page cursor is invalid".into(),
17015        })
17016    })
17017}
17018fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
17019    if offset > items.len() {
17020        return Page::last(vec![]);
17021    }
17022    let tail = items.split_off(offset);
17023    let mut selected = tail;
17024    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
17025    selected.truncate(limit);
17026    Page {
17027        items: selected,
17028        next,
17029    }
17030}
17031
17032impl GitHubProjectsSource {
17033    /// The body [`Self::write_item`] would compose for `incoming`, sized in the characters
17034    /// [`BODY_LIMIT`] counts, without a request: its slot as `slot_metadata` writes it, every
17035    /// asset reference rewritten to its recorded URL or — for one not uploaded yet — to an
17036    /// address as long as GitHub's, and the heading markup this board's bodies carry.
17037    ///
17038    /// What the write reads rather than knows is taken as it most often lands: the issue in
17039    /// the one repository the item names, else the configured one, and every edge on the
17040    /// board's own relationships rather than in the slot.
17041    fn body_size(&self, incoming: &Incoming<'_>) -> onetaskgraph_plugin_api::BodySize {
17042        let own = match incoming.repositories {
17043            [named] => Some(named.clone()),
17044            _ => self
17045                .repository
17046                .as_ref()
17047                .and_then(|target| Repository::try_from(target.origin()).ok()),
17048        };
17049        let mut slot = slot_metadata(incoming, own.as_ref(), &[]);
17050        let content = match incoming.assets {
17051            Some(assets) => {
17052                let mut uploads = onetaskgraph_plugin_api::AssetUploads::default();
17053                for asset in &assets.assets {
17054                    let url = assets
17055                        .recorded_assets
17056                        .as_ref()
17057                        .and_then(|recorded| recorded.reusable(&asset.name, &asset.sha256))
17058                        .unwrap_or(PREDICTED_ATTACHMENT_URL)
17059                        .to_owned();
17060                    uploads.0.insert(
17061                        asset.name.clone(),
17062                        onetaskgraph_plugin_api::AssetUpload {
17063                            sha256: asset.sha256.clone(),
17064                            url,
17065                        },
17066                    );
17067                }
17068                incoming.content.map(|content| {
17069                    onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads)
17070                })
17071            }
17072            None => incoming.content.map(str::to_owned),
17073        };
17074        let marked = content.as_deref().map(|content| self.marked(content));
17075        let size = compose_body(marked.as_deref(), &slot)
17076            .ok()
17077            .flatten()
17078            .map_or(0, |body| body.chars().count());
17079        onetaskgraph_plugin_api::BodySize {
17080            size: u64::try_from(size).unwrap_or(u64::MAX),
17081            limit: Some(BODY_LIMIT),
17082        }
17083    }
17084
17085    async fn write_task_assets(
17086        &self,
17087        write: &ItemWrite<Task>,
17088        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17089    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17090        self.write_item(
17091            &self.task_incoming(write, assets)?,
17092            write.target.as_ref(),
17093            &write.depends_on,
17094        )
17095        .await
17096    }
17097
17098    /// One task write as [`Self::write_item`] takes it, once what it delivers and its priority
17099    /// are known to be ones this source can write.
17100    fn task_incoming<'a>(
17101        &self,
17102        write: &'a ItemWrite<Task>,
17103        assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
17104    ) -> Result<Incoming<'a>, SourceError> {
17105        let near = write.target.as_ref().unwrap_or(&write.item.id);
17106        for (key, entries) in [
17107            (TaskRef::DELIVERS_KEY, &write.item.delivers),
17108            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
17109        ] {
17110            TaskRef::listed(key, near, Some(&self.name), entries.clone())
17111                .map_err(|message| SourceError::Refused { message })?;
17112        }
17113        if self.priorities.is_none() && write.item.priority != Priority::None {
17114            return Err(self.holds_no_priority());
17115        }
17116        Ok(Incoming {
17117            written: Written::Work(ItemKind::Task, &write.item.status),
17118            title: &write.item.title,
17119            content: write.item.content.as_deref(),
17120            assets,
17121            labels: &write.item.labels,
17122            metadata: &write.item.metadata,
17123            repositories: &write.item.repositories,
17124            classification: write.item.classification,
17125            parent: write.item.project.as_ref(),
17126            delivers: &write.item.delivers,
17127            delivered_by: &write.item.delivered_by,
17128            priority: self.priorities.as_ref().map(|_| write.item.priority),
17129        })
17130    }
17131    async fn write_document_assets(
17132        &self,
17133        write: &ItemWrite<Document>,
17134        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17135    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17136        // A document takes part in no dependency graph, so there is no far end to write
17137        // natively and none to record: a caller naming one is told so rather than having it
17138        // stored under the reserved key, where a later read would report an edge the
17139        // contract says cannot exist.
17140        if !write.depends_on.is_empty() {
17141            return Err(SourceError::Refused {
17142                message: format!(
17143                    "this write names {} dependencies for a document, and a document takes \
17144                     part in no dependency graph; next: put the dependency on the task or \
17145                     project the document is about",
17146                    write.depends_on.len()
17147                ),
17148            });
17149        }
17150        self.write_item(
17151            &Incoming {
17152                written: Written::Document,
17153                title: &write.item.title,
17154                content: write.item.content.as_deref(),
17155                assets,
17156                labels: &write.item.labels,
17157                metadata: &write.item.metadata,
17158                repositories: &write.item.repositories,
17159                classification: write.item.classification,
17160                parent: write.item.project.as_ref(),
17161                delivers: &[],
17162                delivered_by: &[],
17163                priority: None,
17164            },
17165            write.target.as_ref(),
17166            &[],
17167        )
17168        .await
17169    }
17170}
17171
17172/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
17173/// itself, for the two things no journey can observe.
17174///
17175/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
17176/// with and without the call, that a settlement, a board listing and a metadata search each
17177/// read afresh after it — the resolved records, the written-item overlay, the board and its
17178/// search, and the narrowed searches. What they cannot reach is the held field definitions,
17179/// because a status write naming an option a person deleted is refused the same whether or
17180/// not the list is held, and a poisoned lock, because nothing outside the source can panic
17181/// while one of its locks is held. So these assert those directly, and every other holder
17182/// beside them so a holder added later without a clear in the call fails here.
17183#[cfg(test)]
17184mod end_command_tests {
17185    use super::*;
17186
17187    struct Token;
17188
17189    impl SecretResolver for Token {
17190        fn get(&self, var: &str) -> Option<SecretString> {
17191            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
17192        }
17193    }
17194
17195    fn source() -> GitHubProjectsSource {
17196        let config = serde_json::from_value(json!({
17197            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
17198            // Nothing here is sent: the source is only built and its state inspected.
17199            "endpoint": "http://127.0.0.1:9/graphql",
17200        }))
17201        .expect("a usable configuration");
17202        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
17203            .expect("the source builds")
17204    }
17205
17206    /// One issue as a board read answers it.
17207    fn resolved(source: &GitHubProjectsSource) -> Resolved {
17208        source
17209            .resolve(&json!({
17210                "id": "ITEM-1",
17211                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
17212                            "body": "what a person may since have edited", "state": "OPEN",
17213                            "stateReason": null, "url": null, "number": 1,
17214                            "subIssuesSummary": {"total": 0},
17215                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
17216                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
17217            }))
17218            .expect("the item reads")
17219            .expect("an issue")
17220    }
17221
17222    /// Hold something in every holder the call clears, and the repository id it keeps.
17223    fn fill(source: &GitHubProjectsSource) {
17224        let item = resolved(source);
17225        source.created.lock().unwrap().push(item.clone());
17226        source.updated.lock().unwrap().push(item.clone());
17227        *source.board_cache.lock().unwrap() = Some(Board {
17228            id: "PVT-board".into(),
17229            fields: json!({"nodes": []}),
17230            items: vec![item.clone()],
17231        });
17232        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
17233        source
17234            .narrowed_cache
17235            .lock()
17236            .unwrap()
17237            .insert("status:todo".into(), vec![item.clone()]);
17238        source.children_cache.lock().unwrap().insert(
17239            NativeId("P-1".into()),
17240            (NativeId("P-1".into()), vec![item.clone()]),
17241        );
17242        source
17243            .search_next
17244            .lock()
17245            .unwrap()
17246            .insert("status:todo".into(), Some("cursor".into()));
17247        source
17248            .resolved_cache
17249            .lock()
17250            .unwrap()
17251            .insert(item.id.clone(), item);
17252        *source.fields_cache.lock().unwrap() = Some(BoardFields {
17253            id: BoardId::parse("PVT-board").unwrap(),
17254            fields: json!({"nodes": []}),
17255        });
17256        source
17257            .repository_cache
17258            .lock()
17259            .unwrap()
17260            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
17261    }
17262
17263    fn assert_dropped(source: &GitHubProjectsSource) {
17264        assert!(source.created().unwrap().is_empty(), "created");
17265        assert!(source.updated().unwrap().is_empty(), "updated");
17266        assert!(source.board_cache().unwrap().is_none(), "board");
17267        assert!(source.search_cache.lock().unwrap().is_none(), "search");
17268        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
17269        assert!(
17270            source.children_cache.lock().unwrap().is_empty(),
17271            "project children"
17272        );
17273        assert!(
17274            source.search_next.lock().unwrap().is_empty(),
17275            "search paging"
17276        );
17277        assert!(
17278            source.resolved_cache().unwrap().is_empty(),
17279            "resolved records"
17280        );
17281        assert!(source.fields_cache().unwrap().is_none(), "board fields");
17282        assert_eq!(
17283            source.repository_cache().unwrap().len(),
17284            1,
17285            "a repository's node id stays valid and is kept"
17286        );
17287    }
17288
17289    fn end(source: &GitHubProjectsSource) {
17290        tokio::runtime::Builder::new_current_thread()
17291            .build()
17292            .unwrap()
17293            .block_on(source.end_command())
17294            .expect("the command ends");
17295    }
17296
17297    #[test]
17298    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
17299        let source = source();
17300        fill(&source);
17301        end(&source);
17302        assert_dropped(&source);
17303    }
17304
17305    #[test]
17306    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
17307        fn poison<T: Send>(held: &Mutex<T>) {
17308            std::thread::scope(|scope| {
17309                let _ = scope
17310                    .spawn(|| {
17311                        let _guard = held.lock().unwrap();
17312                        panic!("a failure while the lock is held");
17313                    })
17314                    .join();
17315            });
17316            assert!(held.is_poisoned());
17317        }
17318        let source = source();
17319        fill(&source);
17320        poison(&source.created);
17321        poison(&source.updated);
17322        poison(&source.board_cache);
17323        poison(&source.search_cache);
17324        poison(&source.narrowed_cache);
17325        poison(&source.search_next);
17326        poison(&source.resolved_cache);
17327        poison(&source.fields_cache);
17328        assert!(
17329            source.resolved_cache().is_err(),
17330            "a poisoned lock is refused before the call"
17331        );
17332        end(&source);
17333        assert_dropped(&source);
17334    }
17335}
17336
17337#[cfg(test)]
17338mod unprojectable_fields_tests {
17339    use super::*;
17340
17341    /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
17342    fn documented() -> std::collections::BTreeSet<String> {
17343        let document = std::fs::read_to_string(
17344            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
17345        )
17346        .expect("docs/metadata.md is readable");
17347        let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
17348        let lead = "a `field` equal, ignoring case, to ";
17349        let start = document.find(lead).expect("the refused-field sentence") + lead.len();
17350        let sentence = &document[start..];
17351        let sentence = &sentence[..sentence
17352            .find("; and two entries naming one field")
17353            .expect("the refused-field sentence ends at the duplicate rule")];
17354        let (own, github) = sentence
17355            .split_once(" or a field GitHub owns on every board (")
17356            .expect("the sentence names GitHub's own fields");
17357        own.split('`')
17358            .skip(1)
17359            .step_by(2)
17360            .chain(
17361                github
17362                    .strip_suffix(')')
17363                    .expect("GitHub's own fields close the sentence")
17364                    .split(", "),
17365            )
17366            .map(str::to_owned)
17367            .collect()
17368    }
17369
17370    #[test]
17371    fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
17372        let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
17373            .iter()
17374            .map(|&name| name.to_owned())
17375            .collect();
17376        assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
17377        let instance = SourceName::new("work").unwrap();
17378        for name in UNPROJECTABLE_FIELDS {
17379            for spelled in [name.to_owned(), name.to_uppercase()] {
17380                let entry = MetadataFieldConfig {
17381                    field: spelled.clone(),
17382                    key: "team.machine".to_owned(),
17383                    path: Vec::new(),
17384                };
17385                let refused = MetadataField::resolve(vec![entry], &instance)
17386                    .expect_err("an unprojectable field is refused");
17387                assert!(
17388                    refused.to_string().contains(&format!("{name:?}")),
17389                    "{spelled}: {refused}"
17390                );
17391            }
17392        }
17393    }
17394}