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, an array of strings as one line per
93//! element and an object of string values as one `KEY: VALUE` line per entry in byte order of
94//! the keys, shortened past [`TEXT_FIELD_LIMIT`] to its leading whole lines and a line counting
95//! the rest, and none of them sent again while the field holds that text — an empty string,
96//! array or object included, which writes the empty string; nothing there or `null` clears a
97//! field holding a value; any other JSON type is refused before any mutation, the shared rule
98//! [`ProjectedValue`] decides. It rides the ordered [`graphql::UPDATE_FIELDS`]
99//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
100//! own moves its field first and the body last, as every write of an existing item does. A
101//! board without the field, or with a non-text field of that name, is refused before any
102//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
103//! a text field and never changes a field that is there.
104//!
105// 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.
106//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
107//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
108//! option of the board's one `Status` field for a task and for a project: a bare name is
109//! the option for both kinds, `null` disables the category for both, and `{task, project}`
110//! names it per kind. A category the mapping does not mention keeps its shipped default for
111//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
112//! gets the shipped default for the kind it leaves out. Two categories one kind would read
113//! back from one option are refused as the configuration is read, ignoring case, while one
114//! option may stand for different categories of the two kinds. Writes go by the kind of the
115//! item written: a status that kind has no option for, or whose option the board lacks, is
116//! refused before any mutation, naming the source, the kind, the category and the key
117//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
118//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
119//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
120//! before selecting its option. Reads give a closed issue's reason precedence over its
121//! option, while an open issue's option decides its category through its own kind's
122//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
123//! The guarded [`GitHubProjectsSource::status_options`] and
124//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
125//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
126//! existing option id and verify the field and item assignments immediately afterwards.
127//! They ask for both kinds' options, counting a terminal category's mapped option as
128//! configured because a terminal write refuses without it. No ordinary source read or
129//! write calls that mutation, whose
130//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
131//! and a mistake destroys every item's status. A status this board cannot represent is a
132//! refusal naming the status and the instance instead.
133//!
134//! `unknown` has no shipped option because this source cannot preserve an open-ended
135//! status word: it writes an existing board option and never
136//! creates an option. An operator may map `unknown` to one existing option, in which case
137//! every unknown word lands on that option and reads back as `unknown` under the option's
138//! name. This differs from `local-md`, which writes and reads the original word itself.
139//!
140//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
141//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
142//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
143//! finished tasks were only moved to a "Done" column would read 0% complete forever.
144// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
145//!
146//! # What this source declares, field by field
147//!
148//! One verdict per field of [`Capabilities`], and what `Native` means when this source
149//! says it. *Proven* means a shared journey drives it against the real
150//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
151//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
152//! [`capabilities`](TaskSource::capabilities) from parting.
153//!
154//! | Field | Verdict |
155//! | --- | --- |
156//! | `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. |
157//! | `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. |
158//! | `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. |
159//! | `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. |
160//! | `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. |
161//! | `assignees` | **Supported and proven when `config.assignees: true`,** unsupported otherwise, which reads no issue's assignees and sends exactly the requests it sent before assignees existed. An issue's `assignees` by login, every one it holds, selected on the reads the source already makes; a write replaces them with one login or none as one more aliased field of the document the write already sends, and a login GitHub refuses, or answers leaving off the issue, is refused for that item naming it. See [`assignees`] for the login lookup. Proven by `onetaskgraph-github-projects-e2e`'s `assignees` journeys. |
162//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
163//! | `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. |
164//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
165//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
166//! | `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. |
167//! | `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. |
168//! | `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. |
169//! | `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. |
170//! | `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. |
171//! | `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. |
172//! | `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. |
173//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
174//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
175//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
176//!
177//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
178//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
179//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
180//! and the raw bytes as the request body. The numeric repository id is read at most once
181//! per repository per source instance. A loopback API endpoint also selects the loopback
182//! uploads host; production needs no assets repository, commits or extra configuration.
183//! Every returned JSON `url` is read with the same token before a body is written: only
184//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
185//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
186//! asset and HTTP status and says the token type may not be accepted by the upload
187//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
188//! fine-grained PATs or OAuth tokens.
189//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
190//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
191//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
192//! and verified afresh, in the repository the issue already lives in on an update.
193//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
194//! Uploads take the same mutation spacing as content writes. Both uploads and verification
195//! reads wait out classified rate limits within the configured per-call retry budget,
196//! on the supplied clock; verifying reads take no mutation slot.
197//!
198//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
199//! source has documents and that its tasks have comments, both of which hold — and the three
200//! facts behind the uniform `Native` on the
201//! predicates beside it are recorded below rather than re-derived, because a reader who
202//! takes `Native` to mean *the remote service filters* will read that uniformity as a
203//! lie.
204//!
205//! First, the plugin contract defines `Support::Native` as *the source applies this
206//! predicate itself*, and says nothing about where it applies it. What the declaration
207//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
208//! — so that the engine may push it down and apply nothing of its own.
209//!
210//! Second, this source can keep that promise for every predicate at no additional API
211//! cost, because whichever of the reads below answers a query has already read every
212//! candidate that query will return before it filters anything. Filtering those items is
213//! in-process work over data already in hand.
214//!
215//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
216//! applied in process over what that question returned. A project filter has a relationship — a
217//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
218//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
219//! a search for metadata values, is the board-scoped issue search carrying the text and each
220//! value as quoted phrases; an origin is the board's own field filter over its origin field
221//! beside the same search for the id. **The text search narrows, and that is this source's
222//! declared semantics:** GitHub matches whole words where the substring rule this source and
223//! the local Markdown source confirm with would match inside one, so an item holding the text
224//! only inside a longer word is never a candidate. Every item returned does contain the text.
225//! A project query's text, and a document query's scoped to no project, is that same search
226//! and narrows on the same terms, its candidates confirmed by their kind as well.
227//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
228//! so those three are applied in process over the candidates, and a query carrying none of
229//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
230//! engine compensate for work this source has already done, and declaring `projects` native
231//! while ignoring the filter (which this source once did) silently returns another project's
232//! tasks, because the engine trusts the declaration and applies nothing locally.
233//!
234//! # The three ways this source reaches an item, and what each costs
235//!
236//! A board read is charged for what its *nested* connections could return rather than for
237//! what was asked, so one whole-board read costs the same whether the question was about
238//! one project or about all of them. That is why a question about one project is never
239//! answered by reading the board:
240//!
241//! | The question | What is sent | What it costs |
242//! | --- | --- | --- |
243//! | 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 |
244//! | 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 |
245//! | 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 |
246//! | 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 |
247//! | 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 |
248//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
249//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
250//! | 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 |
251//! | 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 |
252//! | 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 |
253//! | 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 |
254//! | 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 |
255//! | 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 |
256//!
257//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
258//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
259//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
260//! equal to declared points equal to the row. They include the origin lookup and the
261//! field/repository discovery a create needs. A bound re-copy changes status, priority,
262//! content and metadata; comment recount means a subsequent detail read. Each request here
263//! costs one declared point. A membership beyond the embedded page can additionally require
264//! the one-point membership recovery described above. A bound re-copy of a task filed under a
265//! project adds one read, the engine confirming that project's link by its own id once per
266//! command; and the same-source far ends a write newly names — those that do not already block
267//! the item, whose own read answered for them — are read together by their own ids,
268//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
269//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
270//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
271//!
272//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
273//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
274//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
275//! holds both halves.
276//!
277//! **An existing item is written body last.** A bound re-copy and a `task update` send its
278//! board fields first — the `Status` option and the `Priority` together, in one request — then
279//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
280//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
281//! undoing an earlier field when a later one fails, so that order is what makes a write
282//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
283//! the one piece of metadata written before the body, an origin a copy re-points, is put back
284//! when a later write is refused — and when putting it back is refused too, the write's own
285//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
286//! of those writes in turn, whole and as one aliased field failing after the one before it.
287//!
288//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
289//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
290//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
291//!
292//! - **A board is accepted at creation but its item is not answered, so a create still files
293//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
294//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
295//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
296//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
297//!   against a real board on 2026-10-01 with a create sending the board there and reading the
298//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
299//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
300//!   refused "Content already exists in this project" — GitHub had filed the issue after
301//!   answering, and refuses a second filing rather than answering with the item it holds. A
302//!   create therefore sends no `projectV2Ids` and files the issue with
303//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
304//!   real is the read before it: the board's fields and the repository's id together, in
305//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
306//! - **A comment still reads its target first, so a comment is 2 requests.**
307//!   `AddCommentInput.subjectId: ID!` is declared there with
308//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
309//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
310//!   project's issue, a document's issue, an issue on no board of this source and a pull
311//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
312//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
313//!   refuses those by name.
314//!
315//! | Verb | Requests / points | Documents |
316//! | --- | --- | --- |
317//! | 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 |
318//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, CREATED_BOARD_ITEMS, WRITE_FIELD_VALUES: the new copy with no origin to ask about |
319//! | 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 |
320//! | 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 |
321//! | 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 |
322//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
323//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
324//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
325//! | 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 |
326//! | recount | 1 | ISSUE_DETAIL |
327//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
328//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
329//! | content | 2 | ISSUE, UPDATE_ISSUE |
330//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
331//! | 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 |
332//! | 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 |
333//! | record only | 1 | ISSUE |
334//!
335//! <!-- github-search-paging:start -->
336//! Board-scoped text, metadata, project-name and comment-activity searches send every
337//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
338//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
339//! needs rows. A page is never resized to the rows still needed: GitHub orders one
340//! search differently at different page sizes, so one fixed size makes a paged walk
341//! send exactly the requests one whole read sends, and the answer's order is the order
342//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
343//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
344//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
345//! returned and fetched pages: a limit is sliced from the pages it needs, and local
346//! confirmation can require more candidates than matching rows. Walking all pages
347//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
348//! cursor and how far into that page the last answer stopped, and resumes in the same
349//! process or a new one, without duplicates or gaps. It carries no rows: one process
350//! sends each page's search once, and a new process re-reads only the page it resumes
351//! in, then sends a further page once, never as a re-read, only when its limit still
352//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
353//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
354//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
355//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
356//! not required to include the original process's writes still omitted by the index.
357//! <!-- github-search-paging:end -->
358//!
359//! The board half of an issue — its board item's id, its `Status` option and this
360//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
361//! an item reached any of those ways resolves through the same
362//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
363//! same status, the same labels and the same qualified id. That connection comes back a
364//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
365//! on the page in hand and — only if that page reports more of the connection — in the
366//! last row's read of that one issue's memberships, resumed from the page's own cursor and
367//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
368//! report, which is what keeps an id naming another repository's issue from being answered
369//! as an item of this board; and because the page is where the search starts rather than
370//! where it ends, that answer is one about a connection read to exhaustion and never about
371//! an unread page. Nothing costs the extra read but an issue on more boards than a page
372//! holds: an issue this board really does not hold reports no next page, so its
373//! memberships are already exhausted where they arrived.
374//!
375//! **No document here selects the board's own `Labels` field, and nothing is lost by
376//! that.** An item's labels are read from its content alone, wherever that content is
377//! reached: the three documents above select `Issue.labels` on the fragment, and
378//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
379//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
380//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
381//! create one, and `ProjectV2FieldValue` — the whole of what
382//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
383//! it from the content, for every content type it exists on, and there is nothing it can
384//! hold that the content does not already say: for an `Issue` it *is* that issue's own
385//! labels, so selecting it beside them unions a set with itself.
386//!
387//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
388//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
389//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
390//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
391//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
392//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
393//! label set, and that set is the fixture issue's own, by
394//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
395//! item whose content is a draft reports an empty set, by
396//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
397//! selection is held over [`graphql::DOCUMENTS`] by
398//! `no_document_selects_the_boards_own_labels_field`.
399//!
400//! The whole-board row is still the board's own item connection, and deliberately: a
401//! **draft** board item is not an issue, so no search can list one, and the reads that have
402//! to answer for the whole board are the ones whose cost is the board's size anyway.
403//!
404//! **A question about one item this source already names by id never lists the board.**
405//! Whether that item is on this board, and what its board fields are, is answered by reading
406//! that item — its own `Issue.projectItems`, walked to exhaustion by
407//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
408//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
409//! holds. That covers a write's destination, the project a new item is filed under, a
410//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
411//! and the delete that takes back an item a copy made. What such a write needs of the board
412//! and the item does not carry — the board's id, the `Status` and origin field definitions —
413//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
414//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
415//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
416//! minutes. Scanning this host's 842-item board has refused a document copy and an update
417//! even though the items' own reads named that board. A scan there gives the wrong answer
418//! as well as paying for every page. So a `board.items` lookup does not belong on any of
419//! those paths.
420//!
421//! **What a read may return is capped too, and that cap is on the document rather than on
422//! the board.** GitHub limits the number of nodes **one query may return** to
423//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
424//! an error naming the connection the count crossed at, not a slow or a partial result.
425//! Every board this source reads is refused the same way, so no board is too big for these
426//! documents and none is small enough to save one that is over.
427//!
428//! The count is arithmetic over the document's own text: each connection contributes the
429//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
430//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
431//! restate them — `github-graphql-node-count` implements them, and
432//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
433//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
434//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
435//! same text on every run and fails naming any that reaches the limit — so a connection
436//! added to a shared fragment is caught there rather than by GitHub.
437//!
438//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
439//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
440//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
441//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
442//! effectively squared there, which is why it is the one the limit is most sensitive to.
443//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
444//! page of memberships misses is recovered by one further read rather than refused, so it
445//! buys a bound every read pays for at the price of a request only a multi-board issue
446//! pays.
447//!
448//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
449//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
450//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
451//! `cost` is rate-limit points, metered per hour across everything one credential does; it
452//! is what the two limiters [`Limiter`] tells apart meter, and a document under
453//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
454//! that second number, and `tests/point_cost.rs` pins every document in
455//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
456//! one under, the pin itself is the check. The credentialed lane reconciles both figures
457//! against GitHub's own, off a probe it already sends.
458//!
459//! **What is pinned that way is a per-document price and never a session's.** The record in
460//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
461//! **requests** and **worst-case nodes** — and neither is points. What one whole session
462//! consumes of the hourly point allowance is observable only from a credentialed run's own
463//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
464//! and what `tests/live.rs` prints at the end of every run.
465//!
466//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
467//!
468//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
469//! enumerations of a board can supply one alone.** Resolving a node id is strongly
470//! consistent, so a read by id and a project's own sub-issues are already current. The
471//! other two are not, and they are behind by different amounts and in different directions:
472//!
473//! - GitHub's **issue search** is an index and answers a write made moments ago with the
474//!   value from before it — usually for a second or two.
475//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
476//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
477//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
478//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
479//!
480//! That second one is a measurement rather than a caution. This repository's own
481//! credentialed journey writes a project and waits for the board to report it, then writes a
482//! task and waits for the same thing seconds later on the same board: the project wait is
483//! answered through the search and converged in two or three attempts in each of three runs,
484//! and the task wait is answered through `ProjectV2.items` and converged in none of them
485//! inside thirty. Separately, an item added to a second and larger board was read back by
486//! `Issue.projectItems` on that board's own id while every one of that connection's nine
487//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
488//! through the lagging one alone is what had a board read deny an issue that had certainly
489//! landed on it.
490//!
491//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
492//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
493//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
494//! search reports what the projection is behind on. What closes the last
495//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
496//! source answers is completed with what this process itself wrote, so an item created
497//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
498//! nothing is written down, and the record dies with the process. **A wait that has to
499//! observe GitHub's own data cannot be answered from that record** — which is why the
500//! credentialed journey asks through a source built afresh, and why the union above rather
501//! than a longer wait is what makes such a wait converge.
502//!
503//! **A narrowed read is the same bargain, stated for each of the three predicates it
504//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
505//! than walking the board, and every such answer is completed with what this process wrote —
506//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
507//! each filtered by the same predicates as the rest — so an item this command wrote a moment
508//! ago is returned by a query that matches it whether or not the index has caught up. An item
509//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
510//! consistent. What is left is stated rather than papered over:
511//!
512//! | Read | Finds | Behind by |
513//! | --- | --- | --- |
514//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
515//! | 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 |
516//! | 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 |
517//! | origin, third read | this process's own writes | nothing |
518//!
519//! So an origin carrier another process added within the last second or two, before either
520//! index has it, can be missing from an origin query, and one written by the release before
521//! this one — its origin in the field alone — can be missing for as long as the board's own
522//! item connection is behind on it. A copy that must not duplicate its own earlier write
523//! relies on the link it records, not on either index. **A board draft is not an issue**, so
524//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
525//! lists one, the origin lookup drops any the board's own field filter names, and one this
526//! process wrote is not added back either.
527//!
528//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
529//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
530//! body's metadata slot, so the issue search can find it in seconds. The field is
531//! authoritative: this source reads an item's origin from the field alone, so a slot that
532//! disagrees with it, or holds one where the field holds none, is never read as a second
533//! origin — and the release before this one reads the slot, drops that key's copy for the
534//! field's, and sees the same one origin.
535//!
536//! Filtering happens before paging, so a page of a filtered result is a page of the
537//! survivors rather than the survivors of a page. Label matching and the substring rule a
538//! text candidate is confirmed by answer the same question the same way the local Markdown
539//! source's do; which candidates a text search has to confirm is GitHub's word match, which
540//! is the one place the two sources can answer the same text differently.
541//!
542//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
543//! has one source, `capabilities`, and the note above is the reasoning behind it rather
544//! than a second copy of it: without the three facts recorded here a reader takes the
545//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
546//! crate's own capabilities test, which pins every field of it against a fully spelled-out
547//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
548//! fails to compile there rather than going unasserted. -->
549//! The fixture-server tests above run wherever this crate is selected; the credentialed
550//! lane runs in the same required check, beside them, and can fail it — it verifies the
551//! 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,
552//! one filed under neither, a label on one of the three and a closed status on another —
553//! because that shape is what tells an honoured predicate from an ignored one: a board
554//! holding a single project answers a project filter the same way whether or not this
555//! source applies it, which is exactly how the defect above went unseen.
556//!
557//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
558//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
559//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
560//! session or request, independently of the live demand. Fix that variable in the machine's
561//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
562//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
563//! policy crate; absent credentials or nominations otherwise skip.
564//!
565//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
566//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
567//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
568//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
569//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
570//! after its run reads back as `completed`, immediately before the listed-artifact batch.
571//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
572//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
573//! and the board's `onetaskgraph.origin` field untouched.
574//!
575//! # What a session of requests costs, and where the report is
576//!
577//! This source records **every** request it sends into [`accounting::Accounting`], at
578//! `send_once` — the one place a request leaves this crate, which is why a read path added
579//! later is counted without anybody remembering to count it. That is the whole of what this
580//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
581//! session's spend is arrived at, and what it deliberately does not know are set out.
582//!
583//! What one whole session of the live journey costs, counted that way against this crate's
584//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
585//! reduction it came out of, and with what it does and does not say about rate-limit points.
586//!
587//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
588//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
589//! code path — no environment variable, no feature, no build configuration — because an
590//! instrument nobody switches on measures nothing, and
591//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
592//! source's counts the whole session rather than this source's share. The credentialed lane
593//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
594//! passed or failed.
595//!
596//! **A live session refuses to start unless the account can afford it.** Before it does any
597//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
598//! GitHub documents as not counting against the REST rate limit and which answers both of
599//! its budgets at once — and starts only if, for each of them, what remains minus this
600//! session's estimated cost is still at least
601//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
602//! allowance. A session that cannot **declines**: it did not run, so it is
603//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
604//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
605//! waiting for the budget to come back. The estimate is derived offline from
606//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
607//! which is also where the published rule that model rests on is cited; the accounting
608//! above records the gate's own read like any other request, and
609//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
610//!
611//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
612//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
613//! text, which is what lets it run on every platform and on a pull request from a fork with
614//! no credential — and that is what actually stops a regression merging. But an offline
615//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
616//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
617//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
618//! number of nodes this query may return"* and whose `cost` is what that document would
619//! spend, both for a document **without executing it**, and the lane asks it for every query
620//! document this source sends, under the largest bindings this source sends, and fails when
621//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
622//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
623//! one; the offline pins still cover it. It records what those calls reported about the
624//! account's own allowance, because whether asking is free is a thing to observe rather than
625//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
626//! and `cost` is metered against an hourly allowance the accounting above reads off a
627//! credentialed run's own response headers.
628//!
629//! **GitHub has two rate limiters and this source is refused by both, so nothing here
630//! treats them as one thing.** The primary budget is the hourly allowance `gh api
631//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
632//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
633//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
634//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
635//! one part of.
636#![deny(missing_docs)]
637
638use std::collections::BTreeMap;
639use std::sync::{Arc, Mutex};
640use std::time::Duration;
641
642use chrono::{DateTime, Utc};
643use onetaskgraph_plugin_api::ProjectedValue;
644use onetaskgraph_plugin_api::{
645    Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
646    ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
647    DependencySupport, Designator, Direction, Document, DocumentQuery, DocumentVersionCoverage,
648    Health, HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
649    MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
650    ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
651    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
652    StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
653    TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
654    WaitCause, WriteJournal, WriteSupport, system_clock,
655};
656use reqwest::{Client, StatusCode, Url};
657use schemars::{Schema, schema_for};
658use secrecy::{ExposeSecret, SecretString};
659use serde::{Deserialize, Serialize};
660use serde_json::{Value, json};
661
662pub mod accounting;
663mod assets;
664mod assignees;
665mod batch;
666pub mod change_token;
667mod expected;
668pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
669mod pacing;
670mod projection;
671mod relations;
672mod visibility;
673
674use accounting::Accounting;
675
676/// The registry name for this plugin.
677pub const KIND: &str = "github-projects";
678/// GitHub's maximum connection page size.
679pub const MAX_PAGE_SIZE: u32 = 100;
680/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
681/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
682/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
683pub const SEARCH_PAGE_SIZE: u32 = 20;
684/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
685/// its comments: the largest batch the node-count model prices at one point.
686///
687/// Each aliased item is resolved once, and what GitHub charges for it is the connections
688/// under it — its labels, its page of board memberships, the field values of each of those
689/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
690/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
691/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
692/// one point and fails if one item more would still be priced at one.
693pub const DETAIL_BATCH: usize = 24;
694/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
695///
696/// Each reference asks GitHub for one connection — a page of its board memberships, each with
697/// its `Status` value read by name rather than through a connection of field values — so fifty
698/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
699/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
700/// issues per source. `tests/point_cost.rs` prices the document at one point.
701pub const REFERENCE_BATCH: usize = 50;
702/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
703/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
704/// point.
705///
706/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
707/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
708/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
709/// if one item more would still be priced at one.
710pub const COPY_DETAIL_BATCH: usize = 21;
711/// How many mutations one aliased request of a copy's creates, closes and updates carries:
712/// [`graphql::CREATE_ISSUES`], [`graphql::CLOSE_ISSUES`] and [`graphql::UPDATE_ISSUES`].
713///
714/// Eleven is what GitHub was seen to accept in one request — the timing spike
715/// (`board-copy-speed-2026-10-18-spike-copy-timing-report`) sent eleven aliased mutations of four
716/// kinds, three `createIssue`, three `addProjectV2ItemById`, three field updates, an
717/// `addSubIssue` and an `addBlockedBy`, and every one ran — and no more was established.
718pub const MUTATION_BATCH: usize = 11;
719
720/// The most people one lookup ([`graphql::user_ids`]) asks for: GitHub's documented limit of
721/// [`NODE_COUNT_LIMIT`] nodes a request, each `user(login:)` field being one. Every person a
722/// call names that this process has not resolved is looked up in one request below it — so a
723/// call is split only past it.
724#[expect(
725    clippy::cast_possible_truncation,
726    reason = "500,000 fits every target's usize"
727)]
728pub const PEOPLE_PER_LOOKUP: usize = NODE_COUNT_LIMIT as usize;
729
730/// The most relation writes — `addSubIssue`, `removeSubIssue`, `addRelatesTo`,
731/// `removeRelatesTo` — one request carries: a 60-alias `addRelatesTo` answered HTTP 504 after
732/// landing 58 of its 60 (`spike-relations`), and 30 were accepted every time.
733pub const RELATION_BATCH: usize = 30;
734/// How many aliased writes one request of a copy's board field values and blocked-by edges
735/// carries: [`graphql::WRITE_FIELD_VALUES`], [`graphql::ADD_BLOCKED_BYS`] and, for a request
736/// holding both kinds, [`graphql::WRITE_FIELDS_AND_EDGES`].
737///
738/// Nineteen is the most aliases the feasibility spike
739/// (`board-copy-gaps-2026-10-24-spike-feasibility-report`) sent in one mutation request and
740/// saw GitHub price at the one point every mutation request costs; no larger request was
741/// established, so a copy holding more is split.
742pub const WRITE_BATCH: usize = 19;
743/// How many issues one [`graphql::DELETE_ISSUES`] request deletes: one request of eleven
744/// aliased `deleteIssue` met `RESOURCE_LIMITS_EXCEEDED` from the eighth alias on, and batches
745/// of five succeeded (the feasibility spike's undo-batching run).
746pub const DELETE_BATCH: usize = 5;
747/// How many origins one [`graphql::ORIGIN_LOOKUPS`] request asks about: the nine records of a
748/// plan and the project's document, and no more, since each carries a page of the board's field
749/// filter and one of the issue search.
750pub const ORIGIN_BATCH: usize = 10;
751/// How many searches one [`graphql::SEARCH_BATCH`] request asks: what `task find` asks of a
752/// board in one call, each search an aliased `search` field of its own.
753pub const SEARCH_BATCH: usize = 20;
754/// How many origins one [`graphql::SEARCH_BATCH`] request looks up beside its searches, each by
755/// the board's own field filter in an aliased `items` field of its own and by one of the
756/// request's searches.
757pub const SEARCH_BATCH_ORIGINS: usize = 5;
758/// The rows each search of a [`graphql::SEARCH_BATCH`] request asks for on its first page.
759///
760/// A batched find asks narrowed questions that each expect a handful of answers, and every
761/// search slot is priced at its page size: three rows a slot keep a first round of
762/// [`SEARCH_BATCH`] searches at three points, where a page of [`SEARCH_PAGE_SIZE`] each would
763/// cost twenty. A search with more to answer is walked on at [`SEARCH_PAGE_SIZE`] rows a page.
764pub const SEARCH_BATCH_FIRST_PAGE: u32 = 3;
765
766/// The rows each resumed page of one [`graphql::SEARCH_BATCH`] round asks for, when `resumed`
767/// of its slots — searches and origin item listings alike — resume from a cursor.
768///
769/// Every resumed page of a round shares one [`SEARCH_PAGE_SIZE`] of rows, rather than each
770/// asking that many: a round in which all twenty searches walked on at twenty rows a slot cost
771/// 22 points in one request, while sharing holds every round to what its first pages cost and
772/// one page more. A search with more to answer is walked on over more rounds, each page still
773/// read, so what a walk answers is unchanged; a round of one resumed search asks the page a
774/// search alone asks.
775#[must_use]
776pub fn search_batch_resumed_page(resumed: usize) -> u32 {
777    let resumed = u32::try_from(resumed.max(1)).unwrap_or(u32::MAX);
778    (SEARCH_PAGE_SIZE / resumed).max(1)
779}
780
781/// The page sizes of the most expensive [`graphql::SEARCH_BATCH`] round this source sends:
782/// every slot on its first page, at [`SEARCH_BATCH_FIRST_PAGE`] rows, but one resumed slot
783/// asking all of [`search_batch_resumed_page`]'s shared rows.
784///
785/// `tests/point_cost.rs` holds every round shape [`search_batch_resumed_page`] allows to cost
786/// no more than this, and the credentialed lane reconciles GitHub's own price of the document
787/// under it. [`largest_page_sizes`] still binds every slot at [`SEARCH_PAGE_SIZE`], an upper
788/// bound on this, so the offline worst case covers more than any round sends.
789#[must_use]
790pub fn search_batch_largest_round() -> Variables {
791    let mut sizes = largest_page_sizes();
792    for slot in 0..SEARCH_BATCH {
793        sizes.insert(format!("first{slot}"), SEARCH_BATCH_FIRST_PAGE);
794    }
795    for slot in 0..SEARCH_BATCH_ORIGINS {
796        sizes.insert(format!("itemsFirst{slot}"), SEARCH_BATCH_FIRST_PAGE);
797    }
798    sizes.insert("first0".to_owned(), search_batch_resumed_page(1));
799    sizes
800}
801/// How many comments one [`graphql::ADD_COMMENTS`] request adds, and how many issue updates one
802/// [`graphql::WRITE_TASKS`] request carries: [`MUTATION_BATCH`], the most aliased mutations
803/// GitHub was seen to accept in one request.
804pub const COMMENT_BATCH: usize = MUTATION_BATCH;
805/// How many board field values one [`graphql::WRITE_TASKS`] request carries, beside
806/// [`MUTATION_BATCH`] field clears: [`WRITE_BATCH`].
807pub const TASK_FIELD_BATCH: usize = WRITE_BATCH;
808
809/// The most nodes any one document this source sends may be asked to return.
810///
811/// GitHub's own published per-query ceiling, taken from
812/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
813/// workspace cannot hold a stale copy of somebody else's number. A query above it is
814/// **refused before it is executed**, whoever is asking and whatever board they are
815/// asking about — so this is a bound on the documents rather than a budget that runs out.
816///
817/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
818/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
819/// everything the credential does — two numbers against two limits, and this constant
820/// bounds only the first. The second is computed offline too, per document:
821/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
822/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
823/// lane. There is no constant like this one to hold a price under, because points are an
824/// hourly allowance rather than a per-call bound.
825///
826/// Neither is a session's price. What `session-cost.md` records of a whole session is its
827/// **requests** and its **worst-case nodes**; what a whole session spends in points is
828/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
829/// [`accounting`]. The module section on the three ways this source reaches an item says how
830/// the count is arrived at, and which of the page sizes below decide it.
831pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
832
833/// Nested connection size for the connections that hang off one item.
834///
835/// It multiplies through every document that reaches an item under a page — the count
836/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
837/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
838/// every document under these constants and fails naming any that reaches the limit, so
839/// raising this is caught there rather than by GitHub.
840const NESTED_PAGE_SIZE: u32 = 50;
841/// How many of one issue's board memberships are read when an issue is reached directly.
842///
843/// An issue reached through a search or through its own node id carries its board half in
844/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
845/// under a page of issues, so every point of it multiplies through the whole document and
846/// is paid for whether or not any issue is on a second board — which is why it is
847/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
848///
849/// **Three, because what a page misses is now recovered rather than refused**, and the
850/// recovery is what the value is chosen against. An issue whose entry for this board sits
851/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
852/// that page's own cursor — so the value trades a bound every read pays for a request only
853/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
854/// boards would pay that request *per issue*, which is order N against the one page per
855/// hundred issues a read costs today. At three it is only reached by an issue on four or
856/// more boards at once, which keeps the recovery path exceptional rather than routine for
857/// a plausible deployment.
858const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
859/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
860/// its two connections for.
861///
862/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
863/// second is a duplicate a copy already takes the first of. Both connections are walked to
864/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
865/// never what the answer is. It is small because every point of it is paid on every lookup,
866/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
867/// worst-case nodes than the one whole-board read they replaced.
868const ORIGIN_PAGE_SIZE: u32 = 3;
869/// How many carriers of each origin the batched [`graphql::ORIGIN_LOOKUPS`] asks each of its two
870/// connections for: one, the one carrier an origin names.
871///
872/// A second carrier still shows: the page then says there is more, and that origin is left to
873/// its own [`graphql::ORIGIN_LOOKUP`], which walks every page at `ORIGIN_PAGE_SIZE` — so a
874/// duplicate is found exactly as before, by the lookup that reads all of them. The feasibility
875/// spike (`board-copy-gaps-2026-10-24-spike-feasibility-report`) priced the batch of ten
876/// origins at two points with a first page of three and at one with a first page of one.
877const ORIGIN_LOOKUPS_PAGE_SIZE: u32 = 1;
878
879/// How many of one project's sub-issues one [`graphql::SUB_ISSUES`] page asks for: the most
880/// one point buys, since every row carries six connections GitHub charges for once per row.
881/// `session-cost.md` derives it; `tests/point_cost.rs` prices the document at one point and
882/// fails if one row more would still be priced at one.
883pub const MEMBER_PAGE_SIZE: u32 = 23;
884
885pub use github_graphql_node_count::{NodeCountError, Variables};
886
887/// The largest value this source can bind to each page-size variable its documents name.
888///
889/// Every `first:` in [`graphql`] reads one of these seven, and each is capped at the
890/// constant above it wherever a caller's own limit could reach it — `$first` at
891/// [`MAX_PAGE_SIZE`], `$commentFirst` at [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
892/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`, `$lookupFirst` at
893/// `ORIGIN_LOOKUPS_PAGE_SIZE`, `$memberFirst` at [`MEMBER_PAGE_SIZE`]. So this is the worst case a caller can drive this source to,
894/// not one configuration of it, which is what makes a bound computed under it a bound on
895/// every read.
896pub fn largest_page_sizes() -> Variables {
897    let mut sizes = Variables::from([
898        ("first".to_owned(), MAX_PAGE_SIZE),
899        ("commentFirst".to_owned(), MAX_PAGE_SIZE),
900        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
901        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
902        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
903        ("lookupFirst".to_owned(), ORIGIN_LOOKUPS_PAGE_SIZE),
904        ("memberFirst".to_owned(), MEMBER_PAGE_SIZE),
905    ]);
906    // Each slot of the batched search pages at most what one search does.
907    for slot in 0..SEARCH_BATCH {
908        sizes.insert(format!("first{slot}"), SEARCH_PAGE_SIZE);
909    }
910    for slot in 0..SEARCH_BATCH_ORIGINS {
911        sizes.insert(format!("itemsFirst{slot}"), SEARCH_PAGE_SIZE);
912    }
913    sizes
914}
915
916/// The most nodes `document` could be asked to return, by GitHub's published rules.
917///
918/// Computed offline from the document's own text under [`largest_page_sizes`] — no
919/// network, no credential and no schema — by
920/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
921/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
922/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
923///
924/// # Errors
925///
926/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
927/// no single operation, or binds a page size this source does not name — each of which is
928/// a defect in the document rather than a number.
929pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
930    node_count(document, &largest_page_sizes())
931}
932
933/// The most rate-limit points one call of `document` could spend, by GitHub's published
934/// rules.
935///
936/// Computed offline from the document's own text under [`largest_page_sizes`] — no
937/// network, no credential and no schema — by
938/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
939/// This is `cost`, metered **per hour** against the allowance one credential shares across
940/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
941/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
942/// under, so what `tests/point_cost.rs` does with it is pin every document in
943/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
944/// figures against GitHub's own reported `cost`.
945///
946/// # Errors
947///
948/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
949/// no single operation, or binds a page size this source does not name — each of which is
950/// a defect in the document rather than a number.
951pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
952    github_graphql_node_count::point_cost(document, &largest_page_sizes())
953}
954
955/// The rate-limit points one call of `document` spends under `variables`, by GitHub's published
956/// rules: [`worst_case_point_cost`] is this under [`largest_page_sizes`], and a request this
957/// source sent is priced by it under the page sizes that request really bound — what GitHub
958/// charges the request, since it charges every connection at the page size asked for.
959///
960/// # Errors
961///
962/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
963/// no single operation, or binds a page size `variables` does not name.
964pub fn point_cost(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
965    github_graphql_node_count::point_cost(document, variables)
966}
967
968/// The most nodes `document` could be asked to return under `variables`.
969///
970/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
971/// [`accounting`] is this under the bindings one request really sent — one spelling of the
972/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
973/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
974///
975/// # Errors
976///
977/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
978/// no single operation, or binds a page size `variables` does not name.
979pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
980    github_graphql_node_count::node_count(document, variables)
981}
982
983/// `variables` with the page sizes and flags a token twin's inline issue selection binds,
984/// at a read's page sizes.
985fn token_variables(mut variables: Value) -> Value {
986    if let Some(object) = variables.as_object_mut() {
987        object.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
988        object.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
989        object.insert("duplicates".to_owned(), json!(true));
990    }
991    variables
992}
993
994/// The input members a mutation names the issue or board item it writes by.
995const WRITTEN_NAMES: [&str; 4] = ["id", "issueId", "subjectId", "itemId"];
996
997tokio::task_local! {
998    /// The source a targeted update is running on, by its address, while it runs: what has its
999    /// mutations sent as the twins selecting the whole issue, so the update answers the version
1000    /// its own last response shows (see `GitHubProjectsSource::written_tokens`).
1001    static SPELLING: usize;
1002}
1003
1004/// The issue-title prefix that makes a board issue a document.
1005///
1006/// A GitHub Projects board has no document type — it holds issues — so the discriminator
1007/// is the title, and this is the whole of it: an issue whose title begins with these bytes
1008/// is a document and every other issue is the task or project the sub-issue rule makes it.
1009///
1010/// It is spelled **once**, here, and read rather than restated everywhere else — including
1011/// by the shared journeys, which take it from this constant so a board fixture cannot
1012/// drift from what this source reads. `docs/metadata.md` records the two consequences that
1013/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
1014/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
1015/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
1016pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
1017
1018/// The most characters GitHub stores in one issue's body: a longer one is refused as
1019/// `body is too long (maximum is 65536 characters)`.
1020pub const BODY_LIMIT: u64 = 65_536;
1021
1022/// The address a new upload's URL is spelled at, as GitHub serves a user attachment: what the
1023/// size of a body is measured with before an asset of it is uploaded, a UUID long.
1024const PREDICTED_ATTACHMENT_URL: &str =
1025    "https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000";
1026
1027/// Exact GraphQL query documents issued by this plugin.
1028///
1029/// Keeping the production documents here lets the pinned-schema test validate the same
1030/// bytes that are sent to GitHub, rather than a test-only copy which could drift
1031/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
1032/// field, and its guarded caller always supplies the complete existing option set with ids.
1033pub mod graphql {
1034    /// The board half of one item: the field values every document here reads it from.
1035    ///
1036    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
1037    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
1038    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
1039    /// *the same value*, because
1040    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
1041    /// one path. Three spellings of it is what would drift, so there is one.
1042    ///
1043    /// The `Status` option and this source's own origin text field are the whole of it. It
1044    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
1045    /// content, so it holds nothing the content's own `labels` do not already say, and it
1046    /// would sit a label connection two page sizes deep.
1047    macro_rules! board_item_values {
1048        () => {
1049            r#"fieldValues(first:$nestedFirst){nodes{
1050          ... on ProjectV2ItemFieldSingleSelectValue{name field{
1051            ... on ProjectV2SingleSelectField{id name options{id name}}
1052          }}
1053          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
1054        }pageInfo{hasNextPage}}"#
1055        };
1056    }
1057
1058    /// The variable every document reading an issue declares for the relationship events its
1059    /// change token counts, with its value as the default, so no caller binds it.
1060    ///
1061    /// A variable rather than a literal list because every argument a production document
1062    /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
1063    /// its default to the pinned enum. Its default is the six event types GitHub records a
1064    /// relationship added or removed with: a dependency on either end, as `blockedBy` and
1065    /// `blocking`, so one a person makes from the far end moves this end's count too; and a
1066    /// project membership on the task, as its parent, which GitHub records there whichever
1067    /// issue the move was made from. A sub-issue's events on its parent are not counted: a
1068    /// project carries no task token, and a copy writes nothing of a task its own sub-issues
1069    /// decide. [`super::change_token`] says why the count is in the token.
1070    macro_rules! relationship_events_variable {
1071        () => {
1072            ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
1073        };
1074    }
1075
1076    /// Everything this source reads about one issue, wherever it reaches that issue.
1077    ///
1078    /// A macro rather than a constant so the three documents below can `concat!` it: one
1079    /// spelling of these fields is what makes an issue read through the board-scoped
1080    /// search, through its own node id, and through its project's sub-issue relationship
1081    /// resolve to *the same* item, which is the whole of what
1082    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
1083    ///
1084    /// `projectItems` is what carries the board half of an issue: the board item's own id
1085    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
1086    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
1087    /// issue rather than on the board, which is what makes the cost of a read proportional
1088    /// to what was asked for instead of to the board's size.
1089    ///
1090    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
1091    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
1092    /// not on that page: a page here is where the search for the entry starts rather than
1093    /// where it ends.
1094    ///
1095    /// It does **not** select the board's `Labels` field value, and that is the whole of
1096    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
1097    /// a label connection there sits under `fieldValues` under `projectItems` under a page
1098    /// of issues, spending `$nestedFirst` twice down one path, and took
1099    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
1100    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
1101    /// above, and that connection is where every label this source reports comes from. No
1102    /// document in this module selects the board field any longer, [`BOARD`] included; the
1103    /// module documentation records why nothing it could have held is lost.
1104    macro_rules! board_issue {
1105        () => {
1106            concat!(
1107                r#" fragment BoardIssue on Issue{"#,
1108                board_issue_fields!(),
1109                "}"
1110            )
1111        };
1112    }
1113
1114    /// What [`board_issue!`] selects of an issue, written out where a document selects it
1115    /// inline rather than through the fragment.
1116    macro_rules! board_issue_fields {
1117        () => {
1118            concat!(
1119                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}
1120      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
1121      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1122        "#,
1123                board_item_values!(),
1124                r#"}pageInfo{hasNextPage endCursor}}"#
1125            )
1126        };
1127    }
1128
1129    /// One page of a repository's issues, newest first, each with when it was created and its
1130    /// body: what a retried copy reads to find the issue its earlier attempt created, by the
1131    /// create key in that body's metadata slot — never through search, whose index lags.
1132    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}}}}";
1133    /// Every issue of one board, found by a search scoped to that board.
1134    ///
1135    /// This is how the projects a board holds are listed, and it selects no `items`
1136    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
1137    /// container walked page by page, so nothing nested inside a board item is paid for.
1138    /// Which of the issues it returns is a project is then read off `parent` — GitHub
1139    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
1140    /// discriminator has to be applied to the field, which is a scalar on the issue and
1141    /// costs nothing.
1142    pub const SEARCH_ISSUES: &str = concat!(
1143        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1144        relationship_events_variable!(),
1145        r#"){
1146      search(query:$search,type:$type,first:$first,after:$after){
1147        pageInfo{hasNextPage endCursor}
1148        nodes{__typename ...BoardIssue}
1149      }
1150    }"#,
1151        board_issue!()
1152    );
1153
1154    /// What a dependency read selects of each far end: enough to say which kind of item it
1155    /// is, its body included for the kind marker.
1156    macro_rules! related_issue {
1157        () => {
1158            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
1159        };
1160    }
1161
1162    /// One issue by its own node id, which is what a qualified id names here — with what a
1163    /// write of it needs and the issue does not carry in `board_issue!`: the field
1164    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
1165    ///
1166    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
1167    /// answers a write made moments ago with the value from before it, and resolving a node
1168    /// id does not.
1169    ///
1170    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
1171    /// reads it by its own id, and with them that one read answers everything the write
1172    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
1173    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
1174    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
1175    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
1176    /// they sit under one item, and this read is still one point.
1177    pub const ISSUE: &str = concat!(
1178        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1179        relationship_events_variable!(),
1180        r#"){
1181      node(id:$id){__typename ...BoardIssue ... on Issue{
1182        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1183          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1184          ... on ProjectV2Field{__typename id name dataType}
1185        }pageInfo{hasNextPage}}}}}
1186        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1187      }}
1188    }"#,
1189        board_issue!(),
1190        related_issue!()
1191    );
1192
1193    /// One document by its own node id, as [`ISSUE`] reads one, with the project it is filed
1194    /// under and that project's members beside it: the project issue whole, and a page of its
1195    /// sub-issues, each as [`SUB_ISSUES`] carries one but with no page of what blocks it.
1196    ///
1197    /// What a guarded write of a document's metadata reads, and what reading a document by its
1198    /// id is: a write to a board the boundary screens is held to who may read the document,
1199    /// which is the project's and every member's classification as much as its own, and here
1200    /// the one request that holds the version the write is compared against answers all of it.
1201    /// Its members carry no edges, which nothing classifying them reads.
1202    pub const DOCUMENT_IN_PROJECT: &str = concat!(
1203        r#"query($id:ID!,$first:Int!,$memberFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1204        relationship_events_variable!(),
1205        r#"){
1206      node(id:$id){__typename ...BoardIssue ... on Issue{
1207        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1208          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1209          ... on ProjectV2Field{__typename id name dataType}
1210        }pageInfo{hasNextPage}}}}}
1211        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1212        project:parent{__typename ...BoardIssue
1213          subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}
1214        }
1215      }}
1216    }"#,
1217        board_issue!(),
1218        related_issue!()
1219    );
1220
1221    /// One project's tasks: the sub-issues of the issue that project is, each with a page of
1222    /// what blocks it.
1223    ///
1224    /// Paged at `$memberFirst` — [`MEMBER_PAGE_SIZE`](super::MEMBER_PAGE_SIZE), the most rows
1225    /// one point buys — rather than at `$first`, because every row carries six connections and
1226    /// a hundred-row page cost six points to list a project of ten.
1227    ///
1228    /// The work this costs is the project's own size. Nothing about it grows as the board
1229    /// gains projects, or as those projects gain tasks.
1230    ///
1231    /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1232    /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1233    /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1234    /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1235    /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1236    /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1237    /// It adds a connection under each issue of the page — one rate-limit point per page,
1238    /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1239    /// answer questions that never read an edge.
1240    ///
1241    /// **The project issue itself rides here too**, with the page of what blocks it, because
1242    /// the reader of a project's tasks reads the project next: a copy writing the project reads
1243    /// it and its edges before it writes them. Carried on the request that already names the
1244    /// project, the first page answers that read and the dependency read after it, at the
1245    /// state GitHub answered in this command, rather than spending one more read on an item
1246    /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1247    /// copy's read of a task this listing carried is answered by it rather than sent again.
1248    /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1249    pub const SUB_ISSUES: &str = concat!(
1250        r#"query($id:ID!,$memberFirst:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1251        relationship_events_variable!(),
1252        r#"){
1253      node(id:$id){__typename ...BoardIssue
1254        ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1255          subIssues(first:$memberFirst,after:$after){
1256          pageInfo{hasNextPage endCursor}
1257          nodes{__typename ...BoardIssue ... on Issue{
1258            blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1259          }}
1260        }}}
1261    }"#,
1262        board_issue!(),
1263        related_issue!()
1264    );
1265
1266    /// Everything this source reads about one issue comment, wherever it reaches one.
1267    ///
1268    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1269    /// and a comment just edited are handed to one mapper, so they are selected by one
1270    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1271    /// longer exists, and `login` is the one member every kind of actor carries; `__typename`
1272    /// says whether it is a `Bot`, whose `login` GitHub's own pages spell with `[bot]` after it.
1273    macro_rules! issue_comment {
1274        () => {
1275            "id author{__typename login} createdAt updatedAt body url"
1276        };
1277    }
1278
1279    /// What a read of the board's own `items` selects of each item's content.
1280    ///
1281    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1282    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1283    /// content by one spelling.
1284    macro_rules! board_item_content {
1285        () => { board_item_content!("") };
1286        ($comments:expr) => {
1287            concat!(r#" content{
1288        ... 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#"}
1289        ... on PullRequest{__typename id}
1290        ... on DraftIssue{__typename id title body createdAt updatedAt}
1291      }"#)
1292        };
1293    }
1294
1295    macro_rules! board_listing {
1296        ($variables:expr, $comments:expr) => { concat!(
1297        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1298        relationship_events_variable!(), $variables,
1299        r#"){
1300      owner:repositoryOwner(login:$owner){
1301        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1302      }
1303    } fragment Board on ProjectV2 { id title
1304      fields(first:$nestedFirst){nodes{
1305        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1306        ... on ProjectV2Field{__typename id name dataType}
1307      }pageInfo{hasNextPage}}
1308      items(first:$first,after:$after){nodes{id updatedAt "#,
1309        board_item_values!(),
1310        board_item_content!($comments),
1311        r#"} pageInfo{hasNextPage endCursor}}
1312    }"#
1313    ) };
1314    }
1315
1316    /// Reads the board's fields and one page of its items.
1317    pub const BOARD: &str = board_listing!("", "");
1318
1319    /// A board page carrying each issue's first comment page in the same request.
1320    pub const BOARD_WITH_COMMENTS: &str = concat!(
1321        board_listing!(
1322            ",$commentFirst:Int!",
1323            concat!(
1324                "comments(first:$commentFirst){nodes{",
1325                issue_comment!(),
1326                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}"
1327            )
1328        ),
1329        related_issue!()
1330    );
1331
1332    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1333    /// the board.
1334    ///
1335    /// **`originItems`** is the board's own items narrowed by its own field filter —
1336    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1337    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1338    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1339    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1340    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1341    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1342    /// fresh `addProjectV2ItemById` the way that connection does.
1343    ///
1344    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1345    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1346    /// indexes that comment, and the index catches up with a write in a second or two rather
1347    /// than in minutes, so it finds a carrier another process wrote that the first read is
1348    /// still behind on.
1349    ///
1350    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1351    /// — and resumes from its own cursor; a connection already walked to its end is resumed
1352    /// from its last cursor, which answers an empty page. Every candidate either read returns
1353    /// is confirmed against its own origin field before it is reported, so a token match of
1354    /// the search or anything else the filter admits never is.
1355    ///
1356    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1357    /// own whole reads counts this one among them.
1358    pub const ORIGIN_LOOKUP: &str = concat!(
1359        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1360        relationship_events_variable!(),
1361        r#"){
1362      originItems:repositoryOwner(login:$owner){
1363        ... on ProjectV2Owner{projectV2(number:$number){
1364          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1365        board_item_values!(),
1366        board_item_content!(),
1367        r#"} pageInfo{hasNextPage endCursor}}
1368        }}
1369      }
1370      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1371        pageInfo{hasNextPage endCursor}
1372        nodes{__typename ...BoardIssue}
1373      }
1374    }"#,
1375        board_issue!()
1376    );
1377
1378    /// Every closed item of the board, by the board's own field filter — what
1379    /// `task status reconcile` reads, and nothing else of the board.
1380    ///
1381    /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1382    /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1383    /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1384    /// closed, with a reason and a card that contradict each other, from the item node it
1385    /// already carries, so a filter that also let open items through would cost reads and
1386    /// write nothing wrong.
1387    ///
1388    /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1389    /// option writes that follow need no read of the board. The root is aliased `closedItems`
1390    /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1391    /// among them.
1392    pub const CLOSED_ITEMS: &str = concat!(
1393        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1394        relationship_events_variable!(),
1395        r#"){
1396      closedItems:repositoryOwner(login:$owner){
1397        ... on ProjectV2Owner{projectV2(number:$number){id
1398          fields(first:$nestedFirst){nodes{
1399            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1400            ... on ProjectV2Field{__typename id name dataType}
1401          }pageInfo{hasNextPage}}
1402          items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1403        board_item_values!(),
1404        board_item_content!(),
1405        r#"} pageInfo{hasNextPage endCursor}}
1406        }}
1407      }
1408    }"#
1409    );
1410
1411    /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1412    macro_rules! reference_alias {
1413        ($n:literal) => {
1414            concat!(
1415                "\n      r",
1416                stringify!($n),
1417                ":repository(owner:$owner",
1418                stringify!($n),
1419                ",name:$name",
1420                stringify!($n),
1421                "){issue(number:$number",
1422                stringify!($n),
1423                "){...HeldIssue}}"
1424            )
1425        };
1426    }
1427
1428    /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1429    /// number — the one read `task status complete` makes of a source, and nothing else of
1430    /// the board: no `items`, no search and no project read.
1431    ///
1432    /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1433    /// references one point.** It selects no labels, and of each board membership only the
1434    /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1435    /// connection, its page of memberships, where the whole fragment asks for five. What a
1436    /// completion writes is a card and, at most, the issue's state, so the labels and the other
1437    /// field values it leaves out are never what it needs; an item read this way is held for
1438    /// that write alone and never answers another read. An issue whose entry for this board is
1439    /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1440    /// every other read of an issue recovers one.
1441    ///
1442    /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1443    /// fewer references binds the slots it has none for to its last one.
1444    pub const ISSUES_BY_REFERENCE: &str = concat!(
1445        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!"#,
1446        relationship_events_variable!(),
1447        r#"){"#,
1448        reference_alias!(0),
1449        reference_alias!(1),
1450        reference_alias!(2),
1451        reference_alias!(3),
1452        reference_alias!(4),
1453        reference_alias!(5),
1454        reference_alias!(6),
1455        reference_alias!(7),
1456        reference_alias!(8),
1457        reference_alias!(9),
1458        reference_alias!(10),
1459        reference_alias!(11),
1460        reference_alias!(12),
1461        reference_alias!(13),
1462        reference_alias!(14),
1463        reference_alias!(15),
1464        reference_alias!(16),
1465        reference_alias!(17),
1466        reference_alias!(18),
1467        reference_alias!(19),
1468        reference_alias!(20),
1469        reference_alias!(21),
1470        reference_alias!(22),
1471        reference_alias!(23),
1472        reference_alias!(24),
1473        reference_alias!(25),
1474        reference_alias!(26),
1475        reference_alias!(27),
1476        reference_alias!(28),
1477        reference_alias!(29),
1478        reference_alias!(30),
1479        reference_alias!(31),
1480        reference_alias!(32),
1481        reference_alias!(33),
1482        reference_alias!(34),
1483        reference_alias!(35),
1484        reference_alias!(36),
1485        reference_alias!(37),
1486        reference_alias!(38),
1487        reference_alias!(39),
1488        reference_alias!(40),
1489        reference_alias!(41),
1490        reference_alias!(42),
1491        reference_alias!(43),
1492        reference_alias!(44),
1493        reference_alias!(45),
1494        reference_alias!(46),
1495        reference_alias!(47),
1496        reference_alias!(48),
1497        reference_alias!(49),
1498        "\n    }",
1499        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}
1500      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1501        status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1502          ... on ProjectV2SingleSelectField{id name options{id name}}
1503        }}}
1504      }pageInfo{hasNextPage endCursor}}}"#
1505    );
1506
1507    /// The board's own id and field definitions, and not one of its items.
1508    ///
1509    /// What a write needs of the board when the item it writes does not say: the id a field
1510    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1511    /// origin fields. It selects no `items`, so what it costs is the board's field list
1512    /// however many items the board holds — and it decides nothing about which items those
1513    /// are, which is the question a read of one item by its own id answers instead.
1514    ///
1515    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1516    /// board's item reads by their root counts this one among them.
1517    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1518      boardFields:repositoryOwner(login:$owner){
1519        ... on ProjectV2Owner{projectV2(number:$number){id
1520          fields(first:$nestedFirst){nodes{
1521            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1522            ... on ProjectV2Field{__typename id name dataType}
1523          }pageInfo{hasNextPage}}
1524        }}
1525      }
1526    }"#;
1527
1528    /// One board draft by its own node id, with the board item it sits in.
1529    ///
1530    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1531    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1532    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1533    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1534    /// board listing hands it to, and nothing has to list the board to find one.
1535    pub const DRAFT: &str = concat!(
1536        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1537      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1538        projectV2Items(first:$boardItems){nodes{id project{id number}
1539        "#,
1540        board_item_values!(),
1541        r#"}pageInfo{hasNextPage endCursor}}}}
1542    }"#
1543    );
1544
1545    /// One issue's board memberships alone, walked past the page a read of it carried.
1546    ///
1547    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1548    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1549    /// boards than that page holds may have this board's entry past its end. This asks that
1550    /// one issue for its memberships and nothing else — the caller already holds the issue —
1551    /// so an answer of "this board does not hold it" is only ever given about a connection
1552    /// read to exhaustion.
1553    ///
1554    /// It selects the board item's id, its project number and the same
1555    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1556    /// very same resolver: an issue recovered this way reports the same title, the same
1557    /// status, the same labels and the same qualified id as one whose entry was on the
1558    /// page.
1559    ///
1560    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1561    /// multiplies through it and the membership connection can be walked at
1562    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1563    /// further request for any issue a person really keeps.
1564    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1565        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1566      node(id:$id){
1567        ... on Issue{projectItems(first:$first,after:$after){
1568          nodes{id updatedAt project{id number}
1569        "#,
1570        board_item_values!(),
1571        r#"}
1572          pageInfo{hasNextPage endCursor}}}
1573      }
1574    }"#
1575    );
1576    /// Whether the board's Project is public — half of what decides whether a write here can
1577    /// be read by anybody. Needs the `read:project` scope.
1578    pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1579    /// Resolves the configured repository's node id, which creating an issue requires.
1580    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1581    /// What creating an issue needs and has not read yet: the board's own id and field
1582    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1583    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1584    ///
1585    /// Sent at the point a create knows which repository it is for, when neither half is
1586    /// already known to this process; a create needing only one of them sends that one's own
1587    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1588    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1589    /// status.
1590    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1591      boardFields:repositoryOwner(login:$owner){
1592        ... on ProjectV2Owner{projectV2(number:$number){id
1593          fields(first:$nestedFirst){nodes{
1594            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1595            ... on ProjectV2Field{__typename id name dataType}
1596          }pageInfo{hasNextPage}}
1597        }}
1598      }
1599      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1600    }"#;
1601    /// Reads both dependency directions for one issue, with each far end's own kind — and
1602    /// the issue's own body, which is where an edge to another source is recorded, so that
1603    /// half of a dependency read needs no second read of the issue or of the board.
1604    pub const ISSUE_DEPENDENCIES: &str = concat!(
1605        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1606      ... on Issue{body
1607        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1608        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1609      }}}"#,
1610        related_issue!()
1611    );
1612    /// What one item depends on: [`ISSUE_DEPENDENCIES`] narrowed to `blockedBy`, so a
1613    /// dependency read asks GitHub for the one direction it answers. The issue's `body` rides
1614    /// along because its metadata slot is where an edge to another source is recorded — and a
1615    /// board draft's `body` too, with the one board item GitHub links a draft to and, aliased
1616    /// `board`, this board's own id, so a draft's recorded edges are read off this same answer
1617    /// and only when that item is this board's: no draft read and no board read follow it.
1618    ///
1619    /// The draft's board item is asked for under a root of its own, aliased `draft`, rather
1620    /// than beside its `body` under `node`. GitHub counts the type-conditional fragments of
1621    /// one selection as the largest of them — `blockedBy`'s 100 alone, where this workspace's
1622    /// calculation adds the draft's 3 — while separate roots are summed by both, so this is
1623    /// the spelling whose node count GitHub and the calculation agree on. Both price it at
1624    /// one point; the credentialed lane's reconciliation is what holds the two to that.
1625    pub const ISSUE_DEPENDS_ON: &str = concat!(
1626        r#"query($id:ID!,$first:Int!,$after:String,$owner:String!,$number:Int!,$boardItems:Int!){
1627      node(id:$id){__typename
1628      ... on Issue{body
1629        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1630      }
1631      ... on DraftIssue{id body}}
1632      draft:node(id:$id){... on DraftIssue{projectV2Items(first:$boardItems){nodes{project{id number}}pageInfo{hasNextPage}}}}
1633      board:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id}}}}"#,
1634        related_issue!()
1635    );
1636    /// What depends on one issue: [`ISSUE_DEPENDENCIES`] with its `blocking` alone. No `body`:
1637    /// the reverse of a recorded edge is derived from the far end, never written down here.
1638    pub const ISSUE_DEPENDED_ON_BY: &str = concat!(
1639        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1640      ... on Issue{
1641        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1642      }}}"#,
1643        related_issue!()
1644    );
1645    /// Creates one issue. Outside a copy it is created on no board, and [`ADD_TO_BOARD`] files
1646    /// it; inside one, its input names the board in `projectV2Ids` and its project in
1647    /// `parentIssueId`, so this one request files it under both — see
1648    /// [`GitHubProjectsSource::create_filed_issue`](super::GitHubProjectsSource).
1649    pub const CREATE_ISSUE: &str =
1650        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1651    /// Puts an existing issue on the configured board.
1652    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1653    /// Updates an issue's visible fields and its open or closed state in one call.
1654    pub const UPDATE_ISSUE: &str =
1655        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1656    /// Updates an existing draft's user-visible fields.
1657    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1658    /// Updates a text or single-select value on one project item.
1659    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}}}}}}}}"#;
1660    /// Writes up to six board fields and clears up to three in one ordered mutation: every
1661    /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1662    /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1663    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}}}"#;
1664    /// Clears one project item's value of one field, which is what a `none` priority is.
1665    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}}}}}}}}"#;
1666    /// Creates one field: a single-select one with its options, or a text one for a projected
1667    /// metadata value. Only the guarded field setup may use this document, and only for a
1668    /// field the board lacks.
1669    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1670    /// Replaces a single-select field's options. Only the guarded field setup — the
1671    /// `status-options` and `fields` operations — may use this document, because GitHub
1672    /// treats the input as the complete option list.
1673    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1674    /// A fresh snapshot of the Status field and every board item's assignment.
1675    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}}}}}}"#;
1676    /// Files one issue under another as a sub-issue, which is what project membership is.
1677    pub const ADD_SUB_ISSUE: &str =
1678        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1679    /// Takes one issue back out of its parent.
1680    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1681    /// Adds GitHub's native issue blocked-by relationship.
1682    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1683    /// One origin of [`ORIGIN_LOOKUPS`]: [`ORIGIN_LOOKUP`]'s two connections for the origin the
1684    /// `$filter<n>` and `$search<n>` of slot `<n>` name, sent only while `$ask<n>` holds.
1685    macro_rules! origin_lookup_alias {
1686        ($n:literal) => {
1687            concat!(
1688                "\n      o",
1689                stringify!($n),
1690                ":repositoryOwner(login:$owner) @include(if:$ask",
1691                stringify!($n),
1692                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$lookupFirst,query:$filter",
1693                stringify!($n),
1694                "){nodes{id updatedAt ",
1695                board_item_values!(),
1696                board_item_content!(),
1697                "} pageInfo{hasNextPage endCursor}}}}}\n      s",
1698                stringify!($n),
1699                ":search(query:$search",
1700                stringify!($n),
1701                ",type:$type,first:$lookupFirst) @include(if:$ask",
1702                stringify!($n),
1703                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}"
1704            )
1705        };
1706    }
1707
1708    /// [`ORIGIN_LOOKUPS`], with `$extra_variables` declared after its own and `$extra_fields`
1709    /// selected beside them: one spelling of the batched lookup for the documents that send it.
1710    macro_rules! origin_lookups {
1711        ($extra_variables:literal, $extra_fields:literal) => {
1712            concat!(
1713                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!"#,
1714                relationship_events_variable!(),
1715                $extra_variables,
1716                r#"){
1717      boardFields:repositoryOwner(login:$owner){
1718        ... on ProjectV2Owner{projectV2(number:$number){id
1719          fields(first:$nestedFirst){nodes{
1720            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1721            ... on ProjectV2Field{__typename id name dataType}
1722          }pageInfo{hasNextPage}}
1723        }}
1724      }
1725      repository(owner:$repositoryOwner,name:$repositoryName){id databaseId nameWithOwner}"#,
1726                $extra_fields,
1727                origin_lookup_alias!(0),
1728                origin_lookup_alias!(1),
1729                origin_lookup_alias!(2),
1730                origin_lookup_alias!(3),
1731                origin_lookup_alias!(4),
1732                origin_lookup_alias!(5),
1733                origin_lookup_alias!(6),
1734                origin_lookup_alias!(7),
1735                origin_lookup_alias!(8),
1736                origin_lookup_alias!(9),
1737                "\n    }",
1738                board_issue!()
1739            )
1740        };
1741    }
1742
1743    /// The first page of [`ORIGIN_LOOKUP`] for up to [`ORIGIN_BATCH`](super::ORIGIN_BATCH)
1744    /// origins a copy is about to look for, and everything creating an item then reads —
1745    /// the board's own id and field definitions, as [`CREATION_CONTEXT`] reads them, and the node
1746    /// id and the numeric id of the repository an issue is created in — in one request.
1747    ///
1748    /// What a copy reads before it writes anything, so that finding none of its records on the
1749    /// board and creating every one costs this read and the writes. An origin whose page says
1750    /// there is more is left to its own [`ORIGIN_LOOKUP`]. The repository's numeric id is what an
1751    /// image upload names, so a copy carrying assets needs no `GET /repos/{owner}/{repo}` either.
1752    pub const ORIGIN_LOOKUPS: &str = origin_lookups!("", "");
1753
1754    /// [`ORIGIN_LOOKUPS`], with a page of the members of the project `$project` beside it, each
1755    /// trimmed to what classifying it reads — its id, its parent, its body and its repository,
1756    /// and no connection under it.
1757    ///
1758    /// What a document copy sends where the public boundary screens its write: the document is
1759    /// held to who may read its project, which is its members' classification as much as the
1760    /// project's own, and this request carries both the project — among the origins it asks —
1761    /// and its members. GitHub's own dry run prices it at the one point [`ORIGIN_LOOKUPS`]
1762    /// costs alone (`docs/measurements/board-copy-gaps-2026-10-24/coalesced-members-dry-run`).
1763    pub const ORIGIN_LOOKUPS_WITH_MEMBERS: &str = origin_lookups!(
1764        ",$project:ID!,$memberFirst:Int!",
1765        "\n      members:node(id:$project){... on Issue{subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{id body parent{id} repository{nameWithOwner}}}}}"
1766    );
1767
1768    /// One slot `<n>` of a copy's aliased writes: `$<prefix><n>` is its input and `$send<n>`
1769    /// whether it is sent.
1770    macro_rules! write_slot {
1771        ($prefix:literal, $field:literal, $selection:literal, $n:literal) => {
1772            concat!(
1773                " ",
1774                $prefix,
1775                stringify!($n),
1776                ":",
1777                $field,
1778                "(input:$",
1779                $prefix,
1780                stringify!($n),
1781                ") @include(if:$send",
1782                stringify!($n),
1783                "){",
1784                $selection,
1785                "}"
1786            )
1787        };
1788    }
1789
1790    /// The variables of a copy's aliased writes: eleven inputs of `$type` named `$<prefix><n>`,
1791    /// and eleven `$send<n>`.
1792    macro_rules! write_variables {
1793        ($prefix:literal, $type:literal) => {
1794            concat!(
1795                "mutation(",
1796                "$",
1797                $prefix,
1798                "0:",
1799                $type,
1800                "!,$",
1801                $prefix,
1802                "1:",
1803                $type,
1804                "!,$",
1805                $prefix,
1806                "2:",
1807                $type,
1808                "!,$",
1809                $prefix,
1810                "3:",
1811                $type,
1812                "!,$",
1813                $prefix,
1814                "4:",
1815                $type,
1816                "!,$",
1817                $prefix,
1818                "5:",
1819                $type,
1820                "!,$",
1821                $prefix,
1822                "6:",
1823                $type,
1824                "!,$",
1825                $prefix,
1826                "7:",
1827                $type,
1828                "!,$",
1829                $prefix,
1830                "8:",
1831                $type,
1832                "!,$",
1833                $prefix,
1834                "9:",
1835                $type,
1836                "!,$",
1837                $prefix,
1838                "10:",
1839                $type,
1840                "!,",
1841                "$send0:Boolean!,$send1:Boolean!,$send2:Boolean!,$send3:Boolean!,$send4:Boolean!,",
1842                "$send5:Boolean!,$send6:Boolean!,$send7:Boolean!,$send8:Boolean!,$send9:Boolean!,",
1843                "$send10:Boolean!){"
1844            )
1845        };
1846    }
1847
1848    /// A copy's aliased writes of one kind: [`MUTATION_BATCH`](super::MUTATION_BATCH) slots of
1849    /// `$field`, each sent only while its `$send<n>` holds.
1850    macro_rules! write_batch {
1851        ($prefix:literal, $type:literal, $field:literal, $selection:literal) => {
1852            concat!(
1853                write_variables!($prefix, $type),
1854                write_slot!($prefix, $field, $selection, 0),
1855                write_slot!($prefix, $field, $selection, 1),
1856                write_slot!($prefix, $field, $selection, 2),
1857                write_slot!($prefix, $field, $selection, 3),
1858                write_slot!($prefix, $field, $selection, 4),
1859                write_slot!($prefix, $field, $selection, 5),
1860                write_slot!($prefix, $field, $selection, 6),
1861                write_slot!($prefix, $field, $selection, 7),
1862                write_slot!($prefix, $field, $selection, 8),
1863                write_slot!($prefix, $field, $selection, 9),
1864                write_slot!($prefix, $field, $selection, 10),
1865                "}"
1866            )
1867        };
1868    }
1869
1870    /// Creates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues in one request, each filed on
1871    /// the board and under its project by the same `createIssue` — its `projectV2Ids` and
1872    /// `parentIssueId` — as [`CREATE_ISSUE`] creates one.
1873    pub const CREATE_ISSUES: &str = write_batch!(
1874        "create",
1875        "CreateIssueInput",
1876        "createIssue",
1877        "issue{id number url}"
1878    );
1879
1880    /// One issue of [`CREATED_BOARD_ITEMS`]: the board items the issue `$id<n>` names is on, with
1881    /// the `Status` value of each, read only while `$read<n>` holds.
1882    macro_rules! created_item_alias {
1883        ($n:literal) => {
1884            concat!(
1885                "\n      i",
1886                stringify!($n),
1887                ":node(id:$id",
1888                stringify!($n),
1889                ") @include(if:$read",
1890                stringify!($n),
1891                "){... on Issue{id projectItems(first:$boardItems){nodes{id project{id} status:fieldValueByName(name:$statusName){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}"
1892            )
1893        };
1894    }
1895
1896    /// The board item of each issue a copy created, up to
1897    /// [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH) at a time, with its `Status` as the board
1898    /// set it: what `createIssue`'s `projectV2Ids` files the issue as, which GitHub adds after
1899    /// answering the create, so its id comes from a read made once every create has been sent.
1900    pub const CREATED_BOARD_ITEMS: &str = concat!(
1901        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!){"#,
1902        created_item_alias!(0),
1903        created_item_alias!(1),
1904        created_item_alias!(2),
1905        created_item_alias!(3),
1906        created_item_alias!(4),
1907        created_item_alias!(5),
1908        created_item_alias!(6),
1909        created_item_alias!(7),
1910        created_item_alias!(8),
1911        created_item_alias!(9),
1912        created_item_alias!(10),
1913        created_item_alias!(11),
1914        created_item_alias!(12),
1915        created_item_alias!(13),
1916        created_item_alias!(14),
1917        created_item_alias!(15),
1918        created_item_alias!(16),
1919        created_item_alias!(17),
1920        created_item_alias!(18),
1921        created_item_alias!(19),
1922        created_item_alias!(20),
1923        "\n    }"
1924    );
1925
1926    /// The variables and the aliases of [`WRITE_BATCH`](super::WRITE_BATCH) slots: `$<prefix><n>`
1927    /// is a slot's input and `$<send><n>` whether it is sent, for the slot numbers given.
1928    macro_rules! wide_variables {
1929        ($prefix:literal, $type:literal, $send:literal; $($n:literal)+) => {
1930            concat!($("$", $prefix, stringify!($n), ":", $type, "!,$", $send, stringify!($n), ":Boolean!,",)+)
1931        };
1932    }
1933    macro_rules! wide_slots {
1934        ($prefix:literal, $field:literal, $selection:literal, $send:literal; $($n:literal)+) => {
1935            concat!($(
1936                " ", $prefix, stringify!($n), ":", $field, "(input:$", $prefix, stringify!($n),
1937                ") @include(if:$", $send, stringify!($n), "){", $selection, "}",
1938            )+)
1939        };
1940    }
1941
1942    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) board field values a copy writes on the items it
1943    /// created, each on its own item, in one ordered request.
1944    pub const WRITE_FIELD_VALUES: &str = concat!(
1945        "mutation(",
1946        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1947        "){",
1948        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),
1949        "}"
1950    );
1951
1952    /// A copy's board field values and its blocked-by edges together, up to
1953    /// [`WRITE_BATCH`](super::WRITE_BATCH) of the two kinds between them, in one ordered request:
1954    /// every field value its `$sendField<n>` sends, then every edge its `$sendBlock<n>` sends.
1955    /// What a copy sends when one request of its writes holds both kinds; one holding a single
1956    /// kind is [`WRITE_FIELD_VALUES`] or [`ADD_BLOCKED_BYS`].
1957    pub const WRITE_FIELDS_AND_EDGES: &str = concat!(
1958        "mutation(",
1959        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1960        wide_variables!("block", "AddBlockedByInput", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1961        "){",
1962        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),
1963        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),
1964        "}"
1965    );
1966
1967    /// Closes up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues a copy created at a closing
1968    /// status, each with its own `stateInput` and nothing else, once its option is selected.
1969    pub const CLOSE_ISSUES: &str =
1970        write_batch!("close", "UpdateIssueInput", "updateIssue", "issue{id}");
1971
1972    /// Updates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) existing issues a copy rewrites,
1973    /// each with the members its own [`UPDATE_ISSUE`] would have sent, in one request.
1974    pub const UPDATE_ISSUES: &str =
1975        write_batch!("update", "UpdateIssueInput", "updateIssue", "issue{id}");
1976
1977    /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) native blocked-by relationships a copy adds to the
1978    /// issues it created, in one request.
1979    pub const ADD_BLOCKED_BYS: &str = concat!(
1980        "mutation(",
1981        wide_variables!("block", "AddBlockedByInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1982        "){",
1983        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),
1984        "}"
1985    );
1986
1987    /// Removes one native issue blocked-by relationship.
1988    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1989    /// Deletes one issue, which takes its board item with it.
1990    ///
1991    /// The engine sends this in one situation only: undoing a copy that could not finish,
1992    /// over the items that same copy created. Deleting the issue removes the board item
1993    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1994    pub const DELETE_ISSUE: &str =
1995        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1996    /// Deletes up to [`DELETE_BATCH`](super::DELETE_BATCH) issues in one request, each as
1997    /// [`DELETE_ISSUE`] deletes one: what a write that created several issues and was refused
1998    /// part way sends to take them back.
1999    pub const DELETE_ISSUES: &str = concat!(
2000        "mutation(",
2001        wide_variables!("delete", "DeleteIssueInput", "send"; 0 1 2 3 4),
2002        "){",
2003        wide_slots!("delete", "deleteIssue", "repository{id}", "send"; 0 1 2 3 4),
2004        "}"
2005    );
2006
2007    /// One task's comments: a page of its issue's own `comments` connection.
2008    ///
2009    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
2010    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
2011    /// list every time somebody edited it; left unordered the connection answers in the order
2012    /// the comments were written, which is the order GitHub documents for the same collection
2013    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
2014    /// node count and the caller's own page size is pushed straight down.
2015    pub const ISSUE_COMMENTS: &str = concat!(
2016        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
2017        issue_comment!(),
2018        r#"}pageInfo{hasNextPage endCursor}}}}}"#
2019    );
2020    /// One issue by its own node id, with a page of its comments: what `task show` and a
2021    /// comment listing read, in one request.
2022    ///
2023    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
2024    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
2025    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
2026    /// connection there would multiply through both of those documents' price, and neither
2027    /// needs one.
2028    pub const ISSUE_DETAIL: &str = concat!(
2029        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2030        relationship_events_variable!(),
2031        r#"){
2032      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
2033        issue_comment!(),
2034        r#"}pageInfo{hasNextPage endCursor}}}}
2035    }"#,
2036        board_issue!()
2037    );
2038
2039    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
2040    /// page of its comments when `$comments` asks for them.
2041    macro_rules! issue_details_alias {
2042        ($n:literal) => {
2043            concat!(
2044                "\n      i",
2045                stringify!($n),
2046                ":node(id:$id",
2047                stringify!($n),
2048                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
2049                issue_comment!(),
2050                "}pageInfo{hasNextPage endCursor}}}}"
2051            )
2052        };
2053    }
2054
2055    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
2056    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
2057    ///
2058    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
2059    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
2060    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
2061    /// neither — so every connection under it would be priced at nothing and the pin in
2062    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
2063    /// one-item read the model already prices, so the batch costs what its aliases cost.
2064    ///
2065    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
2066    /// slots it has no item for to the last item it does, and reads that item again; the
2067    /// price is the document's, whatever its variables, so a short batch costs what a full
2068    /// one does and nothing more.
2069    pub const ISSUE_DETAILS: &str = concat!(
2070        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!"#,
2071        relationship_events_variable!(),
2072        r#"){"#,
2073        issue_details_alias!(0),
2074        issue_details_alias!(1),
2075        issue_details_alias!(2),
2076        issue_details_alias!(3),
2077        issue_details_alias!(4),
2078        issue_details_alias!(5),
2079        issue_details_alias!(6),
2080        issue_details_alias!(7),
2081        issue_details_alias!(8),
2082        issue_details_alias!(9),
2083        issue_details_alias!(10),
2084        issue_details_alias!(11),
2085        issue_details_alias!(12),
2086        issue_details_alias!(13),
2087        issue_details_alias!(14),
2088        issue_details_alias!(15),
2089        issue_details_alias!(16),
2090        issue_details_alias!(17),
2091        issue_details_alias!(18),
2092        issue_details_alias!(19),
2093        issue_details_alias!(20),
2094        issue_details_alias!(21),
2095        issue_details_alias!(22),
2096        issue_details_alias!(23),
2097        "\n    }",
2098        board_issue!()
2099    );
2100
2101    /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
2102    /// page of its comments and the issues it is blocked by.
2103    macro_rules! issue_copy_details_alias {
2104        ($n:literal) => {
2105            concat!(
2106                "\n      i",
2107                stringify!($n),
2108                ":node(id:$id",
2109                stringify!($n),
2110                "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
2111                issue_comment!(),
2112                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
2113            )
2114        };
2115    }
2116
2117    /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
2118    /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
2119    /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
2120    /// at a time, so a copy of a changed task costs this read and nothing else.
2121    ///
2122    /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
2123    /// what `task show-many` is priced by and which reads no edge.
2124    ///
2125    /// `children`, read only while `$children` holds, is which issues the issue `$project` has
2126    /// as sub-issues — each one's id and title, the page `$nestedFirst` allows — so that a
2127    /// project copy, which reads its project and every task at once, knows from this same read
2128    /// which tasks the project holds at the destination, and its orphan check is answered by it
2129    /// rather than by a listing of the project.
2130    pub const ISSUE_COPY_DETAILS: &str = concat!(
2131        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!"#,
2132        relationship_events_variable!(),
2133        r#"){"#,
2134        issue_copy_details_alias!(0),
2135        issue_copy_details_alias!(1),
2136        issue_copy_details_alias!(2),
2137        issue_copy_details_alias!(3),
2138        issue_copy_details_alias!(4),
2139        issue_copy_details_alias!(5),
2140        issue_copy_details_alias!(6),
2141        issue_copy_details_alias!(7),
2142        issue_copy_details_alias!(8),
2143        issue_copy_details_alias!(9),
2144        issue_copy_details_alias!(10),
2145        issue_copy_details_alias!(11),
2146        issue_copy_details_alias!(12),
2147        issue_copy_details_alias!(13),
2148        issue_copy_details_alias!(14),
2149        issue_copy_details_alias!(15),
2150        issue_copy_details_alias!(16),
2151        issue_copy_details_alias!(17),
2152        issue_copy_details_alias!(18),
2153        issue_copy_details_alias!(19),
2154        issue_copy_details_alias!(20),
2155        "\n      children:node(id:$project) @include(if:$children){... on Issue{id subIssues(first:$nestedFirst){nodes{id title} pageInfo{hasNextPage}}}}",
2156        "\n    }",
2157        board_issue!(),
2158        related_issue!()
2159    );
2160
2161    /// Which issue one comment is on, read before that comment is edited or removed.
2162    ///
2163    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
2164    /// comment id given against the wrong task would change a comment on another issue.
2165    pub const COMMENT_ISSUE: &str =
2166        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
2167    /// Adds one comment to an issue, signed as the account the token belongs to.
2168    pub const ADD_COMMENT: &str = concat!(
2169        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
2170        issue_comment!(),
2171        r#"}}}}"#
2172    );
2173    /// Replaces the body of one issue comment.
2174    pub const UPDATE_COMMENT: &str = concat!(
2175        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
2176        issue_comment!(),
2177        r#"}}}"#
2178    );
2179    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
2180    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
2181
2182    /// The variables of the search slots `<n>` of [`SEARCH_BATCH`]: its search, where it
2183    /// resumes, its page size and whether it is asked.
2184    macro_rules! search_batch_variables {
2185        ($($n:literal)+) => {
2186            concat!($(
2187                ",$search", stringify!($n), ":String!,$after", stringify!($n),
2188                ":String,$first", stringify!($n), ":Int!,$ask", stringify!($n), ":Boolean!",
2189            )+)
2190        };
2191    }
2192    /// The search slots `<n>` of [`SEARCH_BATCH`]: [`SEARCH_ISSUES`]'s one connection, aliased
2193    /// `s<n>`, sent only while `$ask<n>` holds.
2194    macro_rules! search_batch_slots {
2195        ($($n:literal)+) => {
2196            concat!($(
2197                "\n      s", stringify!($n), ":search(query:$search", stringify!($n),
2198                ",type:$type,first:$first", stringify!($n), ",after:$after", stringify!($n),
2199                ") @include(if:$ask", stringify!($n),
2200                "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}",
2201            )+)
2202        };
2203    }
2204    /// The variables of the origin slots `<n>` of [`SEARCH_BATCH`]: the board's field filter
2205    /// naming the origin, where its items resume, their page size and whether it is asked.
2206    macro_rules! origin_batch_variables {
2207        ($($n:literal)+) => {
2208            concat!($(
2209                ",$filter", stringify!($n), ":String!,$itemsAfter", stringify!($n),
2210                ":String,$itemsFirst", stringify!($n), ":Int!,$origin", stringify!($n),
2211                ":Boolean!",
2212            )+)
2213        };
2214    }
2215    /// The origin slots `<n>` of [`SEARCH_BATCH`]: [`ORIGIN_LOOKUP`]'s board half, the board's
2216    /// own items through its field filter, aliased `o<n>`, sent only while `$origin<n>` holds.
2217    macro_rules! origin_batch_slots {
2218        ($($n:literal)+) => {
2219            concat!($(
2220                "\n      o", stringify!($n), ":repositoryOwner(login:$owner) @include(if:$origin",
2221                stringify!($n),
2222                "){... on ProjectV2Owner{projectV2(number:$number){items(first:$itemsFirst",
2223                stringify!($n), ",after:$itemsAfter", stringify!($n), ",query:$filter",
2224                stringify!($n), "){nodes{id updatedAt ", board_item_values!(),
2225                board_item_content!(), "} pageInfo{hasNextPage endCursor}}}}}",
2226            )+)
2227        };
2228    }
2229
2230    /// Up to [`SEARCH_BATCH`](super::SEARCH_BATCH) board-scoped issue searches in one request,
2231    /// each [`SEARCH_ISSUES`]'s connection in an aliased `s<n>` field with its own query, page
2232    /// size and cursor, and beside them up to
2233    /// [`SEARCH_BATCH_ORIGINS`](super::SEARCH_BATCH_ORIGINS) origin lookups' board halves —
2234    /// [`ORIGIN_LOOKUP`]'s `items` filtered by the origin field — in aliased `o<n>` fields, each
2235    /// origin's issue search in a search slot of its own.
2236    ///
2237    /// What `task find` asks a board, every search a page at a time: one request a round,
2238    /// rather than one per search. A slot not asked is left out by `@include`, its page size
2239    /// bound to one; GitHub answers an error about one slot at that slot's path, and the other
2240    /// slots are answered beside it.
2241    pub const SEARCH_BATCH: &str = concat!(
2242        r#"query($owner:String!,$number:Int!,$type:SearchType!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2243        relationship_events_variable!(),
2244        search_batch_variables!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2245        origin_batch_variables!(0 1 2 3 4),
2246        "){",
2247        search_batch_slots!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2248        origin_batch_slots!(0 1 2 3 4),
2249        "\n    }",
2250        board_issue!()
2251    );
2252
2253    /// The comment slots `<n>` of [`ADD_COMMENTS`]: [`ADD_COMMENT`]'s mutation, aliased
2254    /// `comment<n>`, sent only while `$send<n>` holds.
2255    macro_rules! comment_slots {
2256        ($($n:literal)+) => {
2257            concat!($(
2258                " comment", stringify!($n), ":addComment(input:$comment", stringify!($n),
2259                ") @include(if:$send", stringify!($n), "){subject{id} commentEdge{node{",
2260                issue_comment!(), "}}}",
2261            )+)
2262        };
2263    }
2264
2265    /// Adds up to [`COMMENT_BATCH`](super::COMMENT_BATCH) comments in one request, each as
2266    /// [`ADD_COMMENT`] adds one, in order: what `task comment add-many` sends. GitHub runs each
2267    /// alias on its own and answers one it refused at its own path, beside the others.
2268    pub const ADD_COMMENTS: &str = concat!(
2269        "mutation(",
2270        wide_variables!("comment", "AddCommentInput", "send"; 0 1 2 3 4 5 6 7 8 9 10),
2271        "){",
2272        comment_slots!(0 1 2 3 4 5 6 7 8 9 10),
2273        "}"
2274    );
2275
2276    /// Many tasks' targeted updates, each as [`UPDATE_FIELDS`] and [`UPDATE_ISSUE`] write one:
2277    /// up to [`TASK_FIELD_BATCH`](super::TASK_FIELD_BATCH) board field values in `field<n>`,
2278    /// [`MUTATION_BATCH`](super::MUTATION_BATCH) field clears in `clear<n>`, and as many
2279    /// `updateIssue`s in `update<n>`, each sent only while its own `send…<n>` holds.
2280    ///
2281    /// What `task update-many` sends, in two requests at most: every item's field values and
2282    /// clears first, then every issue update of an item whose fields all landed — the order one
2283    /// item's update keeps, so a refusal part way leaves an item's body as it stood.
2284    ///
2285    /// Each `update<n>` selects the issue it updated as a read of it does, so its response spells
2286    /// the change token the task holds after its batch's writes — the version `update-many`
2287    /// answers — with no read after them. Its page sizes are bound by `$nestedFirst` and
2288    /// `$boardItems`, as every read's are.
2289    pub const WRITE_TASKS: &str = concat!(
2290        "mutation(",
2291        wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2292        wide_variables!("clear", "ClearProjectV2ItemFieldValueInput", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2293        wide_variables!("update", "UpdateIssueInput", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2294        r#"$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2295        relationship_events_variable!(),
2296        "){",
2297        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),
2298        wide_slots!("clear", "clearProjectV2ItemFieldValue", "projectV2Item{id}", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2299        wide_slots!("update", "updateIssue", "issue{id ...BoardIssue}", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2300        "}",
2301        board_issue!()
2302    );
2303
2304    /// One alias of [`ISSUE_RELATIONS`]: the item a batch's `$id<n>` names, as a read of it by
2305    /// its own id reads it, with a page of its sub-issues' ids.
2306    macro_rules! issue_relations_alias {
2307        ($n:literal) => {
2308            concat!(
2309                "\n      i",
2310                stringify!($n),
2311                ":node(id:$id",
2312                stringify!($n),
2313                "){__typename ...BoardIssue ... on Issue{subIssues(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2314            )
2315        };
2316    }
2317
2318    /// [`ISSUE_DETAILS`] with each issue's sub-issues in place of its comments: what `task
2319    /// relations` and a link write read of [`DETAIL_BATCH`](super::DETAIL_BATCH) tasks at once
2320    /// on a source reading sub-issues as children — each task whole, its parent and the ids of up
2321    /// to `$first` of its children.
2322    pub const ISSUE_RELATIONS: &str = concat!(
2323        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!"#,
2324        relationship_events_variable!(),
2325        r#"){"#,
2326        issue_relations_alias!(0),
2327        issue_relations_alias!(1),
2328        issue_relations_alias!(2),
2329        issue_relations_alias!(3),
2330        issue_relations_alias!(4),
2331        issue_relations_alias!(5),
2332        issue_relations_alias!(6),
2333        issue_relations_alias!(7),
2334        issue_relations_alias!(8),
2335        issue_relations_alias!(9),
2336        issue_relations_alias!(10),
2337        issue_relations_alias!(11),
2338        issue_relations_alias!(12),
2339        issue_relations_alias!(13),
2340        issue_relations_alias!(14),
2341        issue_relations_alias!(15),
2342        issue_relations_alias!(16),
2343        issue_relations_alias!(17),
2344        issue_relations_alias!(18),
2345        issue_relations_alias!(19),
2346        issue_relations_alias!(20),
2347        issue_relations_alias!(21),
2348        issue_relations_alias!(22),
2349        issue_relations_alias!(23),
2350        "\n    }",
2351        board_issue!()
2352    );
2353
2354    /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) parent writes in one request:
2355    /// `parent<n>` an `addSubIssue` (a re-parent carrying `replaceParent: true`) and `unparent<n>`
2356    /// a `removeSubIssue`, each sent only while its own `send…<n>` holds. GitHub runs each alias
2357    /// on its own and answers one it refused — a cycle, a depth or children limit — at its own
2358    /// path, beside the others.
2359    pub const SET_PARENTS: &str = concat!(
2360        "mutation(",
2361        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),
2362        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),
2363        "){",
2364        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),
2365        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),
2366        "}"
2367    );
2368
2369    /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) `relates-to` relations added in one
2370    /// request, each `relate<n>` sent only while `$send<n>` holds: what a link of a type a
2371    /// source mirrors natively writes beside its metadata.
2372    pub const ADD_RELATES_TO: &str = concat!(
2373        "mutation(",
2374        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),
2375        "){",
2376        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),
2377        "}"
2378    );
2379
2380    /// [`ADD_RELATES_TO`]'s removal: up to [`RELATION_BATCH`](super::RELATION_BATCH)
2381    /// `relates-to` relations removed in one request.
2382    pub const REMOVE_RELATES_TO: &str = concat!(
2383        "mutation(",
2384        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),
2385        "){",
2386        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),
2387        "}"
2388    );
2389
2390    /// One alias of [`RELATES_TO_READS`]: the issue `$id<n>` names and the ids of up to
2391    /// `$first` of the issues it relates to.
2392    macro_rules! relates_to_alias {
2393        ($n:literal) => {
2394            concat!(
2395                "\n      r",
2396                stringify!($n),
2397                ":node(id:$id",
2398                stringify!($n),
2399                "){... on Issue{id relatesTo(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2400            )
2401        };
2402    }
2403
2404    /// What [`DETAIL_BATCH`](super::DETAIL_BATCH) issues relate to, one alias each: read
2405    /// after a relation write whose answer never arrived, so each relation is answered from what
2406    /// GitHub holds rather than sent again blindly.
2407    pub const RELATES_TO_READS: &str = concat!(
2408        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!){"#,
2409        relates_to_alias!(0),
2410        relates_to_alias!(1),
2411        relates_to_alias!(2),
2412        relates_to_alias!(3),
2413        relates_to_alias!(4),
2414        relates_to_alias!(5),
2415        relates_to_alias!(6),
2416        relates_to_alias!(7),
2417        relates_to_alias!(8),
2418        relates_to_alias!(9),
2419        relates_to_alias!(10),
2420        relates_to_alias!(11),
2421        relates_to_alias!(12),
2422        relates_to_alias!(13),
2423        relates_to_alias!(14),
2424        relates_to_alias!(15),
2425        relates_to_alias!(16),
2426        relates_to_alias!(17),
2427        relates_to_alias!(18),
2428        relates_to_alias!(19),
2429        relates_to_alias!(20),
2430        relates_to_alias!(21),
2431        relates_to_alias!(22),
2432        relates_to_alias!(23),
2433        "\n    }"
2434    );
2435
2436    /// What a mutation of a write the store's cache expects selects of the issue it wrote:
2437    /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
2438    /// change token's every part and the first page of its comments.
2439    ///
2440    /// Sent **only** while that write runs, in place of the document named beside each
2441    /// below, so every other write's documents, prices and request counts are what they
2442    /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
2443    /// mutation's response is stamped seconds after the change it made, so against GitHub
2444    /// itself a response rarely determines a version; the cache is then left unserveable
2445    /// for the task, and its next read refreshes it.
2446    macro_rules! whole_issue {
2447        () => {
2448            concat!(
2449                r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
2450                issue_comment!(),
2451                r#"}pageInfo{hasNextPage endCursor}}}"#
2452            )
2453        };
2454    }
2455
2456    /// The variables every document of `whole_issue!` declares beside its own.
2457    macro_rules! whole_variables {
2458        () => {
2459            concat!(
2460                r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2461                relationship_events_variable!()
2462            )
2463        };
2464    }
2465
2466    /// [`ADD_COMMENT`], selecting the whole issue commented on.
2467    pub const ADD_COMMENT_WHOLE: &str = concat!(
2468        r#"mutation($input:AddCommentInput!"#,
2469        whole_variables!(),
2470        r#"){addComment(input:$input){subject{id "#,
2471        whole_issue!(),
2472        r#"} commentEdge{node{"#,
2473        issue_comment!(),
2474        r#"}}}}"#,
2475        board_issue!()
2476    );
2477    /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
2478    pub const UPDATE_COMMENT_WHOLE: &str = concat!(
2479        r#"mutation($input:UpdateIssueCommentInput!"#,
2480        whole_variables!(),
2481        r#"){updateIssueComment(input:$input){issueComment{"#,
2482        issue_comment!(),
2483        r#" issue{"#,
2484        whole_issue!(),
2485        r#"}}}}"#,
2486        board_issue!()
2487    );
2488    /// [`UPDATE_ISSUE`], selecting the whole issue updated.
2489    pub const UPDATE_ISSUE_WHOLE: &str = concat!(
2490        r#"mutation($input:UpdateIssueInput!"#,
2491        whole_variables!(),
2492        r#"){updateIssue(input:$input){issue{id "#,
2493        whole_issue!(),
2494        r#"}}}"#,
2495        board_issue!()
2496    );
2497    /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
2498    pub const UPDATE_FIELD_WHOLE: &str = concat!(
2499        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2500        whole_variables!(),
2501        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2502        whole_issue!(),
2503        r#"}}}}"#,
2504        board_issue!()
2505    );
2506    /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
2507    pub const CLEAR_FIELD_WHOLE: &str = concat!(
2508        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2509        whole_variables!(),
2510        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2511        whole_issue!(),
2512        r#"}}}}"#,
2513        board_issue!()
2514    );
2515
2516    /// The variables every document selecting [`board_issue_fields!`] inline declares beside
2517    /// its own.
2518    macro_rules! token_variables {
2519        () => {
2520            concat!(
2521                r#",$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2522                relationship_events_variable!()
2523            )
2524        };
2525    }
2526
2527    /// [`UPDATE_ISSUE`], selecting what a read of the issue updated selects — every part of its
2528    /// change token, and no comment — inline, so its root fields are the plain document's.
2529    ///
2530    /// What a targeted update sends in the plain document's place, so the version it answers
2531    /// is the one its own last response spells (`GitHubProjectsSource::written_tokens`). It
2532    /// costs the one point the plain document does.
2533    pub const UPDATE_ISSUE_TOKEN: &str = concat!(
2534        r#"mutation($input:UpdateIssueInput!"#,
2535        token_variables!(),
2536        r#"){updateIssue(input:$input){issue{"#,
2537        board_issue_fields!(),
2538        r#"}}}"#
2539    );
2540    /// [`UPDATE_FIELD`], selecting the issue behind the board item written as
2541    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2542    pub const UPDATE_FIELD_TOKEN: &str = concat!(
2543        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2544        token_variables!(),
2545        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{"#,
2546        board_issue_fields!(),
2547        r#"}}}}}"#
2548    );
2549    /// [`CLEAR_FIELD`], selecting the issue behind the board item cleared as
2550    /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2551    pub const CLEAR_FIELD_TOKEN: &str = concat!(
2552        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2553        token_variables!(),
2554        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{"#,
2555        board_issue_fields!(),
2556        r#"}}}}}"#
2557    );
2558
2559    /// The document a targeted update sends in `document`'s place, when it has one: the twin
2560    /// whose answer spells the change token of the issue written.
2561    #[must_use]
2562    pub fn token_twin(document: &str) -> Option<&'static str> {
2563        [
2564            (UPDATE_ISSUE, UPDATE_ISSUE_TOKEN),
2565            (UPDATE_FIELD, UPDATE_FIELD_TOKEN),
2566            (CLEAR_FIELD, CLEAR_FIELD_TOKEN),
2567        ]
2568        .into_iter()
2569        .find(|(plain, _)| *plain == document)
2570        .map(|(_, token)| token)
2571    }
2572
2573    /// The document sent in `document`'s place while a write the store's cache expects runs,
2574    /// when it has one.
2575    #[must_use]
2576    pub fn whole_twin(document: &str) -> Option<&'static str> {
2577        [
2578            (ADD_COMMENT, ADD_COMMENT_WHOLE),
2579            (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
2580            (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
2581            (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
2582            (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
2583        ]
2584        .into_iter()
2585        .find(|(plain, _)| *plain == document)
2586        .map(|(_, whole)| whole)
2587    }
2588
2589    /// The node id of one person by their login, as the aliased field `u0` answering `$login0`:
2590    /// what an assignee write names a person by, since `assigneeIds` takes node ids and a person
2591    /// is named by login.
2592    ///
2593    /// The inventoried form of the lookup. What is sent is this document sized to the call
2594    /// ([`user_ids`]): one `u<n>:user(login:$login<n>)` field per person the call names that
2595    /// this process has not resolved, so one request answers every one of them. GitHub answers a
2596    /// login it does not know with `null` at that alias beside a `NOT_FOUND` error naming it,
2597    /// and the others' ids beside them.
2598    pub const USER_IDS: &str = "query($login0:String!){ u0:user(login:$login0){id login}}";
2599
2600    /// [`USER_IDS`] for `people` logins — `$login0` to `$login<people - 1>`, each answered at
2601    /// its own alias — and [`USER_IDS`] itself for one.
2602    #[must_use]
2603    pub fn user_ids(people: usize) -> String {
2604        let people = people.max(1);
2605        let declared: Vec<String> = (0..people)
2606            .map(|slot| format!("$login{slot}:String!"))
2607            .collect();
2608        let fields: Vec<String> = (0..people)
2609            .map(|slot| format!("u{slot}:user(login:$login{slot}){{id login}}"))
2610            .collect();
2611        format!("query({}){{ {}}}", declared.join(","), fields.join(" "))
2612    }
2613
2614    /// The document a request sending `document` with `variables` really sends: [`USER_IDS`]
2615    /// sized to the logins `variables` binds, and every other document as it is.
2616    #[must_use]
2617    pub fn sized<'a>(
2618        document: &'a str,
2619        variables: &serde_json::Value,
2620    ) -> std::borrow::Cow<'a, str> {
2621        if document != USER_IDS {
2622            return std::borrow::Cow::Borrowed(document);
2623        }
2624        let people = (0..)
2625            .take_while(|slot| variables.get(format!("login{slot}")).is_some())
2626            .count();
2627        std::borrow::Cow::Owned(user_ids(people))
2628    }
2629
2630    /// The inventoried document `document` is an instance of: [`USER_IDS`] for any lookup
2631    /// [`user_ids`] sized, and `document` itself otherwise.
2632    #[must_use]
2633    pub fn inventoried(document: &str) -> &str {
2634        let people = document.matches(":user(login:$login").count();
2635        if people > 0 && user_ids(people) == document {
2636            USER_IDS
2637        } else {
2638            document
2639        }
2640    }
2641
2642    /// The selection of an issue's — or a draft's — assignees, as a source opted in to them
2643    /// (`config.assignees: true`) adds it to every document that reads one; see
2644    /// [`with_assignees`].
2645    const ASSIGNEES: &str = " assignees(first:$assigneeFirst){nodes{id login}}";
2646    /// The variable [`ASSIGNEES`] pages by, declared with its value as its default — the most
2647    /// people GitHub assigns one issue — so no caller binds it, and inserted first among a
2648    /// document's variables.
2649    const ASSIGNEE_VARIABLE: &str = "$assigneeFirst:Int!=10 ";
2650    /// [`ASSIGNEE_VARIABLE`] for a document declaring no variables of its own.
2651    const ASSIGNEE_VARIABLES: &str = "($assigneeFirst:Int!=10)";
2652    /// Where [`ASSIGNEES`] goes: straight after each of these, wherever an issue or a draft is
2653    /// read — what every read of an issue selects of it — and inside the answer of each
2654    /// `createIssue` and `updateIssue`, so a write's own answer says whom GitHub assigned.
2655    const ASSIGNEE_MARKERS: [&str; 2] = [
2656        "commentCount:comments{totalCount}",
2657        "title body createdAt updatedAt",
2658    ];
2659
2660    /// `document` as a source opted in to assignees sends it: [`ASSIGNEES`] selected wherever
2661    /// it reads an issue or a draft and in every `createIssue` and `updateIssue` answer, with the
2662    /// variable it pages by declared — or `None` when it reads and writes neither, and is sent
2663    /// as written.
2664    ///
2665    /// A rewrite of the one text sent rather than a second set of documents, so a source that
2666    /// has not opted in sends every document byte for byte as it did, and an opted-in one rides
2667    /// its assignees on the very reads and writes it already makes. [`without_assignees`] is its
2668    /// exact inverse, which the loopback board answers a document by.
2669    #[must_use]
2670    pub fn with_assignees(document: &str) -> Option<String> {
2671        let mut spelled = String::with_capacity(document.len() + 256);
2672        let mut inserted = false;
2673        let mut rest = document;
2674        while !rest.is_empty() {
2675            // The earliest place one is due, of every kind.
2676            let next = ASSIGNEE_MARKERS
2677                .iter()
2678                .filter_map(|marker| rest.find(marker).map(|at| (at + marker.len(), true)))
2679                .chain(["createIssue(", "updateIssue("].iter().filter_map(|write| {
2680                    let at = rest.find(write)?;
2681                    let open = at + rest[at..].find('{')?;
2682                    rest[open..]
2683                        .starts_with("{issue{id")
2684                        .then_some((open + "{issue{id".len(), true))
2685                        .or(Some((open, false)))
2686                }))
2687                .min_by_key(|(at, _)| *at);
2688            let Some((at, insert)) = next else {
2689                spelled.push_str(rest);
2690                break;
2691            };
2692            spelled.push_str(&rest[..at]);
2693            if insert {
2694                spelled.push_str(ASSIGNEES);
2695                inserted = true;
2696            }
2697            rest = &rest[at..];
2698            if !insert {
2699                // A write whose answer reads no issue's id first: past its opening brace.
2700                spelled.push('{');
2701                rest = &rest[1..];
2702            }
2703        }
2704        if !inserted {
2705            return None;
2706        }
2707        let open = spelled.find('(');
2708        let body = spelled.find('{');
2709        Some(match (open, body) {
2710            (Some(open), Some(body)) if open < body => {
2711                format!(
2712                    "{}{ASSIGNEE_VARIABLE}{}",
2713                    &spelled[..=open],
2714                    &spelled[open + 1..]
2715                )
2716            }
2717            (_, Some(body)) => format!(
2718                "{}{ASSIGNEE_VARIABLES}{}",
2719                &spelled[..body],
2720                &spelled[body..]
2721            ),
2722            _ => return None,
2723        })
2724    }
2725
2726    /// `document` as [`with_assignees`] spelled it, back as the document it was written as — or
2727    /// `None` for a document it did not rewrite.
2728    #[must_use]
2729    pub fn without_assignees(document: &str) -> Option<String> {
2730        if !document.contains(ASSIGNEES) {
2731            return None;
2732        }
2733        let plain = document.replace(ASSIGNEES, "");
2734        Some(if plain.contains(ASSIGNEE_VARIABLES) {
2735            plain.replacen(ASSIGNEE_VARIABLES, "", 1)
2736        } else {
2737            plain.replacen(ASSIGNEE_VARIABLE, "", 1)
2738        })
2739    }
2740
2741    /// Every document above, with what this source is doing when it sends one.
2742    ///
2743    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
2744    /// name the call that was refused, and a `match` with a catch-all arm would answer a
2745    /// document added later with "talking to GitHub" and never say so.
2746    ///
2747    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
2748    /// const` here that this list omits, so the two cannot part — which is the same guard
2749    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
2750    pub const DOCUMENTS: [(&str, &str); 69] = [
2751        (USER_IDS, "looking up the people an assignee write names"),
2752        (
2753            ISSUE_RELATIONS,
2754            "reading several issues with their parents and sub-issues",
2755        ),
2756        (SET_PARENTS, "writing several issues' parents"),
2757        (ADD_RELATES_TO, "adding several relates-to relations"),
2758        (REMOVE_RELATES_TO, "removing several relates-to relations"),
2759        (
2760            RELATES_TO_READS,
2761            "reading what several issues relate to after a relation write went unanswered",
2762        ),
2763        (
2764            ISSUES_BY_CREATION,
2765            "listing a repository's issues newest first for a create key",
2766        ),
2767        (SEARCH_ISSUES, "searching this board's issues"),
2768        (ISSUE, "reading one issue"),
2769        (
2770            DOCUMENT_IN_PROJECT,
2771            "reading one document with its project and the project's members",
2772        ),
2773        (
2774            ISSUE_BOARD_ITEMS,
2775            "reading one issue's board memberships past the page it came with",
2776        ),
2777        (SUB_ISSUES, "reading a project's tasks"),
2778        (BOARD, "reading the board"),
2779        (
2780            BOARD_WITH_COMMENTS,
2781            "reading a board page with first comments",
2782        ),
2783        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
2784        (CLOSED_ITEMS, "reading the board's closed items"),
2785        (
2786            ISSUES_BY_REFERENCE,
2787            "reading a batch of issues by their references",
2788        ),
2789        (BOARD_FIELDS, "reading the board's fields"),
2790        (DRAFT, "reading one draft"),
2791        (REPOSITORY, "reading the destination repository"),
2792        (
2793            PROJECT_VISIBILITY,
2794            "reading whether the board's project is public",
2795        ),
2796        (
2797            CREATION_CONTEXT,
2798            "reading the board's fields and the destination repository",
2799        ),
2800        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
2801        (ISSUE_DEPENDS_ON, "reading what an item depends on"),
2802        (ISSUE_DEPENDED_ON_BY, "reading what depends on an issue"),
2803        (CREATE_ISSUE, "creating an issue"),
2804        (
2805            ORIGIN_LOOKUPS,
2806            "looking up the items copied from several origins, and what creating one reads",
2807        ),
2808        (
2809            ORIGIN_LOOKUPS_WITH_MEMBERS,
2810            "looking up the items copied from several origins, with a project's members",
2811        ),
2812        (CREATE_ISSUES, "creating several issues together"),
2813        (
2814            CREATED_BOARD_ITEMS,
2815            "reading the board items of the issues a copy created",
2816        ),
2817        (
2818            WRITE_FIELD_VALUES,
2819            "writing board fields of the items a copy created",
2820        ),
2821        (
2822            WRITE_FIELDS_AND_EDGES,
2823            "writing board fields and dependencies of the items a copy created",
2824        ),
2825        (CLOSE_ISSUES, "closing the issues a copy created"),
2826        (UPDATE_ISSUES, "updating several issues together"),
2827        (ADD_BLOCKED_BYS, "recording several dependencies together"),
2828        (ADD_TO_BOARD, "adding an issue to the board"),
2829        (UPDATE_ISSUE, "updating an issue"),
2830        (UPDATE_DRAFT, "updating a draft item"),
2831        (UPDATE_FIELD, "writing a board field"),
2832        (UPDATE_FIELDS, "writing board fields together"),
2833        (CLEAR_FIELD, "clearing a board field"),
2834        (
2835            CREATE_FIELD,
2836            "creating a board single-select field with its options",
2837        ),
2838        (
2839            STATUS_OPTIONS_SNAPSHOT,
2840            "snapshotting board Status options and assignments",
2841        ),
2842        (
2843            STATUS_OPTIONS_UPDATE,
2844            "safely replacing the board Status option list",
2845        ),
2846        (ADD_SUB_ISSUE, "filing an issue under its project"),
2847        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
2848        (ADD_BLOCKED_BY, "recording a dependency"),
2849        (REMOVE_BLOCKED_BY, "removing a dependency"),
2850        (DELETE_ISSUE, "deleting an issue"),
2851        (DELETE_ISSUES, "deleting several issues together"),
2852        (ISSUE_COMMENTS, "reading a task's comments"),
2853        (ISSUE_DETAIL, "reading one issue with its comments"),
2854        (
2855            ISSUE_DETAILS,
2856            "reading a batch of issues with their comments",
2857        ),
2858        (
2859            ISSUE_COPY_DETAILS,
2860            "reading a batch of issues with their comments and dependencies for a copy",
2861        ),
2862        (COMMENT_ISSUE, "reading which issue a comment is on"),
2863        (ADD_COMMENT, "adding a comment"),
2864        (UPDATE_COMMENT, "editing a comment"),
2865        (DELETE_COMMENT, "deleting a comment"),
2866        (
2867            SEARCH_BATCH,
2868            "searching this board's issues several ways at once",
2869        ),
2870        (ADD_COMMENTS, "adding several comments together"),
2871        (WRITE_TASKS, "updating several tasks together"),
2872        (
2873            ADD_COMMENT_WHOLE,
2874            "adding a comment, answered with the whole issue",
2875        ),
2876        (
2877            UPDATE_COMMENT_WHOLE,
2878            "editing a comment, answered with the whole issue",
2879        ),
2880        (
2881            UPDATE_ISSUE_WHOLE,
2882            "updating an issue, answered with it whole",
2883        ),
2884        (
2885            UPDATE_FIELD_WHOLE,
2886            "writing a board field, answered with the whole issue",
2887        ),
2888        (
2889            CLEAR_FIELD_WHOLE,
2890            "clearing a board field, answered with the whole issue",
2891        ),
2892        (
2893            UPDATE_ISSUE_TOKEN,
2894            "updating an issue, answered with its version",
2895        ),
2896        (
2897            UPDATE_FIELD_TOKEN,
2898            "writing a board field, answered with the issue's version",
2899        ),
2900        (
2901            CLEAR_FIELD_TOKEN,
2902            "clearing a board field, answered with the issue's version",
2903        ),
2904    ];
2905}
2906
2907/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
2908/// under, the variable carrying its input, and the boolean variable that includes it.
2909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2910pub struct FieldSlot {
2911    /// The key of the answer's `data` this field's payload is under.
2912    pub alias: &'static str,
2913    /// The variable carrying this field's input.
2914    pub variable: &'static str,
2915    /// The boolean variable whose `true` sends this field.
2916    pub include: &'static str,
2917}
2918
2919/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
2920pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
2921    FieldSlot {
2922        alias: "updateProjectV2ItemFieldValue",
2923        variable: "input",
2924        include: "writeFirst",
2925    },
2926    FieldSlot {
2927        alias: "second",
2928        variable: "second",
2929        include: "writeSecond",
2930    },
2931    FieldSlot {
2932        alias: "third",
2933        variable: "third",
2934        include: "writeThird",
2935    },
2936    FieldSlot {
2937        alias: "fourth",
2938        variable: "fourth",
2939        include: "writeFourth",
2940    },
2941    FieldSlot {
2942        alias: "fifth",
2943        variable: "fifth",
2944        include: "writeFifth",
2945    },
2946    FieldSlot {
2947        alias: "sixth",
2948        variable: "sixth",
2949        include: "writeSixth",
2950    },
2951];
2952
2953/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
2954pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
2955    FieldSlot {
2956        alias: "cleared",
2957        variable: "clear",
2958        include: "writeClear",
2959    },
2960    FieldSlot {
2961        alias: "clearedSecond",
2962        variable: "clearSecond",
2963        include: "writeClearSecond",
2964    },
2965    FieldSlot {
2966        alias: "clearedThird",
2967        variable: "clearThird",
2968        include: "writeClearThird",
2969    },
2970];
2971
2972/// Which of GitHub's two rate limiters refused a request.
2973///
2974/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
2975/// secondary one — so an operator told the wrong one takes the wrong next step, which is
2976/// the whole reason this is carried rather than collapsed into "rate limited".
2977#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2978enum Limiter {
2979    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
2980    Primary,
2981    /// The burst limiter over content-generating requests, which nothing reports.
2982    Secondary,
2983}
2984
2985/// The wordings GitHub answers a secondary rate limit with.
2986///
2987/// It sends them under a forbidden status, under a too-many-requests status, and inside
2988/// the `errors` of a *successful* response, which is why the text is what this matches on
2989/// rather than the status. `abuse detection` is the wording GitHub used before the
2990/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
2991/// what a burst of content creation is refused with.
2992///
2993/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
2994/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
2995/// when, and the drift gate reconciles the two lists both ways. Public for that gate
2996/// alone — a caller has no use for it, and matching on a refusal is this source's job.
2997pub const SECONDARY_WORDINGS: [&str; 5] = [
2998    "secondary rate limit",
2999    "temporarily blocked from content creation",
3000    "abuse detection",
3001    "submitted too quickly",
3002    "exceeded a secondary",
3003];
3004
3005/// The wordings GitHub answers an exhausted primary budget with.
3006///
3007/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
3008/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
3009/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
3010/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
3011/// two phrases is a substring of it, so without it that answer read as a refusal that will
3012/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
3013/// one reason.
3014pub const PRIMARY_WORDINGS: [&str; 4] = [
3015    "api rate limit exceeded",
3016    "api rate limit already exceeded",
3017    "rate limit exceeded",
3018    "rate_limited",
3019];
3020
3021/// The wordings GitHub answers a write that collided with another change to the same
3022/// ordering with, in the `errors` of an HTTP 200: "Your attempt to move this item created a
3023/// temporary conflict. Please try again." The write did not take effect and GitHub asks for it
3024/// again, so it is retried on the same growing schedule a rate limit without a hint is, within
3025/// the same retry budget — except a `createIssue`, whose answer cannot say the issue was not
3026/// made, and which is therefore reported rather than replayed into a duplicate.
3027///
3028/// Observed rather than documented, so it is pinned exactly as [`SECONDARY_WORDINGS`] is:
3029/// `tests/fixtures/rate-limits.json` records where it was read under `conflict.observed`, and
3030/// the same drift gate reconciles the two both ways. Public for that gate alone.
3031pub const CONFLICT_WORDINGS: [&str; 1] = ["created a temporary conflict"];
3032
3033/// What a response *says about itself*, which is the only place a refusal can be read.
3034///
3035/// Deliberately not the whole response body. A board is a place people write about their
3036/// own work, and a task on it titled "the secondary rate limit" would, matched across the
3037/// raw text, turn a perfectly good answer into a refusal this source then waited out and
3038/// reported. So the item data is never read: what is read is GitHub's own REST-style
3039/// `message` envelope, which is what a forbidden status carries, and the `message` and
3040/// `type` of each GraphQL error, which is where a *successful* response says it.
3041///
3042/// A body that is not JSON at all has nothing structured to read, so only a failing
3043/// response's own text is taken — a successful response that is not JSON is malformed
3044/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
3045fn refusal_wording(status: StatusCode, body: &str) -> String {
3046    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
3047        return if status.is_success() {
3048            String::new()
3049        } else {
3050            body.to_owned()
3051        };
3052    };
3053    let mut said: Vec<&str> = parsed
3054        .get("message")
3055        .and_then(Value::as_str)
3056        .into_iter()
3057        .collect();
3058    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
3059        for error in errors {
3060            said.extend(
3061                ["message", "type"]
3062                    .into_iter()
3063                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
3064            );
3065        }
3066    }
3067    said.join("; ")
3068}
3069
3070impl Limiter {
3071    /// Which limiter refused this response, or `None` when none of them did.
3072    ///
3073    /// The wording is read first and the status only decides what carries none of it,
3074    /// because GitHub answers a secondary limit with a forbidden status far more often
3075    /// than with too-many-requests — while a forbidden status saying nothing about a limit
3076    /// really is a credential this token lacks.
3077    ///
3078    /// A response is a refusal because of its status or its own wording. A spent budget
3079    /// only ever explains one; it never turns an answer into a refusal.
3080    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
3081        let normalized = refusal_wording(status, body).to_ascii_lowercase();
3082        if SECONDARY_WORDINGS
3083            .iter()
3084            .any(|wording| normalized.contains(wording))
3085        {
3086            return Some(Self::Secondary);
3087        }
3088        if status == StatusCode::TOO_MANY_REQUESTS {
3089            return Some(Self::Primary);
3090        }
3091        // An exhausted budget *explains* a response that failed; it does not make one that
3092        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
3093        // request the budget allowed as well as on the ones it then refuses, so reading
3094        // the header alone threw away a good answer — and, once refusals were retried,
3095        // replayed a request that had already taken effect.
3096        if !status.is_success() && budget_exhausted {
3097            return Some(Self::Primary);
3098        }
3099        // A successful response saying it: GitHub reports a GraphQL rate limit in the
3100        // `errors` of an HTTP 200, where nothing about the status says so at all.
3101        if status.is_success()
3102            && PRIMARY_WORDINGS
3103                .iter()
3104                .any(|wording| normalized.contains(wording))
3105        {
3106            return Some(Self::Primary);
3107        }
3108        None
3109    }
3110
3111    /// What this limiter is called where an operator can look it up.
3112    const fn name(self) -> &'static str {
3113        match self {
3114            Self::Primary => "GitHub's primary API rate limit",
3115            Self::Secondary => "GitHub's secondary rate limit",
3116        }
3117    }
3118
3119    /// What the endpoint an operator would go and check says about this limiter.
3120    const fn where_to_look(self) -> &'static str {
3121        match self {
3122            Self::Primary => {
3123                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
3124                 comes back."
3125            }
3126            Self::Secondary => {
3127                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
3128                 primary budget and does not report this one, so budget showing there says \
3129                 nothing about this refusal, and every further attempt extends it."
3130            }
3131        }
3132    }
3133
3134    /// The next step this limiter actually calls for.
3135    const fn what_to_do(self) -> &'static str {
3136        match self {
3137            Self::Primary => {
3138                "wait for the reset `gh api rate_limit` reports, then run the command again."
3139            }
3140            Self::Secondary => {
3141                "leave this board alone for a few minutes, then run the command again — or \
3142                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
3143            }
3144        }
3145    }
3146}
3147
3148/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
3149#[derive(Debug, Clone, Copy)]
3150struct Limited {
3151    limiter: Limiter,
3152    hint: Option<u64>,
3153    /// Which header the hint came from: `retry-after`, or an exhausted primary budget's
3154    /// `x-ratelimit-reset`.
3155    hinted_by: Hint,
3156}
3157
3158/// Where a refusal's hint came from, which is what its wait is named after.
3159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3160enum Hint {
3161    /// `retry-after`, or no hint at all.
3162    RetryAfter,
3163    /// An exhausted primary budget's `x-ratelimit-reset`.
3164    PrimaryReset,
3165}
3166
3167impl Limited {
3168    /// The wait this refusal is answered with, when the backing-off schedule is at `backoff`,
3169    /// and the cause it is named by: the hint, raised to the schedule's own next wait —
3170    /// GitHub really does send `retry-after: 0`, and retrying at once is the one move that
3171    /// extends a secondary limit — or the schedule alone when there is none.
3172    fn wait(self, backoff: Duration) -> (Duration, WaitCause) {
3173        match (self.hint, self.hinted_by) {
3174            (Some(hint), Hint::RetryAfter) => (
3175                Duration::from_secs(hint).max(backoff),
3176                pacing::cause::RETRY_AFTER,
3177            ),
3178            (Some(hint), Hint::PrimaryReset) => (
3179                Duration::from_secs(hint).max(backoff),
3180                pacing::cause::PRIMARY_RESET,
3181            ),
3182            (None, _) => (backoff, pacing::cause::BACKOFF),
3183        }
3184    }
3185
3186    /// What the caller is told once this source has waited as long as it may.
3187    ///
3188    /// Both limiters report as [`SourceError::RateLimited`], because that is what
3189    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
3190    /// about *which* limiter it was makes it a different kind of failure. What differs is
3191    /// the operator's next step, and that is what the message carries — a secondary
3192    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
3193    /// budget looks fine, and then back to retry the very burst that was refused.
3194    fn exhausted(
3195        self,
3196        doing: &str,
3197        waits: u32,
3198        waited: Duration,
3199        needed: Duration,
3200        budget: Duration,
3201    ) -> SourceError {
3202        SourceError::RateLimited {
3203            retry_after_seconds: self.hint,
3204            message: Some(format!(
3205                "{} refused this source while {doing}; it waited {} out over {} and was refused \
3206                 again, and the next wait of {} would take it past the {} one call may spend \
3207                 waiting. {} next: {}",
3208                self.limiter.name(),
3209                plural(waits, "refusal"),
3210                seconds(waited),
3211                seconds(needed),
3212                seconds(budget),
3213                self.limiter.where_to_look(),
3214                self.limiter.what_to_do(),
3215            )),
3216        }
3217    }
3218}
3219
3220/// One HTTP attempt's result, with what its response said about the rate limit.
3221///
3222/// The two travel together so the record and the outcome are written from the same place:
3223/// what a response said about the budget is only readable while that response is in hand,
3224/// and what the attempt *meant* is only decidable once its body has been read.
3225struct Attempted {
3226    result: Result<Value, Attempt>,
3227    limits: accounting::RateLimit,
3228    /// GitHub's own reported cost for this call, for a document that asked for it.
3229    reported_cost: Option<u64>,
3230    response_outcome: accounting::ResponseOutcome,
3231}
3232
3233/// One attempt's outcome: an error to report, or a rate limit to wait out.
3234enum Attempt {
3235    Failed(SourceError),
3236    Limited(Limited),
3237    /// GitHub refused a write as a temporary conflict and asked for it again — see
3238    /// [`CONFLICT_WORDINGS`]. Carries what GitHub said, for the diagnostic once retrying ends.
3239    Conflicted(String),
3240}
3241
3242fn plural(count: u32, thing: &str) -> String {
3243    if count == 1 {
3244        format!("{count} {thing}")
3245    } else {
3246        format!("{count} {thing}s")
3247    }
3248}
3249
3250fn seconds(duration: Duration) -> String {
3251    format!("{:.1}s", duration.as_secs_f64())
3252}
3253
3254/// The member [`stamp_served`] writes onto every node of a response that says when it last
3255/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
3256const SERVED_AT: &str = "onetaskgraphServedAt";
3257
3258/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
3259/// node's time later knows the server's clock on the response that read it — which is what
3260/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
3261fn stamp_served(data: &mut Value, served: &str) {
3262    match data {
3263        Value::Object(members) => {
3264            if members.contains_key("updatedAt") {
3265                members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
3266            }
3267            for member in members.values_mut() {
3268                stamp_served(member, served);
3269            }
3270        }
3271        Value::Array(items) => {
3272            for item in items {
3273                stamp_served(item, served);
3274            }
3275        }
3276        _ => {}
3277    }
3278}
3279
3280/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
3281///
3282/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
3283/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
3284/// header, and neither is what makes a response a refusal — so the whole cost of one this
3285/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
3286/// instead. Refusing the response over the header would turn a readable refusal into an
3287/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
3288fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
3289    value
3290        .and_then(|value| value.to_str().ok())
3291        .and_then(|value| value.trim().parse::<u64>().ok())
3292}
3293
3294/// A transport failure with every cause beneath it, outermost first.
3295///
3296/// The client's own text for a request it could not complete is `error sending request for
3297/// url (…)`, and the socket error that says why — a refused or reset connection, an exhausted
3298/// port range, a connection closed before its answer — is only in the chain of sources below
3299/// it. Those differ in what to do next, so all of them are kept.
3300pub(crate) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String {
3301    let mut text = error.to_string();
3302    let mut cause = error.source();
3303    while let Some(next) = cause {
3304        let said = next.to_string();
3305        if !text.ends_with(&said) {
3306            text = format!("{text}: {said}");
3307        }
3308        cause = next.source();
3309    }
3310    text
3311}
3312
3313/// Every mutation this source sends creates content — an issue, a board item, a field of
3314/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
3315/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
3316/// and what the keyword says are the same set. That is what makes the keyword a sound test
3317/// rather than a convenient one: counting an edit or a removal the limiter might not have
3318/// counted costs a wait, and not counting one it did count costs the next fifty minutes.
3319pub(crate) fn is_mutation(query: &str) -> bool {
3320    query.trim_start().starts_with("mutation")
3321}
3322
3323/// What this source was doing, for a diagnostic that has to say so.
3324///
3325/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
3326/// a document added without a description is caught by that list's own gate instead of
3327/// falling through to the vague arm below.
3328fn operation_description(query: &str) -> &'static str {
3329    let query = graphql::inventoried(query);
3330    graphql::DOCUMENTS
3331        .iter()
3332        .find(|(document, _)| *document == query)
3333        .map_or("talking to GitHub", |(_, doing)| *doing)
3334}
3335
3336/// GitHub's published ceiling on content-generating requests in any one minute.
3337///
3338/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
3339/// GitHub's number rather than this source's. This source paces at it over its window: a
3340/// content-creating request goes out at once while the minute's creations leave room for it,
3341/// and waits until they do otherwise — see the `pacing` module.
3342pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
3343/// The same ceiling as GitHub publishes it for any one hour, paced over its window as the
3344/// per-minute one is. Pinned beside its sibling so the gate would notice either one moving.
3345pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
3346/// GitHub's published ceiling on secondary points in any one minute: what every GraphQL
3347/// request is charged against, a mutation [`SECONDARY_POINTS_PER_MUTATION`] and a query
3348/// [`SECONDARY_POINTS_PER_QUERY`]. Pinned and paced as [`CONTENT_CREATION_PER_MINUTE`] is.
3349pub const SECONDARY_POINTS_PER_MINUTE: u64 = 2_000;
3350/// The secondary points one GraphQL request carrying a mutation is charged.
3351pub const SECONDARY_POINTS_PER_MUTATION: u64 = 5;
3352/// The secondary points one GraphQL request carrying no mutation is charged.
3353pub const SECONDARY_POINTS_PER_QUERY: u64 = 1;
3354/// The fastest *steady* spacing of content-creating mutations that cannot exceed
3355/// [`CONTENT_CREATION_PER_MINUTE`], in milliseconds: 60000/80 is 750.
3356///
3357/// It is no longer the default. This source paces by GitHub's windows instead, so a copy's
3358/// few dozen writes go out as fast as GitHub answers them while the minute has room, rather
3359/// than 750 ms apart — which was most of a plan copy's wall time. An installation that wants
3360/// mutations spaced as well says so through `pacing.min_mutation_interval_ms`, and this is the
3361/// value that holds a long sustained burst under the per-minute bound by spacing alone.
3362pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
3363/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
3364///
3365/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
3366/// own advice for a secondary limit — wait, and wait longer each time — without spending
3367/// the first minute of a transient refusal doing nothing.
3368pub const RETRY_BACKOFF_MS: u64 = 1_000;
3369/// Total time one call may spend waiting out rate limits before it reports a failure.
3370///
3371/// Two minutes is long enough to ride out the refusals a paced copy still collects and
3372/// short enough that a command an operator is watching returns. The bound is what makes
3373/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
3374/// the limiter, not in a process nobody can tell from a wedged one.
3375pub const RETRY_BUDGET_MS: u64 = 120_000;
3376
3377fn default_token_env() -> String {
3378    "GH_PROJECTS_TOKEN".to_owned()
3379}
3380fn default_endpoint() -> String {
3381    "https://api.github.com/graphql".to_owned()
3382}
3383
3384/// The name of a `Status` single-select option on the board.
3385///
3386/// Validated on the way in rather than checked later, so a blank option name — which
3387/// nothing on a board can be — is a state this type cannot hold.
3388#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3389#[serde(try_from = "String")]
3390#[schemars(extend("minLength" = 1))]
3391pub struct ColumnName(String);
3392
3393impl ColumnName {
3394    /// The option name, as the board spells it.
3395    fn as_str(&self) -> &str {
3396        &self.0
3397    }
3398}
3399
3400impl TryFrom<String> for ColumnName {
3401    type Error = String;
3402
3403    fn try_from(name: String) -> Result<Self, Self::Error> {
3404        if name.trim().is_empty() {
3405            return Err("a status_mapping option name cannot be blank".to_owned());
3406        }
3407        Ok(Self(name))
3408    }
3409}
3410
3411/// The two closed states this product can mean.
3412///
3413/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
3414/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
3415/// product never decides on a person's behalf that two issues are the same work.
3416#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
3417#[serde(rename_all = "kebab-case")]
3418pub enum ClosedState {
3419    /// `COMPLETED` — precisely done.
3420    Completed,
3421    /// `NOT_PLANNED` — precisely cancelled.
3422    NotPlanned,
3423}
3424
3425impl ClosedState {
3426    const fn reason(self) -> &'static str {
3427        match self {
3428            Self::Completed => "COMPLETED",
3429            Self::NotPlanned => "NOT_PLANNED",
3430        }
3431    }
3432}
3433
3434/// Configuration for one GitHub Projects v2 board.
3435///
3436/// It serializes to a document this same type reads back as itself, so a caller writing a
3437/// configuration — a consumer filing onto a board it configures — can build one here rather
3438/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
3439#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3440#[serde(default, deny_unknown_fields)]
3441pub struct GitHubProjectsConfig {
3442    /// Login of the user or organization which owns the board.
3443    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
3444    /// The project number shown in the board's GitHub URL.
3445    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
3446    // 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.
3447    /// `owner/name` of the repository this source creates an issue in when the item's own
3448    /// `repositories` field does not decide it.
3449    ///
3450    /// An item naming exactly one repository is created there — on a source declared
3451    /// `visibility: private`, only when that repository reads private, and otherwise as an
3452    /// item naming none is; a task or a document naming none or several is created in its
3453    /// parent project's repository; and a project, or a task or document with no parent,
3454    /// naming none or several is created here. A board
3455    /// has no repository of its own and `createIssue` requires one, so a write without
3456    /// this is refused naming the field. Reads never need it.
3457    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
3458    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
3459    /// GitHub logins whose repositories this source may create an issue in.
3460    ///
3461    /// Absent or empty, the default, restricts nothing. Otherwise a create whose issue the
3462    /// rule `repository` describes would file in a repository owned by anyone else is refused
3463    /// before anything of it is written — by `task copy`, `task copy --create`, a member
3464    /// create of a project copy and a dry run of each — with the error kind `creation-owner`,
3465    /// naming the repository and these owners. Logins compare case-insensitively, as GitHub
3466    /// compares them. An update of an issue that already exists is never refused by it.
3467    /// `task locate` reports such a refusal as `creates_in.refused` rather than failing.
3468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3469    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3470    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3471    #[schemars(!skip_serializing_if)]
3472    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.
3473    /// Environment variable containing a fine-grained token with Projects and Issues
3474    /// read/write plus Pull requests read-only access for every repository represented on
3475    /// the board.
3476    #[serde(default = "default_token_env")]
3477    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
3478    /// GraphQL endpoint. GitHub Enterprise installations may override it.
3479    #[serde(default = "default_endpoint")]
3480    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
3481    /// Per-instance mapping from a status category to the option of the board's one
3482    /// `Status` field it lands on, for a task and for a project.
3483    ///
3484    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
3485    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
3486    /// where a kind left out leaves the category unmapped for that kind. A category this
3487    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
3488    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
3489    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
3490    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
3491    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
3492    /// either kind. No two categories may name one option for the same kind, ignoring case.
3493    /// `unknown` may name one existing option; every unknown word then lands on it and
3494    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
3495    /// each unknown word because it never creates board options.
3496    #[serde(default)]
3497    pub status_mapping: StatusMapping,
3498    /// Per-instance mapping from a task's priority to an option of this board's
3499    /// single-select field named `Priority`.
3500    ///
3501    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
3502    /// other priority is refused before it reaches this board. Present, each of `urgent`,
3503    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
3504    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
3505    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
3506    /// no two levels may name one option. Reads and writes never create the field or an
3507    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
3508    /// the board lacks is refused pointing there.
3509    #[serde(default)]
3510    pub priority_mapping: Option<PriorityMappingConfig>,
3511    /// Caller metadata values this source also writes to board text fields, so the board can
3512    /// filter on them.
3513    ///
3514    /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
3515    /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
3516    /// value. On every item write — create, update and copy, of a task, a project or a
3517    /// document — a string found there is written to the field, an array of strings as one
3518    /// line per element and an object of string values as one `KEY: VALUE` line per entry in
3519    /// byte order of the keys, shortened to whole lines and a count of the rest past the
3520    /// field's 1,024-byte limit; nothing is sent when the field already holds that text;
3521    /// nothing there, or `null`, clears the field when it holds a value; any other JSON type
3522    /// is refused before any mutation. `docs/metadata.md` states the rule. The metadata comment in the
3523    /// issue body stays the one home of the value, and every read reports metadata from it.
3524    /// Reads and writes never create the field: `onetaskgraph sources fields <source>
3525    /// --apply` does, as a text field, and a write to a board lacking it, or holding a
3526    /// non-text field of that name, is refused pointing there.
3527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3528    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3529    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3530    #[schemars(!skip_serializing_if)]
3531    pub metadata_fields: Vec<MetadataFieldConfig>,
3532    /// How fast this source writes, and how long it waits out a rate-limit refusal.
3533    ///
3534    /// Every field keeps its shipped default when it is absent, and the defaults are
3535    /// GitHub's own published limits rather than taste. See [`Pacing`].
3536    #[serde(default)]
3537    pub pacing: PacingConfig,
3538    /// What an issue's sub-issues mean on this board.
3539    ///
3540    /// `project`, the default, is this source's meaning since it was first written: an issue
3541    /// with sub-issues reads as a project and its sub-issues as that project's tasks. `parent`
3542    /// reads every issue as a task and a sub-issue as a child under the parent relation, which
3543    /// `task parent set-many` writes and `task relations` reads; a project write to such a
3544    /// source is refused naming this setting. The two meanings answer differently for the same
3545    /// issues, so a `parent` source needs a board of its own: two sources naming one board with
3546    /// different `sub_issues` are refused when the configuration is read.
3547    #[serde(default, skip_serializing_if = "SubIssues::is_project")]
3548    pub sub_issues: SubIssues,
3549    /// The link types this source also writes as GitHub's own `relates-to` relation, beside the
3550    /// store's metadata, so they show in the issue's Relationships panel: each type name mapped
3551    /// to `{"native": "relates-to"}`. A type not named here is kept in metadata alone. Any other
3552    /// word — `blocks`, `blocked-by`, `duplicate`, `similar` among them — is refused when the
3553    /// source is built.
3554    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3555    // Kept in the schema as `"default": {}` although a serialized configuration leaves an empty
3556    // map out, so both SDKs model an absent map as an empty one rather than as `null`.
3557    #[schemars(!skip_serializing_if)]
3558    pub link_types: BTreeMap<String, onetaskgraph_plugin_api::LinkTypeConfig>,
3559    /// Whether this source's tasks carry the GitHub users each issue is assigned to, and
3560    /// take an assignee write. `false`, the default, reads no issue's assignees and writes
3561    /// none: every request this source sends is then exactly what it was before assignees
3562    /// existed. `true` reads each issue's assignees on the reads it already makes, and writes
3563    /// one person or none — by login — in the `createIssue` or `updateIssue` the write already
3564    /// sends, refusing a person GitHub does not know or leaves off the issue.
3565    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3566    // Kept in the schema as `"default": false` although a serialized configuration leaves a
3567    // `false` out, so both SDKs model an absent setting as `false` rather than as `null`.
3568    #[schemars(!skip_serializing_if)]
3569    pub assignees: bool,
3570}
3571
3572/// What an issue's sub-issues mean on a GitHub Projects board: see
3573/// [`GitHubProjectsConfig::sub_issues`].
3574#[derive(
3575    Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
3576)]
3577#[serde(rename_all = "kebab-case")]
3578pub enum SubIssues {
3579    /// An issue with sub-issues is a project, and its sub-issues are its tasks.
3580    #[default]
3581    Project,
3582    /// Every issue is a task, and a sub-issue is a child under the parent relation.
3583    Parent,
3584}
3585
3586impl SubIssues {
3587    /// Whether this is the default meaning, which a serialized configuration leaves out.
3588    #[must_use]
3589    pub const fn is_project(&self) -> bool {
3590        matches!(self, Self::Project)
3591    }
3592}
3593
3594/// Which option of the board's `Priority` field each priority lands on.
3595///
3596/// One member per level rather than a map, so a key that is not a level is refused where
3597/// the configuration is read, naming the levels there are. `none` is not a member: it is no
3598/// value in the field, not an option of it.
3599#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3600#[serde(default, deny_unknown_fields)]
3601pub struct PriorityMappingConfig {
3602    /// The option `urgent` lands on; `Urgent` when absent.
3603    pub urgent: Option<PriorityOptionName>,
3604    /// The option `high` lands on; `High` when absent.
3605    pub high: Option<PriorityOptionName>,
3606    /// The option `medium` lands on; `Medium` when absent.
3607    pub medium: Option<PriorityOptionName>,
3608    /// The option `low` lands on; `Low` when absent.
3609    pub low: Option<PriorityOptionName>,
3610}
3611
3612/// The name of an option of the board's `Priority` single-select field.
3613///
3614/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
3615/// blank name.
3616#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3617#[serde(try_from = "String")]
3618#[schemars(extend("minLength" = 1))]
3619pub struct PriorityOptionName(String);
3620
3621impl PriorityOptionName {
3622    /// The option name, as the board spells it.
3623    fn as_str(&self) -> &str {
3624        &self.0
3625    }
3626}
3627
3628impl TryFrom<String> for PriorityOptionName {
3629    type Error = String;
3630
3631    fn try_from(name: String) -> Result<Self, Self::Error> {
3632        if name.trim().is_empty() {
3633            return Err("a priority_mapping option name cannot be blank".to_owned());
3634        }
3635        Ok(Self(name))
3636    }
3637}
3638
3639/// The name of the board field a priority is held in.
3640pub const PRIORITY_FIELD: &str = "Priority";
3641
3642/// The four priorities a board option can hold, in the order a new `Priority` field lists
3643/// them. `none` is not among them: it is the field holding no value.
3644///
3645/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
3646/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
3647/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
3648/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
3649pub const PRIORITY_LEVELS: [Priority; 4] = [
3650    Priority::Urgent,
3651    Priority::High,
3652    Priority::Medium,
3653    Priority::Low,
3654];
3655
3656/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
3657/// see that list for what this pins.
3658#[must_use]
3659pub const fn level_position(priority: Priority) -> Option<usize> {
3660    match priority {
3661        Priority::None => None,
3662        Priority::Urgent => Some(0),
3663        Priority::High => Some(1),
3664        Priority::Medium => Some(2),
3665        Priority::Low => Some(3),
3666    }
3667}
3668
3669/// This instance's complete priority-to-option mapping, read in both directions.
3670///
3671/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
3672/// two levels name one option.
3673#[derive(Debug, Clone)]
3674struct PriorityMapping {
3675    options: [PriorityOptionName; 4],
3676}
3677
3678impl PriorityMapping {
3679    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3680        let shipped = |name: &str| PriorityOptionName(name.to_owned());
3681        let mapping = Self {
3682            options: [
3683                config.urgent.unwrap_or_else(|| shipped("Urgent")),
3684                config.high.unwrap_or_else(|| shipped("High")),
3685                config.medium.unwrap_or_else(|| shipped("Medium")),
3686                config.low.unwrap_or_else(|| shipped("Low")),
3687            ],
3688        };
3689        for (index, option) in mapping.options.iter().enumerate() {
3690            if let Some(earlier) = mapping.options[..index]
3691                .iter()
3692                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
3693            {
3694                return Err(SourceError::Config {
3695                    message: format!(
3696                        "priority_mapping of source {instance} sends both {} and {} to the board \
3697                         option {:?}; one option cannot read back as two priorities",
3698                        PRIORITY_LEVELS[earlier],
3699                        PRIORITY_LEVELS[index],
3700                        option.as_str()
3701                    ),
3702                });
3703            }
3704        }
3705        Ok(mapping)
3706    }
3707
3708    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
3709    fn option(&self, priority: Priority) -> Option<&str> {
3710        level_position(priority).map(|index| self.options[index].as_str())
3711    }
3712
3713    /// The priority a board option name reports, or `None` when nothing maps to it.
3714    fn priority_of(&self, option: &str) -> Option<Priority> {
3715        self.options
3716            .iter()
3717            .position(|name| name.as_str().eq_ignore_ascii_case(option))
3718            .map(|index| PRIORITY_LEVELS[index])
3719    }
3720
3721    /// Every mapped option name, in the order a new `Priority` field lists them.
3722    fn names(&self) -> impl Iterator<Item = &str> {
3723        self.options.iter().map(PriorityOptionName::as_str)
3724    }
3725}
3726
3727/// One metadata value this source projects onto a board text field.
3728#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3729#[serde(deny_unknown_fields)]
3730pub struct MetadataFieldConfig {
3731    /// The name of the board text field the value is written to.
3732    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.
3733    /// The top-level metadata key the value is read from. A key under the reserved
3734    /// `onetaskgraph.` prefix is refused.
3735    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.
3736    /// Object keys walked inside that key's value, outermost first. Absent or empty, the
3737    /// key's own value is the one projected. Left out of a serialized entry when empty.
3738    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3739    // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
3740    #[schemars(!skip_serializing_if)]
3741    pub path: Vec<String>,
3742}
3743
3744/// The board fields no metadata value may be projected onto, compared ignoring case: the
3745/// three this source writes itself, and those GitHub owns on every board.
3746// 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.
3747const UNPROJECTABLE_FIELDS: [&str; 13] = [
3748    STATUS_FIELD,
3749    PRIORITY_FIELD,
3750    ORIGIN_FIELD,
3751    "Title",
3752    "Assignees",
3753    "Labels",
3754    "Linked pull requests",
3755    "Milestone",
3756    "Repository",
3757    "Reviewers",
3758    "Parent issue",
3759    "Sub-issues progress",
3760    "Type",
3761];
3762
3763/// One validated [`MetadataFieldConfig`] entry.
3764#[derive(Debug, Clone)]
3765struct MetadataField {
3766    field: ProjectedField,
3767    key: ProjectedKey,
3768    path: Vec<String>,
3769}
3770
3771/// The name of a board text field a metadata value is projected onto: never blank, never one
3772/// this source or GitHub writes, and never one another entry of the same source names.
3773#[derive(Debug, Clone, PartialEq, Eq)]
3774struct ProjectedField(String);
3775
3776/// The top-level metadata key a projected value is read from: never blank, and never under
3777/// the namespace this product reserves.
3778#[derive(Debug, Clone, PartialEq, Eq)]
3779struct ProjectedKey(String);
3780
3781impl std::ops::Deref for ProjectedField {
3782    type Target = str;
3783    fn deref(&self) -> &str {
3784        &self.0
3785    }
3786}
3787
3788impl std::ops::Deref for ProjectedKey {
3789    type Target = str;
3790    fn deref(&self) -> &str {
3791        &self.0
3792    }
3793}
3794
3795/// What one item's metadata says one projected field should hold.
3796#[derive(Debug, Clone, PartialEq, Eq)]
3797enum Projected {
3798    /// A string to hold.
3799    Text(String),
3800    /// Nothing: the key or a step of the path is absent, or the value is `null`.
3801    Nothing,
3802}
3803
3804impl MetadataField {
3805    /// Validate every entry of one instance's `metadata_fields`, refusing the first that
3806    /// cannot stand, naming the source and the entry.
3807    fn resolve(
3808        configured: Vec<MetadataFieldConfig>,
3809        instance: &SourceName,
3810    ) -> Result<Vec<Self>, SourceError> {
3811        let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
3812        for (index, entry) in configured.into_iter().enumerate() {
3813            let refuse = |why: String| SourceError::Config {
3814                message: format!(
3815                    "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
3816                    entry.field, entry.key
3817                ),
3818            };
3819            if let Some(why) = ProjectedValue::entry_refusal(
3820                &entry.field,
3821                &entry.key,
3822                resolved.iter().map(|other| &*other.field),
3823                "board field",
3824            ) {
3825                return Err(refuse(why));
3826            }
3827            if let Some(owned) = UNPROJECTABLE_FIELDS
3828                .iter()
3829                .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
3830            {
3831                return Err(refuse(format!(
3832                    "names the board field {owned:?}, which this source or GitHub already \
3833                     writes; next: name a text field of your own"
3834                )));
3835            }
3836            resolved.push(Self {
3837                field: ProjectedField(entry.field),
3838                key: ProjectedKey(entry.key),
3839                path: entry.path,
3840            });
3841        }
3842        Ok(resolved)
3843    }
3844
3845    /// The path as a refusal spells it: `[]` for the key's own value.
3846    fn spelled_path(&self) -> String {
3847        serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
3848    }
3849
3850    /// What `metadata` says this field should hold, or the refusal for a value no text field
3851    /// can hold.
3852    fn projected(
3853        &self,
3854        metadata: &BTreeMap<String, Value>,
3855        instance: &SourceName,
3856    ) -> Result<Projected, SourceError> {
3857        let found = match ProjectedValue::read(metadata, &self.key, &self.path) {
3858            Ok(None) => return Ok(Projected::Nothing),
3859            Ok(Some(value)) => return Ok(Projected::Text(field_text(&value))),
3860            Err(found) => found,
3861        };
3862        Err(SourceError::Refused {
3863            message: format!(
3864                "source {instance} projects metadata key {:?} at path {} onto its board text \
3865                 field {:?}, and this item holds {found} there; only a string, an array of \
3866                 strings, an object of string values, null or nothing can be written to a text \
3867                 field; next: store one of those there, or remove the value",
3868                &*self.key,
3869                self.spelled_path(),
3870                &*self.field
3871            ),
3872        })
3873    }
3874}
3875
3876/// The most a ProjectV2 text field holds, in UTF-8 bytes.
3877///
3878/// Spike `spike-sample-ticket` measured it on a live board through this store with ASCII
3879/// values: 1,024 accepted, 1,025 refused whole with `Column value must be a valid value for
3880/// text column`, and nothing on the item changed. GitHub documents neither the limit nor its
3881/// unit, so this source counts UTF-8 bytes — at least as many as characters or UTF-16 units —
3882/// which can only shorten a non-ASCII value earlier than GitHub would, never send one it
3883/// refuses.
3884// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] GitHub publishes no machine-readable statement of this limit — neither its documentation nor its GraphQL schema (`ProjectV2FieldValue.text: String`) names one — so the only source is the measurement above, and a live probe would spend the credentialed lane's budget, which is a decision outside this change. Drift cannot pass silently either way: a raised limit only shortens a value earlier than needed, and a lowered one makes GitHub refuse the write, which fails the command naming the field.
3885pub const TEXT_FIELD_LIMIT: usize = 1024;
3886
3887/// The text a board text field holds for `value`, as `docs/metadata.md` states it: a string as
3888/// itself, an array's elements and an object's `KEY: VALUE` entries — in byte order of the
3889/// keys — one per line, joined by one line feed; and, over [`TEXT_FIELD_LIMIT`], the longest
3890/// prefix of whole lines that fits beside one line feed and the line counting the rest.
3891fn field_text(value: &ProjectedValue) -> String {
3892    let rendered = match value {
3893        ProjectedValue::Text(text) => text.clone(),
3894        ProjectedValue::List(list) => list.join("\n"),
3895        ProjectedValue::Map(map) => map
3896            .iter()
3897            .map(|(key, value)| format!("{key}: {value}"))
3898            .collect::<Vec<_>>()
3899            .join("\n"),
3900    };
3901    if rendered.len() <= TEXT_FIELD_LIMIT {
3902        return rendered;
3903    }
3904    let lines: Vec<&str> = rendered.split('\n').collect();
3905    let elided = |left_out: usize| {
3906        let noun = if left_out == 1 { "line" } else { "lines" };
3907        format!("\u{2026} {left_out} more {noun} in this item's metadata")
3908    };
3909    // The bytes the first `kept` lines take with the line feeds between them.
3910    let mut prefix = 0;
3911    let mut fits = 0;
3912    for (kept, line) in lines.iter().enumerate().map(|(at, line)| (at + 1, line)) {
3913        prefix += line.len() + usize::from(kept > 1);
3914        if kept == lines.len() || prefix + 1 + elided(lines.len() - kept).len() > TEXT_FIELD_LIMIT {
3915            break;
3916        }
3917        fits = kept;
3918    }
3919    if fits == 0 {
3920        return elided(lines.len());
3921    }
3922    format!(
3923        "{}\n{}",
3924        lines[..fits].join("\n"),
3925        elided(lines.len() - fits)
3926    )
3927}
3928
3929/// What one item's `Priority` field says, read through this instance's mapping.
3930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3931enum HeldPriority {
3932    /// A priority this source reports: an option the mapping names, or no value (`none`).
3933    Read(Priority),
3934    /// An option the mapping does not name, which is never read as a level or as `none`.
3935    Unmapped(String),
3936}
3937
3938/// How fast this source writes, and how long it waits out a rate-limit refusal.
3939///
3940/// Configurable because a GitHub Enterprise installation sets its own limits and an
3941/// operator who has already been refused may want to go slower still — not because the
3942/// defaults are guesses.
3943#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3944#[serde(default, deny_unknown_fields)]
3945pub struct PacingConfig {
3946    /// Shortest interval between two content-creating mutations, in milliseconds.
3947    ///
3948    /// Zero sends them as fast as they are asked for, which is what a fixture server on
3949    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
3950    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.
3951    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
3952    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
3953    /// zero while there is a budget to spend, because a schedule of zero-length waits
3954    /// consumes none of it and so never ends.
3955    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.
3956    /// Total time one call may spend waiting out rate limits, in milliseconds.
3957    ///
3958    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
3959    /// the bound is what makes this a wait rather than a hang.
3960    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.
3961}
3962
3963/// The largest any pacing setting may be, in milliseconds.
3964///
3965/// One hour. GitHub's own harshest published bound on content-generating requests works
3966/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
3967/// anything a real limit asks for, and past it the settings stop describing pacing at all:
3968/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
3969/// and an interval beyond it is a command that never sends its second mutation. It also
3970/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
3971/// what a `Duration` can hold on every platform.
3972pub const MAX_PACING_MS: u64 = 3_600_000;
3973
3974/// [`PacingConfig`] with every default resolved and every value checked, which is what the
3975/// source holds.
3976///
3977/// Unset, `min_mutation_interval_ms` spaces nothing, and every request is paced by GitHub's
3978/// secondary windows alone — see the `pacing` module; set, it spaces content-creating mutations
3979/// on top of those windows, [`MIN_MUTATION_INTERVAL_MS`] being the spacing that holds a sustained
3980/// burst under the per-minute bound by spacing alone; and zero sends every request as fast as it
3981/// is asked for, windows included.
3982#[derive(Debug, Clone, Copy)]
3983struct Pacing {
3984    /// Whether requests are held for GitHub's secondary windows: always, unless
3985    /// `min_mutation_interval_ms` is set to exactly zero, which has always asked for no
3986    /// pacing at all — what a fixture server on loopback wants and no board on github.com does.
3987    windows: bool,
3988    min_mutation_interval: Duration,
3989    retry_backoff: Duration,
3990    retry_budget: Duration,
3991}
3992
3993impl Pacing {
3994    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
3995    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3996        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
3997            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
3998                message: format!(
3999                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
4000                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
4001                     GitHub's own harshest published limit"
4002                ),
4003            }),
4004            Some(value) => Ok(Duration::from_millis(value)),
4005            None => Ok(Duration::from_millis(default)),
4006        };
4007        let retry_backoff = bounded(
4008            config.retry_backoff_ms,
4009            RETRY_BACKOFF_MS,
4010            "retry_backoff_ms",
4011        )?;
4012        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
4013        if retry_backoff.is_zero() && !retry_budget.is_zero() {
4014            return Err(SourceError::Config {
4015                message: format!(
4016                    "pacing.retry_backoff_ms of source {instance} is 0 while \
4017                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
4018                     none of that budget, so it would retry a refusal forever. Set a backoff of \
4019                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
4020                     waiting at all",
4021                    retry_budget.as_millis()
4022                ),
4023            });
4024        }
4025        Ok(Self {
4026            windows: config.min_mutation_interval_ms != Some(0),
4027            min_mutation_interval: bounded(
4028                config.min_mutation_interval_ms,
4029                0,
4030                "min_mutation_interval_ms",
4031            )?,
4032            retry_backoff,
4033            retry_budget,
4034        })
4035    }
4036}
4037
4038/// Factory for [`GitHubProjectsSource`].
4039#[derive(Debug, Clone, Copy, Default)]
4040pub struct Plugin;
4041
4042impl SourcePlugin for Plugin {
4043    fn kind(&self) -> &'static str {
4044        KIND
4045    }
4046    /// The endpoint, the owner and the board's number: what names one board.
4047    fn remote_identity(&self, config: &Value) -> Option<String> {
4048        let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
4049        Some(format!(
4050            "{}\n{}\n{}",
4051            config.endpoint, config.owner, config.project_number
4052        ))
4053    }
4054    fn config_schema(&self) -> Schema {
4055        schema_for!(GitHubProjectsConfig)
4056    }
4057    fn build(
4058        &self,
4059        name: &SourceName,
4060        config: &Value,
4061        secrets: &dyn SecretResolver,
4062    ) -> Result<Box<dyn TaskSource>, SourceError> {
4063        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
4064    }
4065
4066    fn build_with_clock(
4067        &self,
4068        name: &SourceName,
4069        config: &Value,
4070        secrets: &dyn SecretResolver,
4071        clock: SharedClock,
4072    ) -> Result<Box<dyn TaskSource>, SourceError> {
4073        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
4074    }
4075}
4076
4077/// The client `cell` holds, building it with `build` the first time it is asked for.
4078///
4079/// A failed build is the request's failure, with the message the source's construction gave it
4080/// before clients were deferred.
4081// 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`.
4082fn built(
4083    cell: &std::sync::OnceLock<Client>,
4084    build: impl FnOnce() -> Result<Client, SourceError>,
4085) -> Result<&Client, SourceError> {
4086    if let Some(client) = cell.get() {
4087        return Ok(client);
4088    }
4089    let client = build()?;
4090    Ok(cell.get_or_init(|| client))
4091}
4092
4093impl Plugin {
4094    /// Build a source recording every request it sends into an accounting the caller holds.
4095    ///
4096    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
4097    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
4098    /// session total rather than two — see [`accounting`] and
4099    /// [`GitHubProjectsSource::recording_into`].
4100    ///
4101    /// # Errors
4102    ///
4103    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
4104    /// [`SourceError::Config`] for configuration this plugin cannot use and
4105    /// [`SourceError::Auth`] for a credential it cannot find.
4106    pub fn build_recording_into(
4107        &self,
4108        name: &SourceName,
4109        config: &Value,
4110        secrets: &dyn SecretResolver,
4111        ledger: Arc<Accounting>,
4112    ) -> Result<Box<dyn TaskSource>, SourceError> {
4113        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
4114    }
4115
4116    fn build_recording_with_clock(
4117        &self,
4118        name: &SourceName,
4119        config: &Value,
4120        secrets: &dyn SecretResolver,
4121        ledger: Arc<Accounting>,
4122        clock: SharedClock,
4123    ) -> Result<Box<dyn TaskSource>, SourceError> {
4124        let config: GitHubProjectsConfig =
4125            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
4126                message: format!("source {name}: {e}"),
4127            })?;
4128        let prefix = format!("source {name}: ");
4129        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
4130            .map_err(|error| match error {
4131                // The shared `StatusMapping::distinct` names the source itself.
4132                SourceError::Config { message } if message.starts_with(&prefix) => {
4133                    SourceError::Config { message }
4134                }
4135                SourceError::Config { message } => SourceError::Config {
4136                    message: format!("{prefix}{message}"),
4137                },
4138                SourceError::Auth { message } => SourceError::Auth {
4139                    message: format!("source {name}: {message}"),
4140                },
4141                other => other,
4142            })?;
4143        source.clock = clock;
4144        Ok(Box::new(source))
4145    }
4146}
4147
4148/// Where a status category lands on this board, once configuration is resolved.
4149#[derive(Debug, Clone, PartialEq, Eq)]
4150enum StatusTarget {
4151    /// Not usable against this instance for this kind, and why.
4152    Disabled(UnmappedStatus),
4153    /// The board's `Status` option of this name.
4154    Column(ColumnName),
4155    /// A closed issue, with both its board option and the reason that says which closed it means.
4156    // 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.
4157    Terminal(ColumnName, ClosedState),
4158}
4159
4160/// Every status category, in the order the vocabulary declares them.
4161///
4162/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
4163/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
4164/// added to the shared vocabulary fails to compile until it is named there, and this
4165/// crate's suite reconciles this list against that enum's own derived schema, which is
4166/// generated from the variants rather than written beside them. The schema is what
4167/// catches a list left one short — a list checking only the positions it already holds
4168/// would pass while every mapping indexed by the new position panicked.
4169pub const CATEGORIES: [StatusCategory; 8] = [
4170    StatusCategory::Draft,
4171    StatusCategory::Backlog,
4172    StatusCategory::Todo,
4173    StatusCategory::Queued,
4174    StatusCategory::InProgress,
4175    StatusCategory::Done,
4176    StatusCategory::Cancelled,
4177    StatusCategory::Unknown,
4178];
4179
4180/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
4181#[must_use]
4182pub const fn category_position(category: StatusCategory) -> usize {
4183    match category {
4184        StatusCategory::Draft => 0,
4185        StatusCategory::Backlog => 1,
4186        StatusCategory::Todo => 2,
4187        StatusCategory::Queued => 3,
4188        StatusCategory::InProgress => 4,
4189        StatusCategory::Done => 5,
4190        StatusCategory::Cancelled => 6,
4191        StatusCategory::Unknown => 7,
4192    }
4193}
4194
4195/// The spelling a status category is configured and reported under.
4196fn category_name(category: StatusCategory) -> &'static str {
4197    match category {
4198        StatusCategory::Draft => "draft",
4199        StatusCategory::Backlog => "backlog",
4200        StatusCategory::Todo => "todo",
4201        StatusCategory::Queued => "queued",
4202        StatusCategory::InProgress => "in-progress",
4203        StatusCategory::Done => "done",
4204        StatusCategory::Cancelled => "cancelled",
4205        StatusCategory::Unknown => "unknown",
4206    }
4207}
4208
4209/// A shipped default's option name.
4210///
4211/// The literals below are this file's own and non-blank, and they are validated by the
4212/// one constructor a configured name goes through rather than beside it.
4213fn shipped_column(name: &'static str) -> ColumnName {
4214    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
4215}
4216
4217/// The shipped default for one category this instance's `status_mapping` does not mention,
4218/// for either kind.
4219fn shipped_default(category: StatusCategory) -> StatusTarget {
4220    match category {
4221        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
4222        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
4223        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
4224        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
4225        StatusCategory::Done => {
4226            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
4227        }
4228        StatusCategory::Cancelled => {
4229            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
4230        }
4231        StatusCategory::Draft | StatusCategory::Unknown => {
4232            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
4233        }
4234    }
4235}
4236
4237/// The two kinds a status is written and read for, each with its own half of the mapping.
4238const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
4239
4240/// This instance's complete category-to-target mapping for each kind, read in both
4241/// directions.
4242///
4243/// One target per category per kind, held at that category's own [`category_position`], so
4244/// a category missing from the mapping, named twice in it, or filed out of order is a state
4245/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
4246/// targets are options of the board's one `Status` field.
4247#[derive(Debug, Clone)]
4248struct BoardStatuses {
4249    tasks: [StatusTarget; CATEGORIES.len()],
4250    projects: [StatusTarget; CATEGORIES.len()],
4251}
4252
4253impl BoardStatuses {
4254    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
4255    /// would read back from one option.
4256    ///
4257    /// A category the mapping does not mention keeps its shipped default for both kinds; one
4258    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
4259    /// omits unmapped rather than defaulted.
4260    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
4261        let resolve_kind =
4262            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
4263                // `CATEGORIES[position] == category` for every category — the crate's suite
4264                // asserts it — so mapping the list in order fills each category's own slot.
4265                let mut targets = CATEGORIES.map(shipped_default);
4266                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
4267                    if !configured.mentions(category) {
4268                        continue;
4269                    }
4270                    *slot = match configured.name_for(category, kind) {
4271                        Err(why) => StatusTarget::Disabled(why),
4272                        Ok(name) => {
4273                            let option = ColumnName::try_from(name.as_str().to_owned())
4274                                .map_err(|message| SourceError::Config { message })?;
4275                            match category {
4276                                StatusCategory::Done => {
4277                                    StatusTarget::Terminal(option, ClosedState::Completed)
4278                                }
4279                                StatusCategory::Cancelled => {
4280                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
4281                                }
4282                                _ => StatusTarget::Column(option),
4283                            }
4284                        }
4285                    };
4286                }
4287                StatusMapping::distinct(
4288                    instance,
4289                    kind,
4290                    CATEGORIES
4291                        .iter()
4292                        .zip(&targets)
4293                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
4294                )?;
4295                Ok(targets)
4296            };
4297        Ok(Self {
4298            tasks: resolve_kind(ItemKind::Task)?,
4299            projects: resolve_kind(ItemKind::Project)?,
4300        })
4301    }
4302
4303    /// Every category's target for `kind`, in category order.
4304    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
4305        match kind {
4306            ItemKind::Task => &self.tasks,
4307            ItemKind::Project => &self.projects,
4308        }
4309    }
4310
4311    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
4312        &self.targets(kind)[category_position(category)]
4313    }
4314
4315    /// The category a board option name reports for `kind`, or `None` when nothing of that
4316    /// kind maps to it.
4317    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
4318        CATEGORIES.into_iter().find(|category| {
4319            self.target(kind, *category)
4320                .option()
4321                .is_some_and(|name| name.eq_ignore_ascii_case(option))
4322        })
4323    }
4324
4325    /// Every option name either kind maps a category to, each once ignoring case, in
4326    /// category order with a task's name before a project's — what the guarded setup asks
4327    /// the `Status` field to hold.
4328    fn wanted(&self) -> Vec<String> {
4329        let mut wanted: Vec<String> = Vec::new();
4330        for category in CATEGORIES {
4331            for kind in STATUS_KINDS {
4332                if let Some(name) = self.target(kind, category).option()
4333                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
4334                {
4335                    wanted.push(name.to_owned());
4336                }
4337            }
4338        }
4339        wanted
4340    }
4341
4342    /// The status an item of `kind` reports, from the three things a read of it says: its
4343    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
4344    ///
4345    /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
4346    /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
4347    /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
4348    /// produce, so it is read permissively rather than refused — reads are faithful, and
4349    /// refusals belong on writes.
4350    ///
4351    /// **Its name follows the closure too, unless the option agrees with it.** The option's
4352    /// own name is kept when this kind's mapping reads that option as the same category, or
4353    /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
4354    /// named `Shipped`. An option the mapping reads as another category contradicts the
4355    /// closure, and so does no option at all: the name is then the option the mapping gives
4356    /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
4357    /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
4358    /// it. An `unknown` closure keeps the option, or `Closed` without one.
4359    ///
4360    /// An open item's option reads through its own kind's mapping, and an option that mapping
4361    /// does not name reads as `Unknown` under its own name.
4362    ///
4363    /// One function of those three rather than of a response, so a narrow status write can
4364    /// answer what a re-read would report by applying it to the state it has just written.
4365    fn status(
4366        &self,
4367        kind: ItemKind,
4368        option: Option<&str>,
4369        closed: bool,
4370        reason: Option<&str>,
4371    ) -> Status {
4372        if closed {
4373            let category = closed_category(reason);
4374            let agrees = option.filter(|option| {
4375                category == StatusCategory::Unknown
4376                    || self
4377                        .category_of(kind, option)
4378                        .is_none_or(|read| read == category)
4379            });
4380            let name = agrees.or_else(|| self.target(kind, category).option());
4381            let fallback = match category {
4382                StatusCategory::Done => "Done",
4383                StatusCategory::Cancelled => "Cancelled",
4384                _ => "Closed",
4385            };
4386            return Status {
4387                category,
4388                name: name.or(option).unwrap_or(fallback).to_owned(),
4389            };
4390        }
4391        let name = option.unwrap_or("Open").to_owned();
4392        Status {
4393            category: self
4394                .category_of(kind, &name)
4395                .unwrap_or(StatusCategory::Unknown),
4396            name,
4397        }
4398    }
4399
4400    /// The option a closed item's `Status` has to hold for its card to agree with its
4401    /// closure, and the category that closure reads as — or `None` when the item is open,
4402    /// its closure reads `unknown`, or its option already agrees.
4403    ///
4404    /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
4405    /// in line, each to the one option the mapping gives its category — an item with no
4406    /// option, or at one the mapping does not name, included.
4407    fn contradicted(
4408        &self,
4409        kind: ItemKind,
4410        option: Option<&str>,
4411        closed: bool,
4412        reason: Option<&str>,
4413    ) -> Option<(ClosedAs, &str)> {
4414        if !closed {
4415            return None;
4416        }
4417        let closed_as = match closed_category(reason) {
4418            StatusCategory::Done => ClosedAs::Done,
4419            StatusCategory::Cancelled => ClosedAs::Cancelled,
4420            _ => return None,
4421        };
4422        let wanted = self.target(kind, closed_as.category()).option()?;
4423        if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
4424            return None;
4425        }
4426        Some((closed_as, wanted))
4427    }
4428}
4429
4430/// The category a closed issue reads as, from the reason it was closed with.
4431///
4432/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
4433/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
4434/// is one this source cannot read a category from.
4435fn closed_category(reason: Option<&str>) -> StatusCategory {
4436    match reason {
4437        None | Some("COMPLETED") => StatusCategory::Done,
4438        Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
4439        Some(_) => StatusCategory::Unknown,
4440    }
4441}
4442
4443impl BoardStatuses {
4444    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
4445    /// case; a kind lacking none is left out.
4446    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
4447        STATUS_KINDS
4448            .into_iter()
4449            .filter_map(|kind| {
4450                let missing: Vec<String> = self
4451                    .targets(kind)
4452                    .iter()
4453                    .filter_map(StatusTarget::option)
4454                    .filter(|wanted| {
4455                        !existing
4456                            .iter()
4457                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4458                    })
4459                    .map(str::to_owned)
4460                    .collect();
4461                (!missing.is_empty()).then_some(KindMissing { kind, missing })
4462            })
4463            .collect()
4464    }
4465}
4466
4467impl StatusTarget {
4468    /// The board option this target selects, or `None` for an unmapped one.
4469    fn option(&self) -> Option<&str> {
4470        match self {
4471            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
4472            Self::Disabled(_) => None,
4473        }
4474    }
4475}
4476
4477// 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.
4478/// One repository this source can create an issue in, as `owner/name`.
4479///
4480/// Every `createIssue` this source sends names one of these: the item's own single
4481/// `repositories` entry — on a source declared private, only when it reads private — else
4482/// its parent project issue's repository, else the configured
4483/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
4484/// that choice and says what it refuses before `createIssue`.
4485// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
4486#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
4487#[serde(try_from = "String", into = "String")]
4488struct RepositoryTarget {
4489    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4490    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4491}
4492
4493impl RepositoryTarget {
4494    fn parse(value: &str) -> Result<Self, SourceError> {
4495        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
4496            message: format!(
4497                "repository must be spelled owner/name; {value:?} names no repository"
4498            ),
4499        })?;
4500        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
4501            return Err(SourceError::Config {
4502                message: format!(
4503                    "repository must be spelled owner/name with a GitHub login and one \
4504                     repository name; {value:?} is not"
4505                ),
4506            });
4507        }
4508        Ok(Self {
4509            owner: owner.to_owned(),
4510            name: name.to_owned(),
4511        })
4512    }
4513
4514    /// The one host whose repositories this source creates issues in, spelled once: it is
4515    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
4516    const HOST: &str = "github.com";
4517
4518    fn origin(&self) -> String {
4519        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
4520    }
4521
4522    /// The repository a normalized origin names, or why it is none this source can create
4523    /// an issue in: another host, or more or fewer than `owner/name` under this one.
4524    fn from_origin(origin: &Repository) -> Result<Self, String> {
4525        let not_here = || {
4526            format!(
4527                "{} is not a {}/owner/name repository",
4528                origin.as_str(),
4529                Self::HOST
4530            )
4531        };
4532        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
4533        if host != Self::HOST {
4534            return Err(not_here());
4535        }
4536        Self::parse(rest).map_err(|_| not_here())
4537    }
4538
4539    fn slug(&self) -> String {
4540        format!("{}/{}", self.owner, self.name)
4541    }
4542}
4543
4544/// Read back from a kept context as the `owner/name` it was written as, refused unless
4545/// [`RepositoryTarget::parse`] takes it.
4546impl TryFrom<String> for RepositoryTarget {
4547    type Error = String;
4548
4549    fn try_from(slug: String) -> Result<Self, Self::Error> {
4550        Self::parse(&slug).map_err(|error| error.to_string())
4551    }
4552}
4553
4554impl From<RepositoryTarget> for String {
4555    fn from(target: RepositoryTarget) -> Self {
4556        target.slug()
4557    }
4558}
4559
4560/// How long GitHub's issue search may go on missing a comment after it was made or edited,
4561/// which a read of the comments made since an instant sets its watermark back by: four times the
4562/// thirty seconds the credentialed lane has watched it lag.
4563pub const COMMENT_SEARCH_LAG: std::time::Duration = std::time::Duration::from_secs(120);
4564
4565/// A source which reads GitHub afresh for every operation.
4566pub struct GitHubProjectsSource {
4567    /// This source's configured name, used both to tell a far end naming this source
4568    /// from one naming a system it knows nothing about, and to name the instance a
4569    /// status refusal is about.
4570    name: SourceName,
4571    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
4572    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
4573    repository: Option<RepositoryTarget>,
4574    /// The logins whose repositories a create may file in; empty restricts nothing.
4575    creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after each entry passes GitHub's login grammar.
4576    endpoint: Url,
4577    token: SecretString,
4578    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
4579    statuses: BoardStatuses,
4580    /// Where each priority lands on this board, or `None` when this instance holds none.
4581    priorities: Option<PriorityMapping>,
4582    /// The metadata values this instance projects onto board text fields, in configured order.
4583    metadata_fields: Vec<MetadataField>,
4584    /// What this board's sub-issues mean.
4585    sub_issues: SubIssues,
4586    /// The link types this source mirrors as `relates-to`.
4587    link_types: onetaskgraph_plugin_api::LinkTypes,
4588    /// Whether this source's configuration opted in to assignees: every document it sends is
4589    /// then spelled by [`graphql::with_assignees`], and every task it reads carries them.
4590    assignees: bool,
4591    /// The GitHub node id of each person an assignee write of this process has named, by
4592    /// lowercased login — `None` for a login GitHub said it does not know — so a later call
4593    /// naming one sends no lookup. A write that fails, or is answered with other people, has the
4594    /// people it named forgotten when its call ends ([`Self::forgotten`]), so the next call
4595    /// naming them asks GitHub again while the rest of its own call does not.
4596    people: Mutex<BTreeMap<String, Option<String>>>,
4597    /// The node ids of the people a failed or mismatched assignee write of this call named,
4598    /// forgotten from [`Self::people`] when the call ends.
4599    forgotten: Mutex<Vec<String>>,
4600    /// GitHub's own spelling of each login this process looked up, by lowercased login: what a
4601    /// write that assigned a person reports them as, however the caller spelled them.
4602    spellings: Mutex<BTreeMap<String, String>>,
4603    /// Built on the first request rather than with the source, as [`Self::client`] says.
4604    client: std::sync::OnceLock<Client>,
4605    /// Built on the first attachment request, as [`Self::asset_client`] says.
4606    asset_client: std::sync::OnceLock<Client>,
4607    /// The markup the engine handed this source for its bodies' headings, applied to every
4608    /// visible body it composes and stripped from every one it reads back: see
4609    /// [`TaskSource::attach_body_markup`].
4610    body_markup: std::sync::OnceLock<onetaskgraph_plugin_api::SharedBodyMarkup>,
4611    /// Every item this source has created in this command, in the order it created them —
4612    /// dropped by [`TaskSource::end_command`].
4613    ///
4614    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
4615    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
4616    /// a copy resolving a dependency on an item it had just created refused it as not
4617    /// found. A board read is completed from this — an item remembered here and absent from
4618    /// the read is added back, because the board really does hold it and only the read is
4619    /// behind.
4620    ///
4621    /// It is not a cache of a user's work: nothing is remembered that this process did not
4622    /// itself just write, it lives and dies with the process, and it is never consulted for
4623    /// an item this source did not create.
4624    created: Mutex<Vec<Resolved>>,
4625    /// Every item that already existed and that this source has written in this command, as
4626    /// it wrote it — dropped by [`TaskSource::end_command`].
4627    ///
4628    /// The other half of [`Self::created`], held on the same terms and for the reason a
4629    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
4630    /// filter is an index behind a write this process made moments ago, so a query matching
4631    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
4632    /// is remembered that this process did not itself just write.
4633    updated: Mutex<Vec<Resolved>>,
4634    /// Every issue this source has added a comment to or edited a comment of in this command
4635    /// — dropped by [`TaskSource::end_command`].
4636    ///
4637    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
4638    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
4639    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
4640    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
4641    /// from before — until the index caught up. Each id here is a candidate of every such
4642    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
4643    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
4644    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
4645    /// process wrote is still found only once the index has it.
4646    commented: Mutex<Vec<NativeId>>,
4647    /// How fast this source writes, and how long it waits out a refusal.
4648    pacing: Pacing,
4649    /// When the last content-creating mutation finished, or the moment the furthest-out
4650    /// reserved slot releases the next one, whichever is later — so the one after it can be
4651    /// spaced from that, when `pacing.min_mutation_interval_ms` asks for a spacing. See
4652    /// [`GitHubProjectsSource::paced`] for why completion rather than release is what it is
4653    /// measured from.
4654    last_mutation: Mutex<Option<Duration>>,
4655    /// The `Date` GitHub stated on the last answer this source read: its own time, which the
4656    /// watermark of a comment read is set back from. See [`TaskSource::served_at`].
4657    last_served: Mutex<Option<DateTime<Utc>>>,
4658    /// GitHub's secondary windows as this source has charged them; see the `pacing` module.
4659    windows: Mutex<pacing::Windows>,
4660    /// The tasks a project holds, as a copy's batched read of the project and every one of them
4661    /// found them — see [`TaskSource::read_ahead_for_copy`] — for the length of one command, so
4662    /// a listing of that project's tasks is answered without a read of its own.
4663    task_children: Mutex<BTreeMap<NativeId, Vec<Resolved>>>,
4664    /// The web address of each issue this source created in the current command, as its
4665    /// `createIssue` answered it — what [`TaskSource::written_location`] answers, without a
4666    /// request.
4667    written: Mutex<BTreeMap<NativeId, String>>,
4668    /// What a copy has held back of the items it created, between
4669    /// [`TaskSource::begin_deferred_writes`] and [`TaskSource::finish_writes`] — `None` while
4670    /// nothing is being held back, when every write lands whole as it is made.
4671    deferred: Mutex<Option<Deferred>>,
4672    /// Each existing issue's update held back while writes are, in the order it was made: sent
4673    /// together by [`Self::send_held_updates`] before any other request goes out, so nothing
4674    /// this source sends or reads ever sees the board without them.
4675    held_updates: Mutex<Vec<HeldUpdate>>,
4676    /// The first page of comments a comment-activity read read of each candidate it confirmed,
4677    /// held for the one comment listing of that task that follows in this command — see
4678    /// `batch::hold_comment_pages`.
4679    held_comments: Mutex<BTreeMap<NativeId, Page<Comment>>>,
4680    /// The board field writes and issue updates a batch of targeted updates is holding back,
4681    /// while one runs — see `batch::TaskWrites`.
4682    task_writes: Mutex<Option<batch::TaskWrites>>,
4683    /// The change token each task a targeted update wrote in the current command holds after
4684    /// that write, by the task's id with its board item's id — spelled from the write's own
4685    /// last response, or from the read made right after it where no response showed the whole
4686    /// issue: what [`TaskSource::written_task`] answers as its `version`. Any later mutation
4687    /// naming the task or its board item drops its entry before it is sent, so an entry is
4688    /// never one write behind.
4689    /// A response that showed the issue whole but not every part of its token — a draft, or a
4690    /// page of labels or field values that did not end — records `None`, which a read would
4691    /// not improve on.
4692    written_tokens: Mutex<BTreeMap<NativeId, (String, Option<String>)>>,
4693    clock: SharedClock,
4694    /// Whether the configuration declares this source private, as
4695    /// [`TaskSource::declare_visibility`] told it: what decides where a new issue whose one
4696    /// named repository reads public is created — see [`Self::creation_rule`].
4697    declared_private: std::sync::atomic::AtomicBool,
4698    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
4699    /// The board as this process last read it, for the length of one command — dropped by
4700    /// [`TaskSource::end_command`].
4701    ///
4702    /// A copy of a project used to re-read the whole board, paged, before writing each of
4703    /// its items, which is by far the largest part of a copy's request count and none of
4704    /// its work. Nothing else changes this board while a command runs — this source's own
4705    /// writes are the only writer — so one read answers them all.
4706    ///
4707    /// It is not a store of a user's work and it is not the cache the no-persistence
4708    /// invariant forbids: it lives and dies with the process exactly as `created` does,
4709    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
4710    /// an item this command created and then depends on resolves whether or not GitHub's
4711    /// own eventually-consistent read has caught up. A write to an item already on the
4712    /// board updates the entry here too, so what this holds is the last read plus this
4713    /// process's own writes rather than a snapshot taken before them.
4714    board_cache: Mutex<Option<Board>>,
4715    /// Every issue this board's own search reported, for the length of one command — dropped
4716    /// by [`TaskSource::end_command`].
4717    ///
4718    /// The second half of a board read, and cached for the same reason and on the same
4719    /// terms as the first: it lives and dies with the process, nothing is written down, and
4720    /// a write this process makes updates the entry here exactly as it updates the one in
4721    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
4722    /// that lists this board's projects and its tasks pays for one search rather than two.
4723    search_cache: Mutex<Option<Vec<Resolved>>>,
4724    /// What each narrowed question GitHub was asked answered, keyed by that question, for
4725    /// the length of one command — dropped by [`TaskSource::end_command`].
4726    ///
4727    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
4728    /// and dies with the process, nothing is written down, a write this process makes
4729    /// updates the entry here as it updates the other two, and every answer is completed
4730    /// with this process's own writes each time it is given. A command that asks the same
4731    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
4732    /// write — pays for it once, which is what the whole-board read it replaced gave it.
4733    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
4734    search_next: Mutex<BTreeMap<String, Option<String>>>,
4735    /// Records already resolved in this command, reused by writes and for comment identity.
4736    /// Explicit item reads still reach GitHub. Nothing is persisted, and
4737    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
4738    /// its item as a person has since left it.
4739    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4740    /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
4741    /// answered as holding nothing — each taken by the next read of that item by id and by
4742    /// nothing else, dropped by any mutation naming it, and cleared by
4743    /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
4744    /// read-before-write, made in the same request as the others' rather than on its own.
4745    read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
4746    /// The origins [`TaskSource::read_ahead_origins_for_copy`] was asked about and has not sent
4747    /// yet: held for the [`TaskSource::read_ahead_for_copy`] that follows it, which sends them —
4748    /// beside the members of the one project it names, when it names one — or for the next
4749    /// request this source makes, whichever comes first.
4750    pending_origins: Mutex<Vec<String>>,
4751    /// Each project's members as a lookup carrying them read them in this command, trimmed to
4752    /// their classification and repositories, taken by [`TaskSource::member_classifications`]
4753    /// of that project and by nothing else, and cleared by [`TaskSource::end_command`].
4754    member_classes: Mutex<BTreeMap<NativeId, Vec<MemberClass>>>,
4755    /// Every item a listing of one project's members carried whole in this command — the
4756    /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
4757    /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
4758    /// it again. No other read is answered from it, so a read by id outside a copy's
4759    /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
4760    /// item, and cleared by [`TaskSource::end_command`].
4761    carried: Mutex<BTreeMap<NativeId, Resolved>>,
4762    /// Items an issue read by reference reached, held for the one status write each was read
4763    /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
4764    /// what such a write needs. Dropped by [`TaskSource::end_command`].
4765    held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4766    /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
4767    /// asked for under, with the project that selector named — for the length of one command,
4768    /// dropped by [`TaskSource::end_command`].
4769    ///
4770    /// A caller pages through a project's tasks one engine page at a time, and every page
4771    /// is cut from the whole list of them, so without this each page walked every
4772    /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
4773    /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
4774    /// dies with the process, nothing is written down, a write this process makes updates or
4775    /// removes the entry here as it does there, and every answer is completed with this
4776    /// process's own writes each time it is given.
4777    children_cache: Mutex<ProjectChildren>,
4778    /// The board's own id and field definitions as this process last read them on their
4779    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
4780    ///
4781    /// What a write needs of the board and its item does not say, read once per command
4782    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
4783    /// lives and dies with the process and nothing is written down. It holds no item and so
4784    /// can answer no question about one — see [`Self::board_fields`].
4785    fields_cache: Mutex<Option<BoardFields>>,
4786    /// Each destination repository's node id, resolved once per repository
4787    /// rather than per issue created.
4788    ///
4789    /// A repository's node id does not change, and re-reading it for every issue of a copy
4790    /// spent one request per item on an answer this source already had. It is a map rather
4791    /// than one entry because a copy files each item in the repository its own
4792    /// `repositories` field names, so a plan across five repositories asks GitHub five
4793    /// times and not once per item.
4794    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
4795    /// What every request this source sends is recorded into.
4796    ///
4797    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
4798    /// a request leaves this crate, so nothing has to be switched on for a session to be
4799    /// counted. It is shared rather than owned so a caller accounting for a whole session —
4800    /// its own schema verification, board lookups, residue sweep and cleanup beside this
4801    /// source's reads and writes — adds up one accounting instead of two. See
4802    /// [`accounting`] for what a record carries and what a session's spend is and is not.
4803    ledger: Arc<Accounting>,
4804    /// What [`expected`] holds for a write the store's cache expects: the journal while one is
4805    /// open, the issues primed from the cache, and the comments known on each.
4806    expected: expected::Held,
4807    /// What [`projection`] holds for a producer's projection: a kept placement awaiting its
4808    /// setup-context token, and where the last create was placed.
4809    projection: projection::Held,
4810}
4811
4812/// The board item a single field update's answer names, refused unless it is `item_id`.
4813fn field_update_item<'a>(data: &'a Value, item_id: &str) -> Result<&'a Value, SourceError> {
4814    let returned = data
4815        .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
4816        .ok_or_else(|| SourceError::Malformed {
4817            message: "GitHub field update returned no project item".into(),
4818        })?;
4819    if required_str(returned, "id")? != item_id {
4820        return Err(SourceError::Malformed {
4821            message: "GitHub field update returned the wrong project item".into(),
4822        });
4823    }
4824    Ok(returned)
4825}
4826
4827/// GitHub's closed single-select color vocabulary.
4828#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
4829#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
4830pub enum StatusOptionColor {
4831    /// Gray.
4832    Gray,
4833    /// Blue.
4834    Blue,
4835    /// Green.
4836    Green,
4837    /// Yellow.
4838    Yellow,
4839    /// Purple.
4840    Purple,
4841    /// Red.
4842    Red,
4843    /// Orange.
4844    Orange,
4845    /// Pink.
4846    Pink,
4847}
4848
4849/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
4850/// applies its additions.
4851#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4852pub enum SetupMode {
4853    /// Read without mutation.
4854    Plan,
4855    /// Apply and verify.
4856    Apply,
4857}
4858
4859/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
4860/// against it goes on compiling.
4861pub type StatusOptionsMode = SetupMode;
4862
4863/// The explicit result of the requested operation.
4864#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4865#[serde(rename_all = "kebab-case")]
4866pub enum StatusOptionsOutcome {
4867    /// A read-only plan.
4868    Planned,
4869    /// Apply found nothing missing.
4870    Unchanged,
4871    /// Additions were applied and verified.
4872    Applied,
4873}
4874
4875/// A GitHub single-select option's opaque GraphQL node identifier.
4876#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4877#[serde(transparent)]
4878pub struct StatusOptionId(#[schemars(length(min = 1))] String);
4879
4880impl TryFrom<String> for StatusOptionId {
4881    type Error = String;
4882
4883    fn try_from(id: String) -> Result<Self, Self::Error> {
4884        if id.trim().is_empty() {
4885            return Err("a GitHub Status option id cannot be blank".to_owned());
4886        }
4887        Ok(Self(id))
4888    }
4889}
4890
4891/// One existing or proposed option in a guarded Status-field update.
4892#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4893pub struct StatusOption {
4894    /// GitHub's stable id.
4895    pub id: StatusOptionId,
4896    /// The visible option name.
4897    pub name: ColumnName,
4898    /// GitHub's single-select color token.
4899    pub color: StatusOptionColor,
4900    /// The option description, including an empty one.
4901    pub description: String,
4902}
4903
4904/// One board item's Status assignment, retained as recovery data.
4905#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4906pub struct StatusAssignment {
4907    /// The project item id whose assignment this is.
4908    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
4909    // carried verbatim as operator recovery data; introducing a semantic type would claim
4910    // validation rules GitHub does not publish and no operation here interprets.
4911    pub item_id: String,
4912    /// The selected option, absent when the item has no status.
4913    #[serde(skip_serializing_if = "Option::is_none")]
4914    pub option: Option<AssignedStatusOption>,
4915}
4916
4917/// The inseparable id and name of an assigned option.
4918#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4919pub struct AssignedStatusOption {
4920    /// GitHub's stable id.
4921    pub id: StatusOptionId,
4922    /// The visible name.
4923    pub name: ColumnName,
4924}
4925
4926/// The plan and verified outcome of reconciling configured Status options.
4927#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4928pub struct StatusOptionsReport {
4929    /// The configured source name.
4930    pub source: SourceName,
4931    /// Configured option names absent before the operation.
4932    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
4933    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
4934    // serialized string here preserves the report's intentionally simple public contract.
4935    pub missing: Vec<String>,
4936    /// What the requested operation did.
4937    pub outcome: StatusOptionsOutcome,
4938    /// The complete option list observed before any mutation.
4939    pub existing: Vec<StatusOption>,
4940}
4941
4942#[derive(Debug, Clone, PartialEq, Eq)]
4943struct StatusSnapshot {
4944    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4945    // passed back as the mutation's project identity; a newtype could enforce no stronger
4946    // invariant because GitHub publishes no grammar for it.
4947    board_id: String,
4948    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4949    // passed back as the mutation's field identity; a newtype could enforce no stronger
4950    // invariant because GitHub publishes no grammar for it.
4951    field_id: String,
4952    options: Vec<StatusOption>,
4953    assignments: Vec<StatusAssignment>,
4954}
4955
4956/// The name of the board field a status is held in.
4957const STATUS_FIELD: &str = "Status";
4958
4959/// Every item's value of each field `report` names, as it stood before the setup wrote
4960/// anything — what a person puts back when the setup is refused part way.
4961fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
4962    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
4963        .fields
4964        .iter()
4965        .map(|field| (field.field.name(), before.assignments(field.field)))
4966        .collect();
4967    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
4968        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
4969    })
4970}
4971
4972/// One board field the guarded setup reads and writes — every one it reads, and the only
4973/// ones it writes.
4974#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
4975pub enum BoardField {
4976    /// The single-select `Status` field every instance's `status_mapping` resolves into.
4977    Status,
4978    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
4979    Priority,
4980}
4981
4982impl BoardField {
4983    /// The field's name on the board.
4984    #[must_use]
4985    pub const fn name(self) -> &'static str {
4986        match self {
4987            Self::Status => STATUS_FIELD,
4988            Self::Priority => PRIORITY_FIELD,
4989        }
4990    }
4991
4992    /// The field a board calls `name`, or `None` for one this setup does not own.
4993    fn named(name: &str) -> Option<Self> {
4994        [Self::Status, Self::Priority]
4995            .into_iter()
4996            .find(|field| field.name() == name)
4997    }
4998}
4999
5000/// What the guarded setup did to one field.
5001#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5002#[serde(rename_all = "kebab-case")]
5003pub enum FieldOutcome {
5004    /// A read-only plan.
5005    Planned,
5006    /// Apply found the field there with every configured option.
5007    Unchanged,
5008    /// Missing options were added to the field that was there, and verified.
5009    Applied,
5010    /// The field was not there; it was created holding the configured options, and verified.
5011    Created,
5012}
5013
5014/// One field's plan, or its verified outcome.
5015#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5016pub struct FieldReport {
5017    /// Which field.
5018    pub field: BoardField,
5019    /// Whether the board had the field before the operation.
5020    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
5021    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
5022    // "outcome", "existing"}` — so folding one into the other would change a published JSON
5023    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
5024    // one constructor, and it derives `outcome` from `exists` in one match.
5025    pub exists: bool,
5026    /// Configured option names the field lacked before the operation — every one of them,
5027    /// in the order a new field lists them, when the field was not there at all.
5028    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
5029    // mapping name and has therefore already passed its nonblank validation; the serialized
5030    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
5031    pub missing: Vec<String>,
5032    /// For the `Status` field, which item kind each missing name is configured for: one
5033    /// entry per kind `status_mapping` names a missing option for, task before project, each
5034    /// listing that kind's missing names in category order. A name both kinds use is in
5035    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
5036    /// `Priority`, which only a task holds.
5037    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5038    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
5039    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
5040    #[schemars(!skip_serializing_if)]
5041    pub kinds: Vec<KindMissing>,
5042    /// What the requested operation did.
5043    pub outcome: FieldOutcome,
5044    /// The field's complete option list observed before any mutation; empty when the field
5045    /// was not there.
5046    pub existing: Vec<StatusOption>,
5047}
5048
5049/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
5050#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5051pub struct KindMissing {
5052    /// The kind these names are configured for.
5053    pub kind: ItemKind,
5054    /// The names that kind maps a category to and the field lacked, in category order.
5055    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
5056    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
5057    // intentionally simple public contract.
5058    pub missing: Vec<String>,
5059}
5060
5061/// The plan and verified outcome of setting up every field a source's configuration names.
5062#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5063pub struct FieldsReport {
5064    /// The configured source name.
5065    pub source: SourceName,
5066    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
5067    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
5068    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
5069    // per field would change a published JSON shape. The states the list could hold and the
5070    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
5071    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
5072    pub fields: Vec<FieldReport>,
5073    /// Each board text field the source's `metadata_fields` projects a value onto, in
5074    /// configured order. Empty — and left out of the JSON — when it projects none.
5075    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5076    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
5077    // both SDKs model an absent list as an empty one rather than as `null`.
5078    #[schemars(!skip_serializing_if)]
5079    pub metadata_fields: Vec<MetadataFieldReport>,
5080}
5081
5082/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
5083/// its GraphQL type when it has none — which is what a projected field's conflict reports.
5084///
5085/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
5086/// no conflict.
5087// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
5088// refuses exactly what `try_from` below does.
5089#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5090#[serde(transparent)]
5091pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
5092
5093/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
5094/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
5095/// neither SDK's validator can be relied on to support.
5096const NON_TEXT_FIELD_TYPE_PATTERN: &str =
5097    r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
5098
5099impl TryFrom<String> for NonTextFieldType {
5100    type Error = String;
5101
5102    fn try_from(kind: String) -> Result<Self, Self::Error> {
5103        if kind.is_empty() || kind.starts_with(char::is_whitespace) {
5104            return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
5105        }
5106        if kind == "TEXT" {
5107            return Err("a TEXT field is no conflict for a projected text field".to_owned());
5108        }
5109        Ok(Self(kind))
5110    }
5111}
5112
5113impl std::fmt::Display for NonTextFieldType {
5114    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5115        formatter.write_str(&self.0)
5116    }
5117}
5118
5119/// One projected metadata text field's plan, or its verified outcome.
5120///
5121/// The setup creates a missing one as a text field and never alters a field that is there:
5122/// a same-named field of another type is reported in `conflict` and left as it is.
5123#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
5124pub struct MetadataFieldReport {
5125    /// The board field's name.
5126    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.
5127    /// The metadata key the value is read from.
5128    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
5129    /// The object keys walked inside that key's value; empty for the key's own value.
5130    pub path: Vec<String>,
5131    /// Whether the board had a text field of that name before the operation.
5132    // 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.
5133    pub exists: bool,
5134    /// The type of a field of that name that is not a text field, which the setup leaves as
5135    /// it is; absent — and left out of the JSON — when there is none.
5136    #[serde(default, skip_serializing_if = "Option::is_none")]
5137    pub conflict: Option<NonTextFieldType>,
5138    /// What the requested operation did: `planned`; `created` for a field that was not
5139    /// there; `unchanged` for one that was, and for a conflict, which is never changed.
5140    pub outcome: FieldOutcome,
5141}
5142
5143/// Which options one field is configured with, in the order a new field would list them.
5144struct FieldPlan {
5145    field: BoardField,
5146    wanted: Vec<String>,
5147}
5148
5149/// One single-select field as the guarded setup snapshots it.
5150#[derive(Debug, Clone, PartialEq, Eq)]
5151struct SnapshotField {
5152    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
5153    // passed back as the mutation's field identity; a newtype could enforce no stronger
5154    // invariant because GitHub publishes no grammar for it.
5155    field_id: String,
5156    options: Vec<StatusOption>,
5157}
5158
5159/// Every single-select field of a board and every item's value of each.
5160#[derive(Debug, Clone, PartialEq, Eq)]
5161struct BoardSnapshot {
5162    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
5163    // passed back as the mutation's project identity; a newtype could enforce no stronger
5164    // invariant because GitHub publishes no grammar for it.
5165    board_id: String,
5166    fields: BTreeMap<BoardField, SnapshotField>,
5167    /// Each board item's id, and its value of each field this setup owns that it holds one of.
5168    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
5169}
5170
5171impl BoardSnapshot {
5172    /// Every item's value of `field`, in board order — the recovery data a drift refusal
5173    /// carries.
5174    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
5175        self.items
5176            .iter()
5177            .map(|(item_id, values)| StatusAssignment {
5178                item_id: item_id.clone(),
5179                option: values.get(&field).cloned(),
5180            })
5181            .collect()
5182    }
5183}
5184
5185impl GitHubProjectsSource {
5186    /// `content` as this source sends it, with the markup the engine handed it applied.
5187    fn marked(&self, content: &str) -> String {
5188        match self.body_markup.get() {
5189            Some(markup) if !content.is_empty() => markup.written(content),
5190            _ => content.to_owned(),
5191        }
5192    }
5193
5194    /// The visible body and the metadata slot of `body` as [`metadata_body`] splits them,
5195    /// the visible body without the markup [`Self::marked`] applies.
5196    fn visible(
5197        &self,
5198        body: Option<String>,
5199    ) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
5200        let (visible, slot) = metadata_body(body)?;
5201        Ok((
5202            match (visible, self.body_markup.get()) {
5203                (Some(visible), Some(markup)) => {
5204                    Some(markup.read(&visible)).filter(|read| !read.is_empty())
5205                }
5206                (visible, _) => visible,
5207            },
5208            slot,
5209        ))
5210    }
5211
5212    /// Report missing configured Status options and, when `apply` is true, add them with
5213    /// a whole-list mutation that preserves every existing id and verifies the result.
5214    ///
5215    /// # Errors
5216    ///
5217    /// Refuses a board without a single-select `Status` field. A post-write difference in
5218    /// any pre-existing option id or item assignment is refused with the complete pre-write
5219    /// assignment snapshot in the diagnostic for recovery.
5220    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
5221    // successful mutation, both drift refusals, source selection, missing Status, casing,
5222    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
5223    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
5224    // responses from entering the defensive malformed-response branches below.
5225    pub async fn status_options(
5226        &self,
5227        mode: StatusOptionsMode,
5228    ) -> Result<StatusOptionsReport, SourceError> {
5229        let before = self.status_snapshot().await?;
5230        // A terminal category's option is as configured as an open one's: a terminal
5231        // write validates it before closing and refuses when the board lacks it. Both
5232        // kinds' names are options of the one field, so both are asked for.
5233        let missing = self
5234            .statuses
5235            .wanted()
5236            .into_iter()
5237            .filter(|wanted| {
5238                !before
5239                    .options
5240                    .iter()
5241                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5242            })
5243            .collect::<Vec<_>>();
5244        let report = StatusOptionsReport {
5245            source: self.name.clone(),
5246            missing: missing.clone(),
5247            outcome: match (mode, missing.is_empty()) {
5248                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
5249                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
5250                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
5251            },
5252            existing: before.options.clone(),
5253        };
5254        if mode == StatusOptionsMode::Plan || missing.is_empty() {
5255            return Ok(report);
5256        }
5257        let mut options = before
5258            .options
5259            .iter()
5260            .map(|option| {
5261                json!({
5262                    "id": option.id, "name": option.name, "color": option.color,
5263                    "description": option.description,
5264                })
5265            })
5266            .collect::<Vec<_>>();
5267        options.extend(missing.iter().map(|name| {
5268            json!({
5269                "name": name, "color": "GRAY", "description": ""
5270            })
5271        }));
5272        self.graphql(
5273            graphql::STATUS_OPTIONS_UPDATE,
5274            json!({"input": {
5275                "fieldId": before.field_id,
5276                "singleSelectOptions": options,
5277            }}),
5278        )
5279        .await?;
5280        let after = self.status_snapshot().await?;
5281        let options_preserved = before
5282            .options
5283            .iter()
5284            .all(|old| after.options.iter().any(|new| new == old));
5285        let additions_present = missing.iter().all(|wanted| {
5286            after
5287                .options
5288                .iter()
5289                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5290        });
5291        if !options_preserved || !additions_present || after.assignments != before.assignments {
5292            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
5293                SourceError::Malformed {
5294                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
5295                }
5296            })?;
5297            return Err(SourceError::Refused {
5298                message: format!(
5299                    "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}"
5300                ),
5301            });
5302        }
5303        Ok(report)
5304    }
5305
5306    /// A fresh snapshot of the Status field and every board item's assignment of it.
5307    ///
5308    /// # Errors
5309    ///
5310    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
5311    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
5312        // Status alone, as this operation has always read it: a `Priority` field is another
5313        // operation's, so nothing about it can refuse this one.
5314        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
5315        let field = board
5316            .fields
5317            .remove(&BoardField::Status)
5318            .ok_or_else(|| self.no_status_field())?;
5319        Ok(StatusSnapshot {
5320            assignments: board.assignments(BoardField::Status),
5321            board_id: board.board_id,
5322            field_id: field.field_id,
5323            options: field.options,
5324        })
5325    }
5326
5327    /// The refusal a board with no `Status` field is answered with by the guarded setup.
5328    fn no_status_field(&self) -> SourceError {
5329        SourceError::Refused {
5330            message: format!("source {} board has no Status field", self.name),
5331        }
5332    }
5333
5334    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
5335    // the real CLI loopback journey, including pagination. The individual malformed guards
5336    // are defensive validation of a schema-pinned third-party response, not separate user
5337    // journeys; drift and missing-field failures cover the operation's recovery behavior.
5338    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
5339    /// every board item's value of each, walked to the end of the board's items. A field not
5340    /// in `owned` is read past whatever it holds.
5341    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
5342        let mut after: Option<String> = None;
5343        let mut snapshot: Option<BoardSnapshot> = None;
5344        loop {
5345            let data = self
5346                .graphql(
5347                    graphql::STATUS_OPTIONS_SNAPSHOT,
5348                    json!({
5349                        "owner": self.owner, "number": self.project_number,
5350                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
5351                    }),
5352                )
5353                .await?;
5354            let board = data
5355                .pointer("/owner/projectV2")
5356                .filter(|board| board.is_object())
5357                .ok_or_else(|| SourceError::Refused {
5358                    message: format!(
5359                        "source {} has no accessible GitHub Projects board",
5360                        self.name
5361                    ),
5362                })?;
5363            if board
5364                .pointer("/fields/pageInfo/hasNextPage")
5365                .and_then(Value::as_bool)
5366                != Some(false)
5367            {
5368                return Err(SourceError::Malformed {
5369                    message:
5370                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
5371                            .into(),
5372                });
5373            }
5374            let mut fields = BTreeMap::new();
5375            // Only the fields this setup owns, by name: a node the single-select fragment did not
5376            // match carries no name, and a person's own single-select field — a `Size`, a
5377            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
5378            // `Status` or `Priority` field without its options is malformed, not absent.
5379            // 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.
5380            for (owned, field) in board
5381                .pointer("/fields/nodes")
5382                .and_then(Value::as_array)
5383                .ok_or_else(|| SourceError::Malformed {
5384                    message: "GitHub project fields.nodes is not an array".into(),
5385                })?
5386                .iter()
5387                .filter_map(|field| {
5388                    let named = BoardField::named(field.get("name")?.as_str()?)?;
5389                    owned.contains(&named).then_some((named, field))
5390                })
5391            {
5392                let options = field
5393                    .get("options")
5394                    .and_then(Value::as_array)
5395                    .ok_or_else(|| SourceError::Malformed {
5396                        message: "GitHub single-select field options is not an array".into(),
5397                    })?
5398                    .iter()
5399                    .map(|option| {
5400                        Ok(StatusOption {
5401                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
5402                                .map_err(|message| SourceError::Malformed { message })?,
5403                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
5404                                .map_err(|message| SourceError::Malformed {
5405                                    message: format!(
5406                                        "GitHub single-select option name is invalid: {message}"
5407                                    ),
5408                                })?,
5409                            color: serde_json::from_value(
5410                                option.get("color").cloned().unwrap_or(Value::Null),
5411                            )
5412                            .map_err(|error| {
5413                                SourceError::Malformed {
5414                                    message: format!(
5415                                        "GitHub single-select option color is invalid: {error}"
5416                                    ),
5417                                }
5418                            })?,
5419                            description: optional_str(option, "description")?
5420                                .unwrap_or_default()
5421                                .to_owned(),
5422                        })
5423                    })
5424                    .collect::<Result<Vec<_>, SourceError>>()?;
5425                let snapshot = SnapshotField {
5426                    field_id: required_nonblank_str(field, "id")?.to_owned(),
5427                    options,
5428                };
5429                // A board's field names are unique, so a second one is an answer that cannot
5430                // say which field the setup would act on — refused rather than one chosen.
5431                if fields.insert(owned, snapshot).is_some() {
5432                    return Err(SourceError::Malformed {
5433                        message: format!(
5434                            "GitHub answered two {} fields for this board",
5435                            owned.name()
5436                        ),
5437                    });
5438                }
5439            }
5440            let board_id = required_nonblank_str(board, "id")?.to_owned();
5441            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
5442                board_id,
5443                fields,
5444                items: Vec::new(),
5445            });
5446            let items = board
5447                .pointer("/items/nodes")
5448                .and_then(Value::as_array)
5449                .ok_or_else(|| SourceError::Malformed {
5450                    message: "GitHub project items.nodes is not an array".into(),
5451                })?;
5452            for item in items {
5453                let field_values =
5454                    item.get("fieldValues")
5455                        .ok_or_else(|| SourceError::Malformed {
5456                            message: "GitHub project item is missing fieldValues".into(),
5457                        })?;
5458                if field_values
5459                    .pointer("/pageInfo/hasNextPage")
5460                    .and_then(Value::as_bool)
5461                    != Some(false)
5462                {
5463                    return Err(SourceError::Malformed {
5464                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
5465                    });
5466                }
5467                let values = item
5468                    .pointer("/fieldValues/nodes")
5469                    .and_then(Value::as_array)
5470                    .ok_or_else(|| SourceError::Malformed {
5471                        message: "GitHub project item fieldValues.nodes is not an array".into(),
5472                    })?;
5473                let item_id = required_nonblank_str(item, "id")?;
5474                let mut assigned = BTreeMap::new();
5475                for value in values {
5476                    let Some(field) = value
5477                        .pointer("/field/name")
5478                        .and_then(Value::as_str)
5479                        .and_then(BoardField::named)
5480                        .filter(|field| owned.contains(field))
5481                    else {
5482                        continue;
5483                    };
5484                    let held = assigned.insert(
5485                        field,
5486                        AssignedStatusOption {
5487                            id: StatusOptionId::try_from(
5488                                required_str(value, "optionId")?.to_owned(),
5489                            )
5490                            .map_err(|message| SourceError::Malformed { message })?,
5491                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
5492                                .map_err(|message| SourceError::Malformed {
5493                                    message: format!(
5494                                        "GitHub assigned {} name is invalid: {message}",
5495                                        field.name()
5496                                    ),
5497                                })?,
5498                        },
5499                    );
5500                    // An item holds one value of a field, so a second one leaves no way to
5501                    // tell which it holds — and a verification or recovery built on either
5502                    // could restore the wrong one.
5503                    if held.is_some() {
5504                        return Err(SourceError::Malformed {
5505                            message: format!(
5506                                "GitHub answered two {} values for board item {item_id}",
5507                                field.name()
5508                            ),
5509                        });
5510                    }
5511                }
5512                current.items.push((item_id.to_owned(), assigned));
5513            }
5514            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
5515                message: "GitHub project is missing items".into(),
5516            })?;
5517            let has_next = page
5518                .pointer("/pageInfo/hasNextPage")
5519                .and_then(Value::as_bool)
5520                .ok_or_else(|| SourceError::Malformed {
5521                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
5522                })?;
5523            if !has_next {
5524                break;
5525            }
5526            let next =
5527                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
5528            validate_cursor_progress(after.as_deref(), next)?;
5529            after = Some(next.to_owned());
5530        }
5531        snapshot.ok_or_else(|| SourceError::Malformed {
5532            message: "GitHub returned no board field snapshot".into(),
5533        })
5534    }
5535    // llmlint: ignore-end[changed_behavior_has_e2e]
5536
5537    /// Report every board field this source's configuration names and, with
5538    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
5539    /// the `Priority` field when the board has none.
5540    ///
5541    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
5542    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
5543    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
5544    /// color and description: the whole option list goes back with every existing id, because
5545    /// a re-minted id clears every item's value.
5546    ///
5547    /// # Errors
5548    ///
5549    /// Refuses a board without a single-select `Status` field. After an apply the board is
5550    /// read again, and a pre-existing option or any item's value of either field that moved is
5551    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
5552    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
5553    // unchanged apply, a created field, an added option to each field, drift refusal, a board
5554    // with no Status field and a non-github-projects source through the compiled CLI against
5555    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
5556    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
5557        let owned: Vec<BoardField> = if self.priorities.is_some() {
5558            vec![BoardField::Status, BoardField::Priority]
5559        } else {
5560            vec![BoardField::Status]
5561        };
5562        let before = self.board_snapshot(&owned).await?;
5563        let mut plans = vec![FieldPlan {
5564            field: BoardField::Status,
5565            wanted: self.statuses.wanted(),
5566        }];
5567        if !before.fields.contains_key(&BoardField::Status) {
5568            return Err(self.no_status_field());
5569        }
5570        if let Some(mapping) = &self.priorities {
5571            plans.push(FieldPlan {
5572                field: BoardField::Priority,
5573                wanted: mapping.names().map(str::to_owned).collect(),
5574            });
5575        }
5576        // The snapshot reads single-select fields alone, so a field it did not find may still
5577        // be on the board under the name, of another type: creating one beside it would fail
5578        // part way, or leave two fields of one name. Asked of the board's own field list, and
5579        // only when a field is missing.
5580        if plans
5581            .iter()
5582            .any(|plan| !before.fields.contains_key(&plan.field))
5583        {
5584            let board = self.board_fields().await?;
5585            for plan in plans
5586                .iter()
5587                .filter(|plan| !before.fields.contains_key(&plan.field))
5588            {
5589                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
5590                    return Err(SourceError::Refused {
5591                        message: format!(
5592                            "source {}'s board has a {} field that is not a single-select field \
5593                             (it is a {}), so it cannot hold this source's options; next: rename \
5594                             or remove that field, then run this again",
5595                            self.name,
5596                            plan.field.name(),
5597                            optional_str(field, "__typename")?.unwrap_or("field of another type")
5598                        ),
5599                    });
5600                }
5601            }
5602        }
5603        let mut reports = Vec::new();
5604        for plan in &plans {
5605            let held = before.fields.get(&plan.field);
5606            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
5607            let mut missing: Vec<String> = Vec::new();
5608            for wanted in &plan.wanted {
5609                let present = existing
5610                    .iter()
5611                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5612                    || missing
5613                        .iter()
5614                        .any(|named| named.eq_ignore_ascii_case(wanted));
5615                if !present {
5616                    missing.push(wanted.clone());
5617                }
5618            }
5619            let kinds = match plan.field {
5620                BoardField::Status => self.statuses.missing_by_kind(&existing),
5621                BoardField::Priority => Vec::new(),
5622            };
5623            reports.push(FieldReport {
5624                field: plan.field,
5625                exists: held.is_some(),
5626                kinds,
5627                outcome: match (mode, held.is_some(), missing.is_empty()) {
5628                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
5629                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
5630                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
5631                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
5632                },
5633                missing,
5634                existing,
5635            });
5636        }
5637        // A projected field is a text field, which the single-select snapshot cannot see, so
5638        // it is asked of the board's own field list — and only when one is configured.
5639        let mut metadata_fields = Vec::new();
5640        if !self.metadata_fields.is_empty() {
5641            let board = self.board_fields().await?;
5642            for projection in &self.metadata_fields {
5643                let conflict = match Board::field(&board.fields, &projection.field)? {
5644                    None => None,
5645                    Some(field) => {
5646                        let typename = optional_str(field, "__typename")?;
5647                        let data_type = optional_str(field, "dataType")?;
5648                        if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
5649                            None
5650                        } else {
5651                            let named = data_type
5652                                .filter(|kind| *kind != "TEXT")
5653                                .or(typename)
5654                                .unwrap_or("field of another type");
5655                            Some(NonTextFieldType::try_from(named.to_owned()).map_err(
5656                                |message| SourceError::Malformed {
5657                                    message: format!(
5658                                        "GitHub answered the board's {} field with a type this \
5659                                         setup cannot read: {message}",
5660                                        &*projection.field
5661                                    ),
5662                                },
5663                            )?)
5664                        }
5665                    }
5666                };
5667                let exists =
5668                    conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
5669                metadata_fields.push(MetadataFieldReport {
5670                    field: projection.field.to_string(),
5671                    key: projection.key.to_string(),
5672                    path: projection.path.clone(),
5673                    exists,
5674                    outcome: match (mode, exists || conflict.is_some()) {
5675                        (SetupMode::Plan, _) => FieldOutcome::Planned,
5676                        (SetupMode::Apply, true) => FieldOutcome::Unchanged,
5677                        (SetupMode::Apply, false) => FieldOutcome::Created,
5678                    },
5679                    conflict,
5680                });
5681            }
5682        }
5683        let report = FieldsReport {
5684            source: self.name.clone(),
5685            fields: reports,
5686            metadata_fields,
5687        };
5688        let writes: Vec<&FieldReport> = report
5689            .fields
5690            .iter()
5691            .filter(|field| !field.missing.is_empty() || !field.exists)
5692            .collect();
5693        let creates: Vec<&MetadataFieldReport> = report
5694            .metadata_fields
5695            .iter()
5696            .filter(|field| field.outcome == FieldOutcome::Created)
5697            .collect();
5698        if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
5699            return Ok(report);
5700        }
5701        let mut landed: Vec<&str> = Vec::new();
5702        for field in &writes {
5703            let added = field
5704                .missing
5705                .iter()
5706                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
5707            let sent = match before.fields.get(&field.field) {
5708                Some(held) => {
5709                    let mut options = held
5710                        .options
5711                        .iter()
5712                        .map(|option| {
5713                            json!({
5714                                "id": option.id, "name": option.name, "color": option.color,
5715                                "description": option.description,
5716                            })
5717                        })
5718                        .collect::<Vec<_>>();
5719                    options.extend(added);
5720                    self.graphql(
5721                        graphql::STATUS_OPTIONS_UPDATE,
5722                        json!({"input": {
5723                            "fieldId": held.field_id,
5724                            "singleSelectOptions": options,
5725                        }}),
5726                    )
5727                    .await
5728                }
5729                None => {
5730                    self.graphql(
5731                        graphql::CREATE_FIELD,
5732                        json!({"input": {
5733                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
5734                            "name": field.field.name(),
5735                            "singleSelectOptions": added.collect::<Vec<_>>(),
5736                        }}),
5737                    )
5738                    .await
5739                }
5740            };
5741            // A mutation that failed does not establish that GitHub left its field as it was,
5742            // so every failure from here on carries the recovery data a drift refusal does.
5743            match sent {
5744                Ok(_) => landed.push(field.field.name()),
5745                Err(error) => {
5746                    let changed = if landed.is_empty() {
5747                        String::new()
5748                    } else {
5749                        format!("changed the {} field and then ", landed.join(" and "))
5750                    };
5751                    return Err(SourceError::Refused {
5752                        message: format!(
5753                            "the guarded field setup {changed}failed on the {} field, which it may \
5754                             have changed part way: {error}; the pre-write item assignments \
5755                             are:\n{}",
5756                            field.field.name(),
5757                            recovery(&report, &before)?
5758                        ),
5759                    });
5760                }
5761            }
5762        }
5763        for field in &creates {
5764            let sent = self
5765                .graphql(
5766                    graphql::CREATE_FIELD,
5767                    json!({"input": {
5768                        "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
5769                    }}),
5770                )
5771                .await;
5772            if let Err(error) = sent {
5773                let changed = if landed.is_empty() {
5774                    String::new()
5775                } else {
5776                    format!("changed the {} field and then ", landed.join(" and "))
5777                };
5778                return Err(SourceError::Refused {
5779                    message: format!(
5780                        "the guarded field setup {changed}failed creating the {} text field: \
5781                         {error}; run it again to create what is still missing",
5782                        field.field
5783                    ),
5784                });
5785            }
5786            landed.push(&field.field);
5787        }
5788        // The board has been written, so a verification read that fails leaves it unverified
5789        // rather than unchanged, and says what to put back.
5790        let after = match self.board_snapshot(&owned).await {
5791            Ok(after) => after,
5792            Err(error) => {
5793                return Err(SourceError::Refused {
5794                    message: format!(
5795                        "the guarded field setup changed the {} field and then could not read the \
5796                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
5797                        landed.join(" and "),
5798                        recovery(&report, &before)?
5799                    ),
5800                });
5801            }
5802        };
5803        let mut moved = Vec::new();
5804        for field in &report.fields {
5805            let name = field.field.name();
5806            let now = after
5807                .fields
5808                .get(&field.field)
5809                .map(|held| held.options.as_slice())
5810                .unwrap_or_default();
5811            if !field.existing.iter().all(|old| now.contains(old)) {
5812                moved.push(format!(
5813                    "a pre-existing {name} option id, name, color or description"
5814                ));
5815            }
5816            if !field.missing.iter().all(|wanted| {
5817                now.iter()
5818                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5819            }) {
5820                moved.push(format!("an added {name} option"));
5821            }
5822            if after.assignments(field.field) != before.assignments(field.field) {
5823                moved.push(format!("an item's {name} value"));
5824            }
5825        }
5826        if !creates.is_empty() {
5827            // Read afresh: the view held for this command is the one from before the creates.
5828            *self.fields_cache()? = None;
5829            let board = match self.board_fields().await {
5830                Ok(board) => board,
5831                Err(error) => {
5832                    return Err(SourceError::Refused {
5833                        message: format!(
5834                            "the guarded field setup changed the {} field and then could not \
5835                             read the board back to verify it: {error}; run it again to verify \
5836                             it, which creates nothing a second time",
5837                            landed.join(" and ")
5838                        ),
5839                    });
5840                }
5841            };
5842            for field in &creates {
5843                let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
5844                    held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
5845                        && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
5846                });
5847                if !text {
5848                    moved.push(format!("the created {} text field", field.field));
5849                }
5850            }
5851        }
5852        if !moved.is_empty() {
5853            return Err(SourceError::Refused {
5854                message: format!(
5855                    "GitHub changed {} after the guarded field setup; the pre-write item \
5856                     assignments are:\n{}",
5857                    moved.join(", "),
5858                    recovery(&report, &before)?
5859                ),
5860            });
5861        }
5862        Ok(report)
5863    }
5864
5865    /// Validate configuration and capture the named credential without exposing it.
5866    ///
5867    /// # Errors
5868    ///
5869    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
5870    /// [`SourceError::Auth`] when the named credential is missing or empty.
5871    pub fn new(
5872        name: &SourceName,
5873        config: GitHubProjectsConfig,
5874        secrets: &dyn SecretResolver,
5875    ) -> Result<Self, SourceError> {
5876        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
5877    }
5878
5879    /// The same, recording every request it sends into an accounting the caller holds too.
5880    ///
5881    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
5882    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
5883    /// up — passes the one it records those into, so the session total accounts for the
5884    /// whole session rather than for this source's share of it.
5885    ///
5886    /// # Errors
5887    ///
5888    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
5889    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
5890    pub fn recording_into(
5891        name: &SourceName,
5892        config: GitHubProjectsConfig,
5893        secrets: &dyn SecretResolver,
5894        ledger: Arc<Accounting>,
5895    ) -> Result<Self, SourceError> {
5896        if !valid_github_owner(&config.owner) {
5897            return Err(SourceError::Config {
5898                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
5899            });
5900        }
5901        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
5902            return Err(SourceError::Config {
5903                message: format!("project_number must be between 1 and {}", i32::MAX),
5904            });
5905        }
5906        if !valid_environment_name(&config.token_env) {
5907            return Err(SourceError::Config {
5908                message: "token_env must be a valid environment-variable name".into(),
5909            });
5910        }
5911        let repository = config
5912            .repository
5913            .as_deref()
5914            .map(RepositoryTarget::parse)
5915            .transpose()?;
5916        if let Some(entry) = config
5917            .creation_owners
5918            .iter()
5919            .find(|entry| !valid_github_owner(entry))
5920        {
5921            return Err(SourceError::Config {
5922                message: format!(
5923                    "creation_owners entry {entry:?} is not a GitHub login: a login is 1-39 ASCII \
5924                     letters, digits, or single hyphens, and cannot start or end with a hyphen"
5925                ),
5926            });
5927        }
5928        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
5929            message: format!("endpoint is not a valid URL: {e}"),
5930        })?;
5931        if endpoint.scheme() != "https"
5932            && !(endpoint.scheme() == "http"
5933                && endpoint
5934                    .host_str()
5935                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
5936        {
5937            return Err(SourceError::Config {
5938                message:
5939                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
5940                        .into(),
5941            });
5942        }
5943        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
5944            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),
5945        })?;
5946        Ok(Self {
5947            name: name.clone(),
5948            owner: config.owner,
5949            project_number: config.project_number,
5950            repository,
5951            creation_owners: config.creation_owners,
5952            asset_client: std::sync::OnceLock::new(),
5953            body_markup: std::sync::OnceLock::new(),
5954            endpoint,
5955            token,
5956            credential_name: config.token_env,
5957            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
5958            priorities: config
5959                .priority_mapping
5960                .map(|mapping| PriorityMapping::resolve(mapping, name))
5961                .transpose()?,
5962            metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
5963            sub_issues: config.sub_issues,
5964            assignees: config.assignees,
5965            people: Mutex::new(BTreeMap::new()),
5966            forgotten: Mutex::new(Vec::new()),
5967            spellings: Mutex::new(BTreeMap::new()),
5968            link_types: onetaskgraph_plugin_api::LinkTypes::checked(
5969                KIND,
5970                &config.link_types,
5971                &[relations::RELATES_TO],
5972                relations::REFUSED_WORDS,
5973            )
5974            .map_err(|error| match error {
5975                SourceError::Config { message } => SourceError::Config {
5976                    message: format!("source {name}: {message}"),
5977                },
5978                other => other,
5979            })?,
5980            client: std::sync::OnceLock::new(),
5981            created: Mutex::new(Vec::new()),
5982            updated: Mutex::new(Vec::new()),
5983            commented: Mutex::new(Vec::new()),
5984            pacing: Pacing::resolve(config.pacing, name)?,
5985            last_mutation: Mutex::new(None),
5986            last_served: Mutex::new(None),
5987            windows: Mutex::new(pacing::Windows::default()),
5988            task_children: Mutex::new(BTreeMap::new()),
5989            written: Mutex::new(BTreeMap::new()),
5990            deferred: Mutex::new(None),
5991            held_updates: Mutex::new(Vec::new()),
5992            held_comments: Mutex::new(BTreeMap::new()),
5993            task_writes: Mutex::new(None),
5994            written_tokens: Mutex::new(BTreeMap::new()),
5995            clock: system_clock(),
5996            declared_private: std::sync::atomic::AtomicBool::new(false),
5997            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
5998            board_cache: Mutex::new(None),
5999            search_cache: Mutex::new(None),
6000            narrowed_cache: Mutex::new(BTreeMap::new()),
6001            resolved_cache: Mutex::new(BTreeMap::new()),
6002            read_ahead: Mutex::new(BTreeMap::new()),
6003            pending_origins: Mutex::new(Vec::new()),
6004            member_classes: Mutex::new(BTreeMap::new()),
6005            carried: Mutex::new(BTreeMap::new()),
6006            held_cache: Mutex::new(BTreeMap::new()),
6007            children_cache: Mutex::new(BTreeMap::new()),
6008            search_next: Mutex::new(BTreeMap::new()),
6009            fields_cache: Mutex::new(None),
6010            repository_cache: Mutex::new(BTreeMap::new()),
6011            ledger,
6012            expected: expected::Held::default(),
6013            projection: projection::Held::default(),
6014        })
6015    }
6016
6017    /// The client every API request goes through, built on the first one.
6018    ///
6019    /// Not with the source: building a client loads the system's certificate store, which
6020    /// costs tens of milliseconds, and a command that configures this board but never sends to
6021    /// it — a read of another source — should not pay that in its process.
6022    fn client(&self) -> Result<&Client, SourceError> {
6023        built(&self.client, || {
6024            Client::builder()
6025                .user_agent("onetaskgraph")
6026                .build()
6027                .map_err(|e| SourceError::Config {
6028                    message: format!("cannot build HTTP client: {e}"),
6029                })
6030        })
6031    }
6032
6033    /// The client attachment requests go through, with its own redirect policy, built on the
6034    /// first one for the reason [`Self::client`] gives.
6035    fn asset_client(&self) -> Result<&Client, SourceError> {
6036        built(&self.asset_client, || assets::client(&self.endpoint))
6037    }
6038
6039    /// A snapshot of every request this source has sent, and what each cost.
6040    ///
6041    /// A value to hold and compare rather than a borrow of the accounting itself, so two
6042    /// of them can sit side by side. When this source was built with
6043    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
6044    /// point of building it that way.
6045    #[must_use]
6046    pub fn accounting(&self) -> accounting::Session {
6047        self.ledger.snapshot()
6048    }
6049
6050    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
6051    /// rate limit rather than handing it straight back as an error.
6052    ///
6053    /// Retrying is safe for every document here, including the mutations, and the reason
6054    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
6055    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
6056    /// this replays has already taken effect. An outcome this source cannot know — the
6057    /// send failed, or the body could not be read, so the mutation may well have landed —
6058    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
6059    /// attempt. A duplicate write would come from replaying one of those, and none is
6060    /// replayed.
6061    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
6062        // Updates held back go out before any other request, so whatever it reads or writes
6063        // sees the board as though each had been sent when it was made.
6064        Box::pin(self.send_held_updates()).await?;
6065        // Origins held back for a read ahead that never came are sent before anything else.
6066        if query != graphql::ORIGIN_LOOKUPS && query != graphql::ORIGIN_LOOKUPS_WITH_MEMBERS {
6067            Box::pin(self.send_pending_origins(None)).await?;
6068        }
6069        let journaling = self.expected.journaling();
6070        let spelling = self.spelling();
6071        if !journaling && !spelling {
6072            return self.graphql_sent(query, variables).await;
6073        }
6074        // While a write the store's cache expects runs, its mutations select the whole issue
6075        // they leave behind, so the response vouches for what the cache keeps — and every
6076        // request is recorded, so the write's parts are reported from their own responses.
6077        // A targeted update sends a twin of each selecting the issue's token parts inline, at
6078        // the same price, so the version it answers is the one its own last response spells.
6079        let (query, variables) = match (journaling, graphql::whole_twin(query)) {
6080            (true, Some(twin)) => (twin, expected::whole_variables(variables)),
6081            _ => match graphql::token_twin(query).filter(|_| spelling) {
6082                Some(twin) => (twin, token_variables(variables)),
6083                None => (query, variables),
6084            },
6085        };
6086        let sent = self.graphql_sent(query, variables.clone()).await;
6087        if spelling && let Ok(data) = &sent {
6088            self.record_written_token(query, &variables, data);
6089        }
6090        if journaling {
6091            self.journal(query, &variables, &sent).await;
6092        }
6093        sent
6094    }
6095
6096    /// Whether a targeted update of this source is running, whose mutations are each sent as
6097    /// the twin selecting the whole issue (see [`Self::written_tokens`]).
6098    fn spelling(&self) -> bool {
6099        SPELLING
6100            .try_with(|source| *source == std::ptr::from_ref(self) as usize)
6101            .unwrap_or(false)
6102    }
6103
6104    /// Record the change token the issue a mutation's response shows whole holds now, as
6105    /// [`TaskSource::written_task`] answers it; a response showing no whole issue records
6106    /// nothing.
6107    fn record_written_token(&self, document: &str, variables: &Value, data: &Value) {
6108        let (issue, _) = expected::read_back(document, variables, data);
6109        if let Some(issue) = issue {
6110            self.record_written_issue(issue);
6111        }
6112    }
6113
6114    /// Record the change token `issue`, as a write's own answer shows it whole, holds now — or,
6115    /// where that answer lacks a part of one, that it was shown and spells none.
6116    fn record_written_issue(&self, issue: &Value) {
6117        // An answer naming the issue by its id alone did not show it, so it says nothing.
6118        if issue.get("updatedAt").is_none() {
6119            return;
6120        }
6121        let Some(id) = issue.get("id").and_then(Value::as_str) else {
6122            return;
6123        };
6124        let (item, token) = match self.written_token_of(issue) {
6125            Some((item, token)) => (item, Some(token)),
6126            None => {
6127                // Answered whole, but without every part of a token: its board item's id, where
6128                // it says one, so a later write naming that item still drops the entry.
6129                let item = issue
6130                    .pointer("/projectItems/nodes")
6131                    .and_then(Value::as_array)
6132                    .and_then(|nodes| self.board_entry(nodes))
6133                    .and_then(|entry| entry.get("id"))
6134                    .and_then(Value::as_str)
6135                    .unwrap_or_default()
6136                    .to_owned();
6137                (item, None)
6138            }
6139        };
6140        if let Ok(mut tokens) = self.written_tokens.lock() {
6141            tokens.insert(NativeId(id.to_owned()), (item, token));
6142        }
6143    }
6144
6145    /// Record `token` as the change token the task `item` holds after this command's write.
6146    fn remember_written_token(&self, item: &Resolved, token: String) {
6147        if let Ok(mut tokens) = self.written_tokens.lock() {
6148            tokens.insert(item.id.clone(), (item.item_id.clone(), Some(token)));
6149        }
6150    }
6151
6152    /// The change token the task `id` holds after this command's latest write of it, when that
6153    /// write's own response — or the read made right after it — showed one.
6154    fn written_token(&self, id: &NativeId) -> Option<String> {
6155        self.written_answer(id).flatten()
6156    }
6157
6158    /// What this command's latest write of the task `id` showed of it: `Some` once a response
6159    /// showed the issue whole — its token, or `None` where that response could not spell one —
6160    /// and `None` where no response did.
6161    fn written_answer(&self, id: &NativeId) -> Option<Option<String>> {
6162        self.written_tokens
6163            .lock()
6164            .ok()?
6165            .get(id)
6166            .map(|(_, token)| token.clone())
6167    }
6168
6169    /// Drop the written token of every task `variables` names, by its id or its board item's,
6170    /// before a mutation that may move it is sent.
6171    fn forget_written_tokens(&self, variables: &Value) {
6172        let Ok(mut tokens) = self.written_tokens.lock() else {
6173            return;
6174        };
6175        if tokens.is_empty() {
6176            return;
6177        }
6178        let named: Vec<&str> = variables
6179            .as_object()
6180            .into_iter()
6181            .flat_map(|arguments| arguments.values())
6182            .flat_map(|input| {
6183                WRITTEN_NAMES
6184                    .iter()
6185                    .filter_map(|key| input.get(key).and_then(Value::as_str))
6186            })
6187            .collect();
6188        tokens.retain(|id, (item, _)| {
6189            !named
6190                .iter()
6191                .any(|name| *name == id.0 || *name == item.as_str())
6192        });
6193    }
6194
6195    /// One document sent, retried while GitHub asks for a wait, exactly as asked.
6196    async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
6197        if is_mutation(query) {
6198            self.forget_written_tokens(&variables);
6199        }
6200        if is_mutation(query)
6201            && ![
6202                graphql::ADD_COMMENT,
6203                graphql::UPDATE_COMMENT,
6204                graphql::DELETE_COMMENT,
6205                graphql::ADD_COMMENT_WHOLE,
6206                graphql::UPDATE_COMMENT_WHOLE,
6207            ]
6208            .contains(&query)
6209        {
6210            let mut cache = self.resolved_cache()?;
6211            let mut ahead = self.read_ahead()?;
6212            let mut carried = self.carried()?;
6213            for argument in ["input", "second", "third", "clear"] {
6214                if let Some(input) = variables.get(argument) {
6215                    let names = |id: &NativeId, item_id: Option<&str>| {
6216                        ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
6217                            input
6218                                .get(key)
6219                                .and_then(Value::as_str)
6220                                .is_some_and(|value| value == id.0 || Some(value) == item_id)
6221                        })
6222                    };
6223                    cache.retain(|id, item| !names(id, Some(&item.item_id)));
6224                    ahead.retain(|id, item| {
6225                        !names(id, item.as_ref().map(|item| item.item_id.as_str()))
6226                    });
6227                    carried.retain(|id, item| !names(id, Some(&item.item_id)));
6228                }
6229            }
6230        }
6231        let doing = operation_description(query);
6232        let mutation = is_mutation(query);
6233        let charge = pacing::Charge::graphql(query, &variables);
6234        let mut waited = Duration::ZERO;
6235        let mut waits = 0_u32;
6236        let mut backoff = self.pacing.retry_backoff;
6237        loop {
6238            let ticket = self.pace(charge, mutation).await;
6239            // Every write but a create is asked for immediately before it is sent — a create's
6240            // fields, its relationships, its content and the delete that takes it back included
6241            // — so a copy that owns the create renews its hold first, and one that has lost it
6242            // sends nothing more. Asked again before a retry, which is sent later.
6243            if mutation
6244                && !query.contains("createIssue(")
6245                && let Err(refused) = <dyn onetaskgraph_plugin_api::WriteGuard>::check().await
6246            {
6247                self.paced(ticket, mutation);
6248                return Err(refused);
6249            }
6250            let attempt = self.send_once(query, &variables, waits + 1).await;
6251            self.paced(ticket, mutation);
6252            let remaining = self.pacing.retry_budget.saturating_sub(waited);
6253            let (wait, cause) = match attempt {
6254                Ok(data) => return Ok(data),
6255                Err(Attempt::Failed(error)) => return Err(error),
6256                Err(Attempt::Conflicted(said)) => {
6257                    // A create is never replayed: see `CONFLICT_WORDINGS`.
6258                    if query.contains("createIssue(") || backoff.is_zero() || backoff > remaining {
6259                        return Err(SourceError::Unavailable {
6260                            message: format!(
6261                                "GitHub refused this source while {doing} as a temporary \
6262                                 conflict ({said}); it retried {} over {} and the conflict \
6263                                 did not clear within the {} one call may spend waiting. \
6264                                 next: run the command again once nothing else is writing to \
6265                                 the same items",
6266                                plural(waits, "time"),
6267                                seconds(waited),
6268                                seconds(self.pacing.retry_budget),
6269                            ),
6270                        });
6271                    }
6272                    (backoff, pacing::cause::BACKOFF)
6273                }
6274                Err(Attempt::Limited(limited)) => {
6275                    let (wait, cause) = limited.wait(backoff);
6276                    // A wait of nothing spends none of the budget, so it is exhaustion rather
6277                    // than a retry. `Pacing::resolve` rules out every way of configuring one
6278                    // except a budget of zero, where reporting the first refusal is the ask.
6279                    if wait.is_zero() || wait > remaining {
6280                        return Err(limited.exhausted(
6281                            doing,
6282                            waits,
6283                            waited,
6284                            wait,
6285                            self.pacing.retry_budget,
6286                        ));
6287                    }
6288                    (wait, cause)
6289                }
6290            };
6291            self.clock.sleep_because(wait, cause).await;
6292            waited += wait;
6293            waits += 1;
6294            backoff = backoff.saturating_mul(2);
6295        }
6296    }
6297
6298    /// Hold one request charging `charge` until every secondary window it charges has room for
6299    /// the whole of it — and, for a mutation of an instance configured with
6300    /// `pacing.min_mutation_interval_ms`, until that interval has passed since the last one
6301    /// finished — then count it, answering the ticket [`Self::paced`] finishes it with.
6302    ///
6303    /// The moment is decided and counted under the lock and waited out outside it, so two
6304    /// callers take two places rather than the same one, and no lock is held across an await.
6305    /// A request with room goes out at once: nothing here waits that a window does not ask for.
6306    async fn pace(&self, charge: pacing::Charge, mutation: bool) -> pacing::Ticket {
6307        let (wait, cause, ticket) = {
6308            // A poisoned lock here costs pacing, not correctness, and refusing the write over
6309            // it would turn an earlier failure into a second one for no gain.
6310            let mut windows = self
6311                .windows
6312                .lock()
6313                .unwrap_or_else(std::sync::PoisonError::into_inner);
6314            let now = self.clock.now();
6315            let (mut at, mut cause) = if self.pacing.windows {
6316                windows.room(now, charge)
6317            } else {
6318                (now, None)
6319            };
6320            if mutation && !self.pacing.min_mutation_interval.is_zero() {
6321                let mut last = self
6322                    .last_mutation
6323                    .lock()
6324                    .unwrap_or_else(std::sync::PoisonError::into_inner);
6325                // `checked_add` rather than `+`: adding durations can panic on overflow, and
6326                // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
6327                if let Some(earliest) = last
6328                    .and_then(|previous| previous.checked_add(self.pacing.min_mutation_interval))
6329                    && earliest > at
6330                {
6331                    at = earliest;
6332                    cause = Some(pacing::cause::MIN_INTERVAL);
6333                }
6334                *last = Some(at);
6335            }
6336            let ticket = windows.charge(at, charge);
6337            (at.saturating_sub(now), cause, ticket)
6338        };
6339        if let (false, Some(cause)) = (wait.is_zero(), cause) {
6340            self.clock.sleep_because(wait, cause).await;
6341        }
6342        ticket
6343    }
6344
6345    /// Record that the request `ticket` was charged for has finished, so each window — and
6346    /// the configured spacing — counts it from here rather than from the moment it left.
6347    ///
6348    /// This source can only choose when a request *departs*; GitHub's limiter counts when it
6349    /// *arrives*, and the two differ by whatever the request spent in transit. Counting from
6350    /// departure would hand the limiter a window that looked roomier than it is, so a source
6351    /// pacing exactly at a limit could still be seen arriving over it — which is how a copy
6352    /// paced well inside a board's threshold was once refused by it on a slower machine while
6353    /// passing on a quick one. The previous request had arrived before its response came back,
6354    /// so counting from here is never earlier than the limiter does, whatever transit costs
6355    /// and on whatever platform: the safe side of a limit that punishes being wrong by refusing
6356    /// reads for the next fifty minutes.
6357    ///
6358    /// A failed attempt is finished too: a request refused by the limiter still arrived, and
6359    /// one that never left costs only room nobody needed.
6360    fn paced(&self, ticket: pacing::Ticket, mutation: bool) {
6361        let now = self.clock.now();
6362        self.windows
6363            .lock()
6364            .unwrap_or_else(std::sync::PoisonError::into_inner)
6365            .finish(ticket, now);
6366        if mutation && !self.pacing.min_mutation_interval.is_zero() {
6367            let mut last = self
6368                .last_mutation
6369                .lock()
6370                .unwrap_or_else(std::sync::PoisonError::into_inner);
6371            // `max` rather than an assignment: a concurrent caller may already have reserved a
6372            // slot further out, and completing this request must never pull that slot back in.
6373            *last = Some(last.map_or(now, |reserved| reserved.max(now)));
6374        }
6375    }
6376
6377    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
6378    /// failure that waiting cannot help — and recorded, whichever of the three it was.
6379    ///
6380    /// This is the one place a request leaves this crate, which is why the accounting is
6381    /// here rather than at each of the callers: a read path added later is counted without
6382    /// anybody remembering to count it, and
6383    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
6384    /// when one is not.
6385    async fn send_once(
6386        &self,
6387        query: &str,
6388        variables: &Value,
6389        number: u32,
6390    ) -> Result<Value, Attempt> {
6391        // A lookup goes out sized to the people it names, and the record names and counts it as
6392        // what it is; everything that decides how it is retried or read compares `query`, the
6393        // constant it was written as.
6394        let sized = graphql::sized(query, variables);
6395        let executed = self.ledger.priced_document(sized.as_ref());
6396        let Attempted {
6397            result,
6398            limits,
6399            reported_cost,
6400            response_outcome,
6401        } = self.attempt(executed.as_ref(), variables).await;
6402        // No `otherwise` name: every document this source sends is one of its own, and the
6403        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
6404        let sending = accounting::Request::graphql(sized.as_ref(), variables, None, reported_cost);
6405        let outcome = match &result {
6406            Ok(_) => accounting::Outcome::Answered,
6407            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
6408            Err(Attempt::Failed(_) | Attempt::Conflicted(_)) => accounting::Outcome::Refused,
6409        };
6410        self.ledger
6411            .record(sending.finished(outcome, limits).with_execution(
6412                Some(executed.into_owned()),
6413                response_outcome,
6414                number,
6415            ));
6416        result
6417    }
6418
6419    /// The attempt itself, with what its response said about the rate limit alongside.
6420    ///
6421    /// The two are returned together rather than recorded here because every one of the
6422    /// early exits below is a different outcome, and a record written at each of them is a
6423    /// record one of them can be added without.
6424    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
6425        let mut limits = accounting::RateLimit::default();
6426        let mut reported_cost = None;
6427        let mut response_outcome = accounting::ResponseOutcome::TransportError;
6428        // Spelled here, at the one place a document leaves for GitHub, and nowhere earlier:
6429        // everything that decides how a document is retried or how its answer is read compares
6430        // it with the constant it was written as.
6431        let spelled = self
6432            .assignees
6433            .then(|| graphql::with_assignees(query))
6434            .flatten();
6435        let result = self
6436            .attempted(
6437                query,
6438                spelled.as_deref().unwrap_or(query),
6439                variables,
6440                &mut limits,
6441                &mut reported_cost,
6442                &mut response_outcome,
6443            )
6444            .await;
6445        Attempted {
6446            result,
6447            limits,
6448            reported_cost,
6449            response_outcome,
6450        }
6451    }
6452
6453    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
6454    async fn attempted(
6455        &self,
6456        query: &str,
6457        sent: &str,
6458        variables: &Value,
6459        limits: &mut accounting::RateLimit,
6460        reported_cost: &mut Option<u64>,
6461        response_outcome: &mut accounting::ResponseOutcome,
6462    ) -> Result<Value, Attempt> {
6463        let response = self
6464            .client()
6465            .map_err(Attempt::Failed)?
6466            .post(self.endpoint.clone())
6467            .bearer_auth(self.token.expose_secret())
6468            .json(&json!({"query": sent, "variables": variables}))
6469            .send()
6470            .await
6471            .map_err(|e| {
6472                Attempt::Failed(SourceError::Unavailable {
6473                    message: format!("GitHub GraphQL request failed: {}", transport_error(&e)),
6474                })
6475            })?;
6476        let status = response.status();
6477        if !status.is_success() {
6478            *response_outcome = accounting::ResponseOutcome::HttpError;
6479        }
6480        let header = |name: &str| whole_seconds(response.headers().get(name));
6481        let served = response
6482            .headers()
6483            .get(reqwest::header::DATE)
6484            .and_then(|value| value.to_str().ok())
6485            .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
6486            .map(|at| at.with_timezone(&Utc));
6487        *limits = accounting::RateLimit::read(|name| {
6488            response
6489                .headers()
6490                .get(name)
6491                .and_then(|value| value.to_str().ok())
6492                .map(str::to_owned)
6493        });
6494        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
6495        // that are not text at all — is "not known to be exhausted". This never makes a
6496        // response a refusal on its own: it says which limiter a refusal is attributed to
6497        // and where its hint comes from, so a value this cannot read costs a hint rather
6498        // than an answer.
6499        let exhausted = response
6500            .headers()
6501            .get("x-ratelimit-remaining")
6502            .and_then(|value| value.to_str().ok())
6503            == Some("0");
6504        // `retry-after` is what GitHub asks for when it asks; when it does not and the
6505        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
6506        // which is the same question answered as an absolute time. Nothing else here is a
6507        // hint, and a schedule is what answers a refusal that carries none.
6508        let retry_after = header("retry-after");
6509        let hinted_by = if retry_after.is_some() || !exhausted {
6510            Hint::RetryAfter
6511        } else {
6512            Hint::PrimaryReset
6513        };
6514        let hint = retry_after.or_else(|| {
6515            exhausted
6516                .then(|| header("x-ratelimit-reset"))
6517                .flatten()
6518                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
6519        });
6520        // Read before it is parsed, because the evidence which tells a secondary rate
6521        // limit from a rejected credential is in the body of a response whose status says
6522        // only "forbidden" — and a non-success response was never parsed at all.
6523        let body = response.text().await.map_err(|e| {
6524            Attempt::Failed(SourceError::Unavailable {
6525                message: format!(
6526                    "GitHub GraphQL response could not be read: {}",
6527                    transport_error(&e)
6528                ),
6529            })
6530        })?;
6531        *reported_cost = serde_json::from_str::<Value>(&body)
6532            .ok()
6533            .as_ref()
6534            .and_then(|body| body.pointer("/data/rateLimit/cost"))
6535            .and_then(Value::as_u64);
6536        let parsed = serde_json::from_str::<Value>(&body).ok();
6537        *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
6538        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
6539            return Err(Attempt::Limited(Limited {
6540                limiter,
6541                hint,
6542                hinted_by,
6543            }));
6544        }
6545        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
6546            return Err(Attempt::Failed(SourceError::Auth {
6547                message: format!(
6548                    "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"
6549                ),
6550            }));
6551        }
6552        if !status.is_success() {
6553            return Err(Attempt::Failed(SourceError::Unavailable {
6554                message: format!("GitHub GraphQL returned HTTP {status}"),
6555            }));
6556        }
6557        let said = refusal_wording(status, &body);
6558        let normalized = said.to_ascii_lowercase();
6559        if CONFLICT_WORDINGS
6560            .iter()
6561            .any(|wording| normalized.contains(wording))
6562        {
6563            return Err(Attempt::Conflicted(said));
6564        }
6565        // A lookup is read as the inventoried document it was sized from.
6566        let query = graphql::inventoried(query);
6567        let answered = if query == graphql::ISSUES_BY_REFERENCE {
6568            self.answer_unresolved(&body)
6569        } else if [
6570            graphql::CREATE_ISSUES,
6571            graphql::SEARCH_BATCH,
6572            graphql::USER_IDS,
6573            graphql::ADD_COMMENTS,
6574            graphql::WRITE_TASKS,
6575            graphql::SET_PARENTS,
6576            graphql::ADD_RELATES_TO,
6577            graphql::REMOVE_RELATES_TO,
6578        ]
6579        .contains(&query)
6580        {
6581            answer_with_errors(&body).map_or_else(|| self.answer(&body), Ok)
6582        } else {
6583            self.answer(&body)
6584        };
6585        let mut data = answered.map_err(Attempt::Failed)?;
6586        if let Some(served) = served {
6587            *self
6588                .last_served
6589                .lock()
6590                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(served);
6591            stamp_served(&mut data, &served.to_rfc3339());
6592        }
6593        Ok(data)
6594    }
6595
6596    /// What one successful HTTP response says, once its GraphQL errors are read.
6597    /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
6598    /// for this credential — a repository it cannot see, an issue number it does not hold —
6599    /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
6600    /// that reference rather than a failure of the others. Any other error refuses the read,
6601    /// as there.
6602    fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
6603        let parsed: Option<Value> = serde_json::from_str(body).ok();
6604        // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
6605        // `issue`, and the answer holds `null` at exactly that path: anything else — another
6606        // kind of error, one naming no slot, or one beside a value it says is not there — is a
6607        // refusal of the whole read.
6608        let unresolved = |data: &Value, error: &Value| {
6609            error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
6610                && error
6611                    .get("path")
6612                    .and_then(Value::as_array)
6613                    .is_some_and(|path| {
6614                        let alias = path.first().and_then(Value::as_str).and_then(|alias| {
6615                            alias
6616                                .strip_prefix('r')
6617                                .and_then(|slot| slot.parse::<usize>().ok())
6618                                .filter(|slot| *slot < REFERENCE_BATCH)
6619                                .map(|_| alias)
6620                        });
6621                        match (alias, path.get(1).and_then(Value::as_str), path.len()) {
6622                            (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
6623                            (Some(alias), Some("issue"), 2) => data
6624                                .get(alias)
6625                                .and_then(|repository| repository.get("issue"))
6626                                .is_some_and(Value::is_null),
6627                            _ => false,
6628                        }
6629                    })
6630        };
6631        let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
6632            parsed.get("data").is_some_and(|data| {
6633                data.is_object()
6634                    && parsed
6635                        .get("errors")
6636                        .and_then(Value::as_array)
6637                        .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
6638            })
6639        });
6640        match parsed {
6641            Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
6642            _ => self.answer(body),
6643        }
6644    }
6645
6646    fn answer(&self, body: &str) -> Result<Value, SourceError> {
6647        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
6648            message: format!("GitHub returned invalid JSON: {e}"),
6649        })?;
6650        let errors = body
6651            .get("errors")
6652            .map(|value| {
6653                value.as_array().ok_or_else(|| SourceError::Malformed {
6654                    message: "GitHub response errors is not an array".into(),
6655                })
6656            })
6657            .transpose()?;
6658        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
6659            let messages = errors
6660                .iter()
6661                .filter_map(|e| e.get("message").and_then(Value::as_str))
6662                .collect::<Vec<_>>()
6663                .join("; ");
6664            let message = if messages.is_empty() {
6665                "GitHub returned GraphQL errors".into()
6666            } else {
6667                messages
6668            };
6669            let normalized = message.to_ascii_lowercase();
6670            if normalized.contains("resource not accessible") || normalized.contains("scope") {
6671                return Err(SourceError::Auth {
6672                    message: format!(
6673                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
6674                        self.credential_name
6675                    ),
6676                });
6677            }
6678            return Err(SourceError::Refused { message });
6679        }
6680        body.get("data")
6681            .filter(|data| data.is_object())
6682            .cloned()
6683            .ok_or_else(|| SourceError::Malformed {
6684                message: "GitHub response has no data object".into(),
6685            })
6686    }
6687
6688    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
6689    // GraphQL cannot independently page them inside the outer item page. This source page is
6690    // deliberately bounded at that published maximum; the live drift journey exercises it.
6691    async fn board_page(
6692        &self,
6693        items_after: Option<&str>,
6694        items_first: u32,
6695    ) -> Result<Value, SourceError> {
6696        let data = self
6697            .graphql(
6698                graphql::BOARD,
6699                json!({"owner":self.owner,"number":self.project_number,
6700                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
6701                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
6702            )
6703            .await?;
6704        data.pointer("/owner/projectV2")
6705            .filter(|v| !v.is_null())
6706            .cloned()
6707            .ok_or_else(|| SourceError::Refused {
6708                message: format!(
6709                    "GitHub project {}/{} was not found or is not visible to the token",
6710                    self.owner, self.project_number
6711                ),
6712            })
6713    }
6714
6715    /// The search that finds the issues of this board, narrowed by `also` when it is
6716    /// given.
6717    ///
6718    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
6719    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
6720    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
6721    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
6722    /// from a task by the `parent` field each issue carries rather than by the search.
6723    fn board_search(&self, also: Option<&str>) -> String {
6724        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
6725        match also {
6726            Some(also) => format!("{scope} {also}"),
6727            None => scope,
6728        }
6729    }
6730
6731    /// One issue this source reached directly, as the board item a read of the board would
6732    /// have produced — or `None` when this board does not hold it.
6733    ///
6734    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
6735    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
6736    /// item's own id, that item's field values, and the issue as its content. One resolver
6737    /// for both routes is what makes an issue read through a search, through its own node
6738    /// id, or through its project's sub-issues report the same title, the same status, the
6739    /// same labels and the same qualified id.
6740    ///
6741    /// An issue with no entry for *this* board is not this source's to report, which is
6742    /// what keeps an id naming some other repository's issue from being answered as an item
6743    /// of this board. That answer is given about an **exhausted** connection and never
6744    /// about an unread page: the entry is looked for on the page in hand, and only if that
6745    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
6746    /// rest of it.
6747    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
6748        self.resolve_issue_as(issue, true).await
6749    }
6750
6751    /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
6752    /// and writes only when `remember` says to.
6753    async fn resolve_issue_as(
6754        &self,
6755        issue: &Value,
6756        remember: bool,
6757    ) -> Result<Option<Resolved>, SourceError> {
6758        if optional_str(issue, "__typename")? != Some("Issue") {
6759            return Ok(None);
6760        }
6761        let memberships = issue
6762            .get("projectItems")
6763            .ok_or_else(|| SourceError::Malformed {
6764                message: "GitHub issue is missing projectItems".into(),
6765            })?;
6766        let nodes = memberships
6767            .get("nodes")
6768            .and_then(Value::as_array)
6769            .ok_or_else(|| SourceError::Malformed {
6770                message: "GitHub issue projectItems.nodes is not an array".into(),
6771            })?;
6772        let held = match self.board_entry(nodes) {
6773            Some(held) => held.clone(),
6774            None => {
6775                let info = memberships
6776                    .get("pageInfo")
6777                    .ok_or_else(|| SourceError::Malformed {
6778                        message: "GitHub issue projectItems has no pageInfo".into(),
6779                    })?;
6780                // The page held no entry for this board. Whether that means the issue is
6781                // not on it is a question about the rest of the connection, and only a
6782                // connection with no rest answers it here.
6783                if !required_bool(info, "hasNextPage")? {
6784                    return Ok(None);
6785                }
6786                let cursor = required_str(info, "endCursor")?;
6787                validate_cursor_progress(None, cursor)?;
6788                let issue_id = required_str(issue, "id")?;
6789                match self.board_membership(issue_id, cursor).await? {
6790                    Some(held) => held,
6791                    None => return Ok(None),
6792                }
6793            }
6794        };
6795        // The membership's own `updatedAt` rides along: it is the board half of the item's change
6796        // token, which a field change moves where the issue's own time does not.
6797        let item = json!({
6798            "id": required_str(&held, "id")?,
6799            "updatedAt": held.get("updatedAt"),
6800            "project": held.get("project"),
6801            "fieldValues": held.get("fieldValues"),
6802            "content": issue,
6803        });
6804        if remember {
6805            self.resolve(&item)
6806        } else {
6807            self.resolve_unremembered(&item)
6808        }
6809    }
6810
6811    /// This board's own entry among one page of an issue's `Issue.projectItems`.
6812    ///
6813    /// One spelling of *which membership is this board's*, so the page a read carries and
6814    /// the pages [`Self::board_membership`] walks are searched by the same rule.
6815    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
6816        nodes.iter().find(|node| {
6817            node.pointer("/project/number").and_then(Value::as_u64)
6818                == Some(u64::from(self.project_number))
6819        })
6820    }
6821
6822    /// The rest of one issue's board memberships, from `after`, for this board's entry.
6823    ///
6824    /// The recovery read: a page of memberships that holds no entry for this board says
6825    /// nothing about the memberships past it, so the connection is walked to exhaustion
6826    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
6827    /// positive answer — the whole connection was read and no entry named this board —
6828    /// rather than a failure, and the walk is held to
6829    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
6830    /// with a cursor that does not advance is refused instead of spun on.
6831    async fn board_membership(
6832        &self,
6833        issue: &str,
6834        after: &str,
6835    ) -> Result<Option<Value>, SourceError> {
6836        let mut after = after.to_owned();
6837        loop {
6838            let data = self
6839                .graphql(
6840                    graphql::ISSUE_BOARD_ITEMS,
6841                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
6842                           "nestedFirst":NESTED_PAGE_SIZE}),
6843                )
6844                .await?;
6845            let Some(connection) = data
6846                .pointer("/node/projectItems")
6847                .filter(|value| !value.is_null())
6848            else {
6849                // The id resolved to nothing, or to something with no memberships to walk —
6850                // which is the same answer as a connection holding no entry for this board.
6851                return Ok(None);
6852            };
6853            let nodes = connection
6854                .get("nodes")
6855                .and_then(Value::as_array)
6856                .ok_or_else(|| SourceError::Malformed {
6857                    message: "GitHub issue projectItems.nodes is not an array".into(),
6858                })?;
6859            if let Some(held) = self.board_entry(nodes) {
6860                return Ok(Some(held.clone()));
6861            }
6862            let info = connection
6863                .get("pageInfo")
6864                .ok_or_else(|| SourceError::Malformed {
6865                    message: "GitHub issue projectItems has no pageInfo".into(),
6866                })?;
6867            let next = required_bool(info, "hasNextPage")?
6868                .then(|| required_str(info, "endCursor"))
6869                .transpose()?;
6870            match next {
6871                Some(next) => {
6872                    validate_cursor_progress(Some(&after), next)?;
6873                    after = next.to_owned();
6874                }
6875                None => return Ok(None),
6876            }
6877        }
6878    }
6879
6880    /// One page of a board-scoped issue search, and where the next page resumes.
6881    async fn search_page(
6882        &self,
6883        search: &str,
6884        first: u32,
6885        after: Option<&str>,
6886    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
6887        let data = self
6888            .graphql(
6889                graphql::SEARCH_ISSUES,
6890                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
6891                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
6892                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6893            )
6894            .await?;
6895        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
6896            message: "GitHub search response has no search connection".into(),
6897        })?;
6898        let mut found = Vec::new();
6899        for node in connection
6900            .get("nodes")
6901            .and_then(Value::as_array)
6902            .ok_or_else(|| SourceError::Malformed {
6903                message: "GitHub search nodes is not an array".into(),
6904            })?
6905        {
6906            if let Some(resolved) = self.resolve_issue(node).await? {
6907                found.push(resolved);
6908            }
6909        }
6910        let info = connection
6911            .get("pageInfo")
6912            .ok_or_else(|| SourceError::Malformed {
6913                message: "GitHub search connection has no pageInfo".into(),
6914            })?;
6915        let next = required_bool(info, "hasNextPage")?
6916            .then(|| required_str(info, "endCursor"))
6917            .transpose()?
6918            .map(str::to_owned);
6919        if let Some(next) = &next {
6920            validate_cursor_progress(after, next)?;
6921        }
6922        Ok((found, next))
6923    }
6924
6925    /// Every issue this board holds, completed with what this run wrote.
6926    ///
6927    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
6928    /// is an index and is eventually consistent, so an issue this run created seconds ago
6929    /// can be absent from it, and a project listed straight after being written would
6930    /// otherwise be missing from its own board. What is added back is only what this
6931    /// process itself wrote, out of [`Self::created`], which lives and dies with the
6932    /// process.
6933    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6934        let found = self.searched_issues().await?;
6935        self.completed_with_written(found, |_| true)
6936    }
6937
6938    /// Every issue this board's own search reports, walked to exhaustion, read once per
6939    /// source.
6940    ///
6941    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
6942    /// needs it too and the two would otherwise walk the same search twice in one command.
6943    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
6944    /// is.
6945    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6946        let cached = self.search_cache()?.clone();
6947        if let Some(held) = cached {
6948            return Ok(held);
6949        }
6950        let mut after: Option<String> = None;
6951        let mut found = Vec::new();
6952        let search = self.board_search(None);
6953        loop {
6954            let (page, next) = self
6955                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6956                .await?;
6957            found.extend(page);
6958            match next {
6959                Some(next) => after = Some(next),
6960                None => break,
6961            }
6962        }
6963        *self.search_cache()? = Some(found.clone());
6964        Ok(found)
6965    }
6966
6967    /// Every issue this board's own search reports with `also` beside its scope, walked to
6968    /// exhaustion — held for no later read, which may not be narrowed alike.
6969    async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6970        let mut after: Option<String> = None;
6971        let mut found = Vec::new();
6972        let search = self.board_search(Some(also));
6973        loop {
6974            let (page, next) = self
6975                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6976                .await?;
6977            found.extend(page);
6978            match next {
6979                Some(next) => after = Some(next),
6980                None => return Ok(found),
6981            }
6982        }
6983    }
6984
6985    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
6986    fn search_cache(
6987        &self,
6988    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
6989        self.search_cache
6990            .lock()
6991            .map_err(|_| SourceError::Unavailable {
6992                message: "this source's view of the board's issues was left inconsistent by an \
6993                      earlier failure; next: run the command again"
6994                    .into(),
6995            })
6996    }
6997
6998    /// `found`, with everything this run wrote that `keep` accepts and the read did not
6999    /// report.
7000    ///
7001    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
7002    /// at all: the search index is behind, and a node read of an item filed moments ago can
7003    /// be too.
7004    fn completed_with_written(
7005        &self,
7006        mut found: Vec<Resolved>,
7007        keep: impl Fn(&Resolved) -> bool,
7008    ) -> Result<Vec<Resolved>, SourceError> {
7009        for own in self.created()?.iter().filter(|own| keep(own)) {
7010            if !found.iter().any(|item| item.id == own.id) {
7011                found.push(own.clone());
7012            }
7013        }
7014        Ok(found)
7015    }
7016
7017    /// What resolving one node id reached.
7018    ///
7019    /// Three answers rather than an `Option`, because a board *draft* is none of the other
7020    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
7021    /// is completed by a read of the draft itself rather than reported as nothing.
7022    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
7023        let asked = self
7024            .graphql(
7025                graphql::ISSUE,
7026                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
7027                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
7028            )
7029            .await;
7030        let data = match asked {
7031            Ok(data) => data,
7032            // A string that is not a node id at all is not a failure to report: it is an id
7033            // this board does not hold, which is what every read of one already answers.
7034            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
7035            Err(error) => return Err(error),
7036        };
7037        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7038            return Ok(Reached::Nothing);
7039        };
7040        if optional_str(node, "__typename")? == Some("DraftIssue") {
7041            return Ok(Reached::Draft);
7042        }
7043        Ok(match self.resolve_issue(node).await? {
7044            Some(item) => Reached::Held(Box::new(item)),
7045            None => Reached::Nothing,
7046        })
7047    }
7048
7049    /// One item of this board by its own id, whatever kind it is.
7050    ///
7051    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
7052    /// run wrote is read first, because a node read of an item created moments ago can
7053    /// still be behind the board field values written onto it — see [`Self::created`].
7054    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7055        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
7056            return Ok(Some(own.clone()));
7057        }
7058        if let Some(held) = self.read_ahead()?.remove(id) {
7059            return Ok(held);
7060        }
7061        match self.reach(id).await? {
7062            Reached::Held(item) => Ok(Some(*item)),
7063            Reached::Nothing => Ok(None),
7064            Reached::Draft => self.draft_by_id(id).await,
7065        }
7066    }
7067
7068    /// [`Self::item_by_id`] for a document: one [`graphql::DOCUMENT_IN_PROJECT`] read, which
7069    /// answers the project it is filed under and that project's members beside it.
7070    ///
7071    /// Each is held for this command as a read of it would have been: the project for the
7072    /// next read of it by its id, and — when its page of members is the whole of them — the
7073    /// members for the next listing of that project. So a write held to who may read the
7074    /// document, which reads all three, is answered by this one request.
7075    async fn document_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7076        if self.created()?.iter().any(|own| own.id == *id) || self.read_ahead()?.contains_key(id) {
7077            return self.item_by_id(id).await;
7078        }
7079        let asked = self
7080            .graphql(
7081                graphql::DOCUMENT_IN_PROJECT,
7082                json!({"id":id.0,"first":MAX_PAGE_SIZE,"memberFirst":MEMBER_PAGE_SIZE,
7083                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7084                       "duplicates":true}),
7085            )
7086            .await;
7087        let data = match asked {
7088            Ok(data) => data,
7089            Err(error) if unresolvable_node(&error) => return Ok(None),
7090            Err(error) => return Err(error),
7091        };
7092        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7093            return Ok(None);
7094        };
7095        if optional_str(node, "__typename")? == Some("DraftIssue") {
7096            return self.draft_by_id(id).await;
7097        }
7098        let Some(item) = self.resolve_issue(node).await? else {
7099            return Ok(None);
7100        };
7101        // An answer for another issue than the one asked about says nothing of that one.
7102        if item.id != *id {
7103            return Err(SourceError::Malformed {
7104                message: format!(
7105                    "GitHub answered issue {} when asked for document {}",
7106                    item.id.0, id.0
7107                ),
7108            });
7109        }
7110        if let Some(project) = node.get("project").filter(|value| !value.is_null())
7111            && let Some(parent) = self
7112                .resolve_issue(project)
7113                .await?
7114                .filter(|parent| Some(&parent.id) == item.parent.as_ref())
7115        {
7116            let members = project.get("subIssues").filter(|value| !value.is_null());
7117            let whole = members
7118                .and_then(|members| members.pointer("/pageInfo/hasNextPage"))
7119                .and_then(Value::as_bool)
7120                == Some(false);
7121            if whole {
7122                let nodes = members
7123                    .and_then(|members| members.get("nodes"))
7124                    .and_then(Value::as_array)
7125                    .ok_or_else(|| SourceError::Malformed {
7126                        message: "GitHub subIssues.nodes is not an array".into(),
7127                    })?;
7128                let mut children = Vec::new();
7129                for member in nodes {
7130                    if let Some(resolved) = self.resolve_issue(member).await? {
7131                        children.push(resolved);
7132                    }
7133                }
7134                // Held as the project's members only when every one resolved and says it is
7135                // filed under it: a page with any other answers nothing of all the members.
7136                if children.len() == nodes.len()
7137                    && children
7138                        .iter()
7139                        .all(|child| child.parent.as_ref() == Some(&parent.id))
7140                {
7141                    self.children_cache()?
7142                        .insert(parent.id.clone(), (parent.id.clone(), children));
7143                }
7144            }
7145            self.read_ahead()?.insert(parent.id.clone(), Some(parent));
7146        }
7147        Ok(Some(item))
7148    }
7149
7150    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
7151    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
7152    /// than one request per id.
7153    ///
7154    /// What this run wrote answers first, as it does there, and only the rest is read. One id
7155    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
7156    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
7157    /// id at a time, so that id is answered as not held and the others as themselves; a draft
7158    /// is completed by a read of the draft, exactly as there.
7159    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
7160        let mut found: Vec<Option<Option<Resolved>>> = {
7161            let created = self.created()?;
7162            ids.iter()
7163                .map(|id| {
7164                    created
7165                        .iter()
7166                        .find(|own| own.id == *id)
7167                        .map(|own| Some(own.clone()))
7168                })
7169                .collect()
7170        };
7171        let unread: Vec<NativeId> = ids
7172            .iter()
7173            .zip(&found)
7174            .filter(|(_, found)| found.is_none())
7175            .map(|(id, _)| id.clone())
7176            .collect();
7177        let mut read = Vec::with_capacity(unread.len());
7178        if let [one] = unread.as_slice() {
7179            read.push(self.item_by_id(one).await?);
7180        } else {
7181            for batch in unread.chunks(DETAIL_BATCH) {
7182                let data = match self
7183                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
7184                    .await
7185                {
7186                    Ok(data) => data,
7187                    Err(error) if unresolvable_node(&error) => {
7188                        for id in batch {
7189                            read.push(self.item_by_id(id).await?);
7190                        }
7191                        continue;
7192                    }
7193                    Err(error) => return Err(error),
7194                };
7195                for (slot, id) in batch.iter().enumerate() {
7196                    let node =
7197                        data.get(format!("i{slot}"))
7198                            .ok_or_else(|| SourceError::Malformed {
7199                                message: format!(
7200                                    "GitHub answered a batch read with no item for {}",
7201                                    id.0
7202                                ),
7203                            })?;
7204                    read.push(if node.is_null() {
7205                        None
7206                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
7207                        self.draft_by_id(id).await?
7208                    } else {
7209                        if optional_str(node, "__typename")? == Some("Issue")
7210                            && required_str(node, "id")? != id.0
7211                        {
7212                            return Err(SourceError::Malformed {
7213                                message: format!(
7214                                    "GitHub answered the read of {} with issue {}",
7215                                    id.0,
7216                                    required_str(node, "id")?
7217                                ),
7218                            });
7219                        }
7220                        self.resolve_issue(node).await?
7221                    });
7222                }
7223            }
7224        }
7225        let mut read = read.into_iter();
7226        Ok(found
7227            .iter_mut()
7228            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
7229            .collect())
7230    }
7231
7232    fn read_ahead(
7233        &self,
7234    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
7235        self.read_ahead
7236            .lock()
7237            .map_err(|_| SourceError::Unavailable {
7238                message:
7239                    "items read ahead for a copy were left inconsistent; run the command again"
7240                        .into(),
7241            })
7242    }
7243
7244    fn resolved_cache(
7245        &self,
7246    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
7247        self.resolved_cache
7248            .lock()
7249            .map_err(|_| SourceError::Unavailable {
7250                message: "resolved item records were left inconsistent; run the command again"
7251                    .into(),
7252            })
7253    }
7254
7255    /// Reuse a record this invocation already resolved. The mutation sender invalidates
7256    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
7257    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7258        let cached = self.resolved_cache()?.get(id).cloned();
7259        if let Some(item) = cached {
7260            return Ok(Some(item));
7261        }
7262        self.item_by_id(id).await
7263    }
7264
7265    /// Take out the item an issue read by reference reached for `id`, when one is held: the
7266    /// status write it was read for is the one caller, so no other read or write is ever
7267    /// answered from a read that selected only what that write needs.
7268    fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7269        Ok(self
7270            .held_cache
7271            .lock()
7272            .map_err(|_| SourceError::Unavailable {
7273                message: "items read by issue reference were left inconsistent; run the \
7274                          command again"
7275                    .into(),
7276            })?
7277            .remove(id))
7278    }
7279
7280    /// One board draft by its own id, with the board item it sits in — or `None` when no
7281    /// item of this board is that draft's.
7282    ///
7283    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
7284    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
7285    /// links a draft to one board item, so the page this read carries is the whole of that
7286    /// connection, and a page that reports more than it holds is refused rather than read
7287    /// as an answer about memberships nobody read.
7288    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7289        let data = self
7290            .graphql(
7291                graphql::DRAFT,
7292                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
7293                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
7294            )
7295            .await?;
7296        // Gone between the two reads is an answer — the draft is no longer there. Anything
7297        // else than the draft [`Self::reach`] was just told this id is, is not one.
7298        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
7299            return Ok(None);
7300        };
7301        if optional_str(draft, "__typename")? != Some("DraftIssue") {
7302            return Err(SourceError::Malformed {
7303                message: format!(
7304                    "GitHub answered {} as a draft and then as something else",
7305                    id.0
7306                ),
7307            });
7308        }
7309        if required_str(draft, "id")? != id.0 {
7310            return Err(SourceError::Malformed {
7311                message: format!("GitHub answered a different draft for {}", id.0),
7312            });
7313        }
7314        let memberships = draft
7315            .get("projectV2Items")
7316            .ok_or_else(|| SourceError::Malformed {
7317                message: format!("GitHub draft {} is missing projectV2Items", id.0),
7318            })?;
7319        let nodes = memberships
7320            .get("nodes")
7321            .and_then(Value::as_array)
7322            .ok_or_else(|| SourceError::Malformed {
7323                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
7324            })?;
7325        let info = memberships
7326            .get("pageInfo")
7327            .ok_or_else(|| SourceError::Malformed {
7328                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
7329            })?;
7330        // Read whether or not this board's entry is on the page: a page claiming more than
7331        // the one item GitHub links a draft to is a malformed answer either way.
7332        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
7333            return Err(SourceError::Malformed {
7334                message: format!(
7335                    "GitHub draft {} reports more board items than the one GitHub links a draft \
7336                     to",
7337                    id.0
7338                ),
7339            });
7340        }
7341        if let Some(node) = nodes.first()
7342            && node
7343                .pointer("/project/number")
7344                .and_then(Value::as_u64)
7345                .is_none()
7346        {
7347            return Err(SourceError::Malformed {
7348                message: format!(
7349                    "GitHub draft {} board item has no numeric project number",
7350                    id.0
7351                ),
7352            });
7353        }
7354        let Some(held) = self.board_entry(nodes) else {
7355            return Ok(None);
7356        };
7357        if required_str(
7358            held.get("project").ok_or_else(|| SourceError::Malformed {
7359                message: format!("GitHub draft {} board item has no project", id.0),
7360            })?,
7361            "id",
7362        )? != self.board_fields().await?.id.as_str()
7363        {
7364            return Ok(None);
7365        }
7366        let item = json!({
7367            "id": required_str(held, "id")?,
7368            "project": held.get("project"),
7369            "fieldValues": held.get("fieldValues"),
7370            "content": draft,
7371        });
7372        self.resolve(&item)
7373    }
7374
7375    /// The board's own id and field definitions, for a write whose item does not carry
7376    /// them — never its items.
7377    ///
7378    /// A board this command has already listed supplies them, since it read them beside its
7379    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
7380    /// is consulted about which items the board holds: see the module documentation for
7381    /// why a question about one known item is answered by reading that item.
7382    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
7383        if let Some(board) = self.board_cache()?.as_ref() {
7384            return Ok(BoardFields {
7385                id: BoardId::parse(&board.id)?,
7386                fields: board.fields.clone(),
7387            });
7388        }
7389        if let Some(held) = self.fields_cache()?.clone() {
7390            return Ok(held);
7391        }
7392        let data = self
7393            .graphql(
7394                graphql::BOARD_FIELDS,
7395                json!({"owner":self.owner,"number":self.project_number,
7396                       "nestedFirst":NESTED_PAGE_SIZE}),
7397            )
7398            .await?;
7399        self.fields_read(&data)
7400    }
7401
7402    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
7403    /// the rest of this command.
7404    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
7405        let board = data
7406            .pointer("/boardFields/projectV2")
7407            .filter(|value| !value.is_null())
7408            .ok_or_else(|| SourceError::Refused {
7409                message: format!(
7410                    "GitHub project {}/{} was not found or is not visible to the token",
7411                    self.owner, self.project_number
7412                ),
7413            })?;
7414        let read = BoardFields {
7415            id: BoardId::parse(required_str(board, "id")?)?,
7416            fields: board.get("fields").cloned().unwrap_or(Value::Null),
7417        };
7418        *self.fields_cache()? = Some(read.clone());
7419        Ok(read)
7420    }
7421
7422    /// Read what creating an issue in `repository` needs and this command has not read yet —
7423    /// the board's fields and the repository's node id — in one request when it needs both.
7424    ///
7425    /// When either is already known this sends nothing, and the other is read by its own
7426    /// document where it is asked for, so no create reads anything twice.
7427    async fn creation_context(
7428        &self,
7429        repository: &RepositoryTarget,
7430        incoming: &Incoming<'_>,
7431    ) -> Result<(), SourceError> {
7432        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
7433        if fields_known || self.repository_cache()?.contains_key(repository) {
7434            return Ok(());
7435        }
7436        // A placement an earlier process kept is taken only while the project issue this create
7437        // has just read shows its setup-context token unchanged; see `projection`.
7438        if self.placed_by_kept(repository, incoming.parent) {
7439            return Ok(());
7440        }
7441        let data = self
7442            .graphql(
7443                graphql::CREATION_CONTEXT,
7444                json!({"owner":self.owner,"number":self.project_number,
7445                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
7446                       "repositoryName":repository.name}),
7447            )
7448            .await?;
7449        self.fields_read(&data)?;
7450        self.repository_read(&data, repository, incoming)?;
7451        Ok(())
7452    }
7453
7454    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
7455    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
7456        self.fields_cache
7457            .lock()
7458            .map_err(|_| SourceError::Unavailable {
7459                message: "this source's view of the board's fields was left inconsistent by an \
7460                      earlier failure; next: run the command again"
7461                    .into(),
7462            })
7463    }
7464
7465    /// What a write to `item` needs of the board, read off that item when it says enough and
7466    /// off [`Self::board_fields`] when it does not.
7467    ///
7468    /// A node read of an item names its board and carries the definition of every field it
7469    /// holds a value of — so an item naming its board, holding a value of the origin field
7470    /// when the write carries an origin, and, when the write carries a status, holding a
7471    /// `Status` value, needs no read of the board at all. **Nothing the item does not say is
7472    /// guessed:** a field it holds no value of may still be on the board, and a view reading it
7473    /// as absent would refuse a write the board can take or skip a field write the board
7474    /// needs, so such an item — and a create, which has no item yet — takes the board's fields
7475    /// from their own read instead. A write carrying no origin to an item holding none — every
7476    /// write out of a source declared private — writes no origin whatever the board defines,
7477    /// so it needs no definition of that field.
7478    async fn fields_for(
7479        &self,
7480        item: Option<&Resolved>,
7481        writes_origin: bool,
7482        writes_status: bool,
7483        selects_priority: bool,
7484    ) -> Result<BoardFields, SourceError> {
7485        if let Some(board) = item.and_then(Resolved::carried_board) {
7486            return Ok(board);
7487        }
7488        if let Some(item) = item
7489            && let Some(board_id) = item.named_board()
7490            && (!writes_origin || item.defines(ORIGIN_FIELD))
7491            && (!writes_status || item.defines("Status"))
7492            && (!selects_priority || item.defines(PRIORITY_FIELD))
7493            && self
7494                .metadata_fields
7495                .iter()
7496                .all(|projection| item.defines(&projection.field))
7497        {
7498            return Ok(BoardFields {
7499                id: board_id,
7500                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7501            });
7502        }
7503        self.board_fields().await
7504    }
7505
7506    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
7507    /// when that id names nothing here with a sub-issue relationship to walk.
7508    ///
7509    /// `None` and an empty answer are different: `None` is *this is not an issue of this
7510    /// GitHub*, which is what sends a project selector on to be read as a name, and an
7511    /// empty vector is a project that holds nothing.
7512    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7513        let mut after: Option<String> = None;
7514        let mut children = Vec::new();
7515        // The project issue as the first page carried it, held once the walk has ended.
7516        let mut parent: Option<Resolved> = None;
7517        loop {
7518            let asked = self
7519                .graphql(
7520                    graphql::SUB_ISSUES,
7521                    json!({"id":id.0,"memberFirst":MEMBER_PAGE_SIZE,"after":after,
7522                           "nestedFirst":NESTED_PAGE_SIZE,
7523                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
7524                )
7525                .await;
7526            let data = match asked {
7527                Ok(data) => data,
7528                // A string that is not a node id at all is not a failure to report: it is
7529                // the ordinary answer to a selector naming a project by its name.
7530                Err(error) if unresolvable_node(&error) => return Ok(None),
7531                Err(error) => return Err(error),
7532            };
7533            let Some(connection) = data
7534                .pointer("/node/subIssues")
7535                .filter(|value| !value.is_null())
7536            else {
7537                // No such node, or one with no sub-issue relationship — a board draft is
7538                // the one this board can really hold.
7539                return Ok(None);
7540            };
7541            if after.is_none()
7542                && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
7543            {
7544                parent = self
7545                    .resolve_issue(node)
7546                    .await?
7547                    .filter(|resolved| resolved.id == *id);
7548            }
7549            for node in connection
7550                .get("nodes")
7551                .and_then(Value::as_array)
7552                .ok_or_else(|| SourceError::Malformed {
7553                    message: "GitHub subIssues.nodes is not an array".into(),
7554                })?
7555            {
7556                if let Some(resolved) = self.resolve_issue(node).await? {
7557                    children.push(resolved);
7558                }
7559            }
7560            let info = connection
7561                .get("pageInfo")
7562                .ok_or_else(|| SourceError::Malformed {
7563                    message: "GitHub subIssues connection has no pageInfo".into(),
7564                })?;
7565            let next = required_bool(info, "hasNextPage")?
7566                .then(|| required_str(info, "endCursor"))
7567                .transpose()?;
7568            match next {
7569                Some(next) => {
7570                    validate_cursor_progress(after.as_deref(), next)?;
7571                    after = Some(next.to_owned());
7572                }
7573                None => {
7574                    // Held only once the walk has ended, so one failing part way holds
7575                    // nothing and every read it would have answered is made.
7576                    // The project issue the first page carried answers the next read of the
7577                    // project by its id in this command, as a read of it made now would: what
7578                    // classifying a project for a write reads straight after its members.
7579                    if let Some(parent) = &parent {
7580                        self.read_ahead()?
7581                            .entry(parent.id.clone())
7582                            .or_insert_with(|| Some(parent.clone()));
7583                    }
7584                    let mut carried = self.carried()?;
7585                    for item in parent.into_iter().chain(children.iter().cloned()) {
7586                        carried.insert(item.id.clone(), item);
7587                    }
7588                    return Ok(Some(children));
7589                }
7590            }
7591        }
7592    }
7593
7594    fn carried(
7595        &self,
7596    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
7597        self.carried.lock().map_err(|_| SourceError::Unavailable {
7598            message: "items a project's listing carried were left inconsistent; run the \
7599                      command again"
7600                .into(),
7601        })
7602    }
7603
7604    /// Which issue of this board a project *name* is, or `None` when none is.
7605    ///
7606    /// One bounded query which filters on that name at the server, rather than a walk of
7607    /// every issue the board holds. The name is compared again here: the qualifier narrows
7608    /// what GitHub sends, and this source decides what it names.
7609    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
7610        let search = self.board_search(Some(&title_qualifier(name)));
7611        let mut after = None;
7612        loop {
7613            let (candidates, next) = self
7614                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7615                .await?;
7616            if let Some(item) = candidates.into_iter().find(|item| {
7617                item.kind == BoardKind::Work(ItemKind::Project)
7618                    && item.title.eq_ignore_ascii_case(name)
7619            }) {
7620                return Ok(Some(item.id));
7621            }
7622            match next {
7623                Some(next) => after = Some(next),
7624                None => return Ok(None),
7625            }
7626        }
7627    }
7628
7629    /// Everything filed under one project of this board: the sub-issues of the issue that
7630    /// project is.
7631    ///
7632    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
7633    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
7634    /// gains projects, or as another project gains tasks.
7635    ///
7636    /// A qualified id names the issue and is asked for its sub-issues directly: one
7637    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
7638    /// read as a project *name*, which costs the one bounded search
7639    /// [`Self::project_by_name`] makes.
7640    ///
7641    /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
7642    /// — and each answer is still cut to the items whose parent is this project, so one this
7643    /// process has since filed elsewhere is not reported here, and completed with what this
7644    /// process filed under it.
7645    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
7646        let held = self.children_cache()?.get(selector).cloned();
7647        let (project, children) = match held {
7648            Some(held) => held,
7649            None => {
7650                let answered = match self.sub_issues(selector).await? {
7651                    Some(children) => (selector.clone(), children),
7652                    None => match self.project_by_name(&selector.0).await? {
7653                        Some(project) => {
7654                            let children = self.sub_issues(&project).await?.unwrap_or_default();
7655                            (project, children)
7656                        }
7657                        None => return Ok(Vec::new()),
7658                    },
7659                };
7660                self.children_cache()?
7661                    .insert(selector.clone(), answered.clone());
7662                answered
7663            }
7664        };
7665        let mut children: Vec<Resolved> = children
7666            .into_iter()
7667            .filter(|child| child.parent.as_ref() == Some(&project))
7668            .collect();
7669        for own in self.updated()?.iter() {
7670            if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
7671                children.push(own.clone());
7672            }
7673        }
7674        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
7675    }
7676
7677    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
7678    /// completed with what this run wrote — the candidates a comment-activity read confirms.
7679    ///
7680    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
7681    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
7682    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
7683    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
7684    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
7685    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
7686    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
7687    /// rather than silently narrowing a caller's answer.
7688    ///
7689    /// The instant is written to the second, rounded down, which can only widen what the
7690    /// search returns; confirmation against each candidate's own comments is what makes the
7691    /// answer exact. The search is an index that lags a write by a second or two — the module
7692    /// documentation records it — so a caller that asks again from its last instant should
7693    /// overlap the two by more than that.
7694    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
7695        let found = self.searched(&updated_qualifier(since)).await?;
7696        self.completed_with_written(found, |_| true)
7697    }
7698
7699    /// Every issue of this board GitHub's issue search reports for the board-scoped search
7700    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
7701    ///
7702    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
7703    /// own record is the fresher of the two.
7704    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
7705        let search = self.board_search(Some(also));
7706        let mut after: Option<String> = None;
7707        let mut found = Vec::new();
7708        loop {
7709            let (page, next) = self
7710                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7711                .await?;
7712            found.extend(page);
7713            match next {
7714                Some(next) => after = Some(next),
7715                None => return Ok(found),
7716            }
7717        }
7718    }
7719
7720    /// A bounded task answer; the versioned cursor carries the connection position, how
7721    /// many rows of the page starting there were already handed out, and the own-write ids
7722    /// already observed, including across a new source instance.
7723    ///
7724    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
7725    /// sliced from the pages it needs; why is the module documentation's paging contract.
7726    async fn search_tasks(
7727        &self,
7728        query: &TaskQuery,
7729        page: &PageRequest,
7730        also: &str,
7731    ) -> Result<Page<Task>, SourceError> {
7732        let mut position = match &page.cursor {
7733            None => SearchPosition::default(),
7734            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
7735                .ok()
7736                .filter(|position| {
7737                    position.version == SEARCH_CURSOR_VERSION
7738                        && position.connection.valid_resume(position.offset)
7739                })
7740                .ok_or_else(|| SourceError::Config {
7741                    message: "page cursor is invalid".into(),
7742                })?,
7743        };
7744        let search = self.board_search(Some(also));
7745        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
7746        let own = self.with_own_writes(Vec::new())?;
7747        // An issue this process commented on is a candidate of a comment-activity read
7748        // whether or not the search has caught up with the comment; see `Self::commented`.
7749        let commented = match query.commented_since {
7750            Some(_) => self.commented()?.clone(),
7751            None => Vec::new(),
7752        };
7753        for id in own.iter().map(|item| &item.id).chain(&commented) {
7754            if !position.own.contains(id) {
7755                position.own.push(id.clone());
7756            }
7757        }
7758        let mut tasks = Vec::new();
7759        while !position.connection.exhausted() && tasks.len() < limit {
7760            let first = SEARCH_PAGE_SIZE;
7761            // Page size is part of the key: a short cached answer cannot answer a wider ask.
7762            let key =
7763                serde_json::to_string(&("page", &search, &position.connection.after(), first))
7764                    .expect("search page key is serializable");
7765            let cached = if query.commented_since.is_none() {
7766                self.narrowed_cache()?.get(&key).cloned()
7767            } else {
7768                None
7769            };
7770            let (found, next) = match cached {
7771                Some(found) => {
7772                    let next = self
7773                        .search_next
7774                        .lock()
7775                        .map_err(|_| SourceError::Unavailable {
7776                            message:
7777                                "search pagination was left inconsistent; run the command again"
7778                                    .into(),
7779                        })?
7780                        .get(&key)
7781                        .cloned()
7782                        .flatten();
7783                    (found, next)
7784                }
7785                None => {
7786                    let (found, next) = self
7787                        .search_page(&search, first, position.connection.after())
7788                        .await?;
7789                    if query.commented_since.is_none() {
7790                        self.search_next
7791                            .lock()
7792                            .map_err(|_| SourceError::Unavailable {
7793                                message:
7794                                    "search pagination was left inconsistent; run the command again"
7795                                        .into(),
7796                            })?
7797                            .insert(key.clone(), next.clone());
7798                        self.narrowed_cache()?.insert(key, found.clone());
7799                    }
7800                    (found, next)
7801                }
7802            };
7803            let rows = found.len();
7804            // Every candidate of the page this read confirms by its comments is read together.
7805            let candidates =
7806                self.comment_candidates(found.iter().skip(position.offset), query, &query.project)?;
7807            self.hold_comment_pages(&candidates).await?;
7808            for mut item in found.into_iter().skip(position.offset) {
7809                if tasks.len() == limit {
7810                    break;
7811                }
7812                position.offset += 1;
7813                if position.own.contains(&item.id) {
7814                    if position.seen.contains(&item.id) {
7815                        continue;
7816                    }
7817                    position.seen.push(item.id.clone());
7818                    // The search's own copy of an issue this process only commented on is as
7819                    // good as a node read of it, since its comments are read either way.
7820                    let only_commented = commented.contains(&item.id)
7821                        && !own.iter().any(|written| written.id == item.id);
7822                    if !only_commented {
7823                        let updated_at = item.updated_at;
7824                        let Some(written) = self.search_written(&own, &item.id).await? else {
7825                            continue;
7826                        };
7827                        item = written;
7828                        item.updated_at = item.updated_at.max(updated_at);
7829                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
7830                    }
7831                }
7832                if item.kind == BoardKind::Work(ItemKind::Task) {
7833                    let task = item.task()?;
7834                    if task_matches(&task, query, &query.project)
7835                        && self.commented_since(&item, query.commented_since).await?
7836                    {
7837                        tasks.push(task);
7838                    }
7839                }
7840            }
7841            if position.offset < rows {
7842                continue;
7843            }
7844            position.offset = 0;
7845            position.connection = match next {
7846                Some(after) => SearchConnection::Continuing {
7847                    after: Cursor(after),
7848                },
7849                None => SearchConnection::Exhausted {},
7850            };
7851        }
7852        if position.connection.exhausted() {
7853            for id in position.own.clone() {
7854                if position.seen.contains(&id) {
7855                    continue;
7856                }
7857                if tasks.len() == limit {
7858                    break;
7859                }
7860                position.seen.push(id.clone());
7861                let Some(item) = self.search_written(&own, &id).await? else {
7862                    continue;
7863                };
7864                if item.kind == BoardKind::Work(ItemKind::Task) {
7865                    let task = item.task()?;
7866                    if task_matches(&task, query, &query.project)
7867                        && self.commented_since(&item, query.commented_since).await?
7868                    {
7869                        tasks.push(task);
7870                    }
7871                }
7872            }
7873        }
7874        let more = !position.connection.exhausted()
7875            || position.own.iter().any(|id| !position.seen.contains(id));
7876        Ok(Page {
7877            items: tasks,
7878            next: more.then(|| {
7879                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
7880            }),
7881        })
7882    }
7883
7884    /// A resumed process has the ids but no write records; resolve only a record the
7885    /// current page needs, by its uncached node read rather than the lagging search index.
7886    async fn search_written(
7887        &self,
7888        own: &[Resolved],
7889        id: &NativeId,
7890    ) -> Result<Option<Resolved>, SourceError> {
7891        match own.iter().find(|item| item.id == *id) {
7892            Some(item) => Ok(Some(item.clone())),
7893            None => self.item_by_id(id).await,
7894        }
7895    }
7896
7897    /// The candidates for a task query carrying a text, metadata or origin predicate, read
7898    /// without enumerating the board — or `None` for a query carrying none of the three, which
7899    /// keeps the reads it always had.
7900    ///
7901    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
7902    /// because it names at most a handful of items. Text and metadata are answered by one
7903    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
7904    /// further by `updated:>=` when the query also asks for comment activity, since both
7905    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
7906    /// process afterwards by the same predicates [`task_matches`] applies to every read.
7907    ///
7908    /// Completed with what this process wrote, its own record winning over the index's copy
7909    /// of the same item: see [`Self::with_own_writes`].
7910    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
7911        self.send_pending_origins(None).await?;
7912        let asked = match (&query.origin, narrowing_qualifiers(query)) {
7913            (Some(origin), _) => Narrowing::Origin(origin.clone()),
7914            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
7915                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
7916                None => qualifiers,
7917            }),
7918            (None, None) => return Ok(None),
7919        };
7920        // A question about comment activity is asked afresh every time, as it always was: it
7921        // is the one a caller polls from one source while waiting for the index, and an
7922        // answer held from the first poll would be the answer to every later one.
7923        let key = query.commented_since.is_none().then(|| asked.key());
7924        let cached = match &key {
7925            Some(key) => self.narrowed_cache()?.get(key).cloned(),
7926            None => None,
7927        };
7928        let found = match cached {
7929            Some(found) => found,
7930            None => {
7931                let found = match &asked {
7932                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
7933                    Narrowing::Search(also) => self.searched(also).await?,
7934                };
7935                if let Some(key) = key {
7936                    self.narrowed_cache()?.insert(key, found.clone());
7937                }
7938                found
7939            }
7940        };
7941        self.with_own_writes(found).map(Some)
7942    }
7943
7944    /// The candidates for a project or unscoped document query carrying a searchable text,
7945    /// read without enumerating the board — or `None` for a query with no text or a blank one,
7946    /// which keeps the read it always had.
7947    ///
7948    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
7949    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
7950    /// costs is the issues that match and never the board. Its answer is held for the command
7951    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
7952    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
7953    /// substring rule, exactly as an item of the wider read was, and is completed with what this
7954    /// process wrote: see [`Self::with_own_writes`].
7955    async fn text_searched(
7956        &self,
7957        text: Option<&TextQuery>,
7958    ) -> Result<Option<Vec<Resolved>>, SourceError> {
7959        let Some(also) = text_qualifiers(text) else {
7960            return Ok(None);
7961        };
7962        let key = Narrowing::Search(also.clone()).key();
7963        let cached = self.narrowed_cache()?.get(&key).cloned();
7964        let found = match cached {
7965            Some(found) => found,
7966            None => {
7967                let found = self.searched(&also).await?;
7968                self.narrowed_cache()?.insert(key, found.clone());
7969                found
7970            }
7971        };
7972        self.with_own_writes(found).map(Some)
7973    }
7974
7975    /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
7976    /// [`Self::narrowed`] reads for a task query carrying that origin — one
7977    /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
7978    /// what this process wrote — each confirmed against its own origin entry.
7979    ///
7980    /// What answers a project or document listing by origin, so finding every copy of one plan
7981    /// costs the items that carry its origin and never a page of the board.
7982    async fn copied_from(
7983        &self,
7984        origin: &str,
7985        kind: BoardKind,
7986    ) -> Result<Vec<Resolved>, SourceError> {
7987        let wanted = TaskQuery {
7988            origin: Some(origin.to_owned()),
7989            ..TaskQuery::default()
7990        };
7991        Ok(self
7992            .narrowed(&wanted)
7993            .await?
7994            .unwrap_or_default()
7995            .into_iter()
7996            .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
7997            .collect())
7998    }
7999
8000    /// Every item of this board that may carry `origin` — a superset of those that do — found
8001    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
8002    ///
8003    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
8004    /// which reads the field every carrier holds, whichever release wrote it — and the
8005    /// board-scoped issue search for the same id as a phrase in the body, where this source
8006    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
8007    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
8008    /// query's, exactly.
8009    ///
8010    /// Both connections are walked to exhaustion, each from its own cursor. One that has
8011    /// already ended is sent its last cursor again, which answers an empty page, so the one
8012    /// document serves every page of either. What the two leave is stated in the module
8013    /// documentation: a carrier another process added within the last second or two, before
8014    /// either index has it.
8015    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
8016        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
8017        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
8018        let mut items_after: Option<String> = None;
8019        let mut search_after: Option<String> = None;
8020        let mut found: Vec<Resolved> = Vec::new();
8021        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
8022            if !found.iter().any(|held| held.id == resolved.id) {
8023                found.push(resolved);
8024            }
8025        };
8026        loop {
8027            let data = self
8028                .graphql(
8029                    graphql::ORIGIN_LOOKUP,
8030                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
8031                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
8032                           "itemsAfter":items_after,"searchAfter":search_after,
8033                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
8034                           "duplicates":true}),
8035                )
8036                .await?;
8037            let items = data
8038                .pointer("/originItems/projectV2/items")
8039                .filter(|value| !value.is_null())
8040                .ok_or_else(|| SourceError::Refused {
8041                    message: format!(
8042                        "GitHub project {}/{} was not found or is not visible to the token",
8043                        self.owner, self.project_number
8044                    ),
8045                })?;
8046            for item in optional_nodes(Some(items), "project items")?
8047                .into_iter()
8048                .flatten()
8049            {
8050                // The board's own items list its drafts too, and a draft is not an issue: no
8051                // narrowed read answers with one, whatever its origin field holds.
8052                if let Some(resolved) = self.resolve(item)?
8053                    && resolved.content_kind == ContentKind::Issue
8054                {
8055                    keep(resolved, &mut found);
8056                }
8057            }
8058            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
8059                message: "GitHub search response has no search connection".into(),
8060            })?;
8061            for node in optional_nodes(Some(searched), "search")?
8062                .into_iter()
8063                .flatten()
8064            {
8065                if let Some(resolved) = self.resolve_issue(node).await? {
8066                    keep(resolved, &mut found);
8067                }
8068            }
8069            let items_next = resumed(items, items_after.as_deref())?;
8070            let search_next = resumed(searched, search_after.as_deref())?;
8071            if !items_next.has_more() && !search_next.has_more() {
8072                return Ok(found);
8073            }
8074            items_after = items_next.cursor();
8075            search_after = search_next.cursor();
8076        }
8077    }
8078
8079    /// `found`, with every item this process created or wrote in its place, and every one of
8080    /// them the read did not report added.
8081    ///
8082    /// This process's own record wins over the read's copy of the same item, because a read
8083    /// of an item written moments ago can still be behind what was written onto it — the
8084    /// origin field included, which is the one a narrowed read is confirmed against — and a
8085    /// read that still names an item under a predicate this process's write moved it out of
8086    /// must not return it. The one thing the read knows that the record cannot is when GitHub
8087    /// last saw the item change, which is what a comment-activity read rules a candidate out
8088    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
8089    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
8090    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
8091        // A board draft is not an issue, so no narrowed read returns one, and this process
8092        // having written one does not make it an answer either.
8093        let own: Vec<Resolved> = self
8094            .created()?
8095            .iter()
8096            .chain(self.updated()?.iter())
8097            .filter(|own| own.content_kind == ContentKind::Issue)
8098            .cloned()
8099            .collect();
8100        for mut own in own {
8101            self.resolved_cache()?.insert(own.id.clone(), own.clone());
8102            match found.iter_mut().find(|read| read.id == own.id) {
8103                Some(read) => {
8104                    own.updated_at = own.updated_at.max(read.updated_at);
8105                    *read = own;
8106                }
8107                None => found.push(own),
8108            }
8109        }
8110        Ok(found)
8111    }
8112
8113    /// Whether `item` has a comment created or last edited at or after `since` — always, when
8114    /// there is no instant to hold it to.
8115    ///
8116    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
8117    /// or after the instant moved it there: an issue not updated since holds no such comment,
8118    /// and its comments are never asked for — unless this process commented on it in this
8119    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
8120    /// only as far as the first that matches. A board draft is not an issue and has no
8121    /// comments, so it never matches.
8122    async fn commented_since(
8123        &self,
8124        item: &Resolved,
8125        since: Option<DateTime<Utc>>,
8126    ) -> Result<bool, SourceError> {
8127        let Some(since) = since else {
8128            return Ok(true);
8129        };
8130        if item.content_kind == ContentKind::DraftIssue {
8131            return Ok(false);
8132        }
8133        // An `updatedAt` this process's own record or a lagging index holds can predate a
8134        // comment this process wrote since, so only an issue it did not comment on is ruled
8135        // out by one.
8136        if item.updated_at.is_some_and(|updated| updated < since)
8137            && !self.commented()?.contains(&item.id)
8138        {
8139            return Ok(false);
8140        }
8141        let query = TaskQuery {
8142            commented_since: Some(since),
8143            ..TaskQuery::default()
8144        };
8145        let mut after: Option<String> = None;
8146        // A first page read beside its fellow candidates answers as this read's own would.
8147        let held = self.held_comments()?.get(&item.id).cloned();
8148        if let Some(first) = held {
8149            if query.comments_match(&first.items) {
8150                return Ok(true);
8151            }
8152            match first.next {
8153                Some(next) => after = Some(next.0),
8154                None => return Ok(false),
8155            }
8156        }
8157        loop {
8158            let data = self
8159                .graphql(
8160                    graphql::ISSUE_COMMENTS,
8161                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
8162                )
8163                .await?;
8164            let Some(connection) = data
8165                .get("node")
8166                .filter(|value| !value.is_null())
8167                .and_then(|node| node.get("comments"))
8168                .filter(|value| !value.is_null())
8169            else {
8170                // Removed since the search reported it: no longer an issue with comments.
8171                return Ok(false);
8172            };
8173            let comments = optional_nodes(Some(connection), "issue comments")?
8174                .into_iter()
8175                .flatten()
8176                .map(comment_from)
8177                .collect::<Result<Vec<_>, _>>()?;
8178            if query.comments_match(&comments) {
8179                return Ok(true);
8180            }
8181            match next_cursor(connection)? {
8182                Some(next) => {
8183                    validate_cursor_progress(after.as_deref(), &next.0)?;
8184                    after = Some(next.0);
8185                }
8186                None => return Ok(false),
8187            }
8188        }
8189    }
8190
8191    /// Every item on the board: the union of both enumerations GitHub offers of one.
8192    ///
8193    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
8194    /// board **draft** and reads the board's own fields beside its items, and only the search
8195    /// reports an item that connection is behind on. The module documentation is where the lag and the
8196    /// measurements behind it are written down.
8197    ///
8198    /// A search result is admitted on the same terms as any other issue this source reaches
8199    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
8200    /// names *this* board — so an issue the index still believes is here after it was taken
8201    /// off is refused rather than reported.
8202    ///
8203    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
8204    /// which is what the cache could otherwise have broken.
8205    async fn board(&self) -> Result<Board, SourceError> {
8206        self.board_completed_by(None).await
8207    }
8208
8209    /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
8210    /// that half could add is then answered only if it holds.
8211    async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
8212        let cached = self.board_cache()?.clone();
8213        let mut board = match cached {
8214            Some(board) => board,
8215            None => {
8216                let read = self.read_board().await?;
8217                *self.board_cache()? = Some(read.clone());
8218                read
8219            }
8220        };
8221        let searched = match also {
8222            None => self.searched_issues().await?,
8223            Some(also) => self.searched_narrowly(also).await?,
8224        };
8225        // A complete sweep asks for every page of this view. Reconcile ids once per
8226        // view, rather than scanning the board for every search result on every page.
8227        let mut ids: std::collections::HashSet<_> =
8228            board.items.iter().map(|item| item.id.clone()).collect();
8229        for held in searched {
8230            if ids.insert(held.id.clone()) {
8231                board.items.push(held);
8232            }
8233        }
8234        for own in self.created()?.iter() {
8235            if ids.insert(own.id.clone()) {
8236                board.items.push(own.clone());
8237            }
8238        }
8239        Ok(board)
8240    }
8241
8242    /// This process's own view of the board, or the refusal a poisoned lock is.
8243    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
8244        self.board_cache
8245            .lock()
8246            .map_err(|_| SourceError::Unavailable {
8247                message: "this source's view of the board was left inconsistent by an earlier \
8248                      failure; next: run the command again"
8249                    .into(),
8250            })
8251    }
8252
8253    /// Bring this process's own view of the board up to an item it has just written.
8254    ///
8255    /// A created item goes to `created`, which is what completes a board read GitHub's own
8256    /// eventual consistency has left behind. An item that was already there is replaced
8257    /// where it sits, so a second write of it in the same command reads its real parent
8258    /// rather than the one it had before the first write.
8259    ///
8260    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
8261    /// that wins: an item this same run created is held in `created` and not in the cached
8262    /// board, and `board` completes the cached board *from* `created`, so replacing only
8263    /// the cached copy of such an item replaces nothing and the read still reports the
8264    /// title it was created with. The search is the third, and it is the one an item the
8265    /// board's own projection is behind on sits in *alone* — which is exactly the item this
8266    /// source is least able to re-read, so leaving it out would put the stale title back on
8267    /// the only items the completion in [`Self::board`] exists for.
8268    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
8269        self.resolved_cache()?.insert(item.id.clone(), item.clone());
8270        if created {
8271            self.created()?.push(item);
8272            return Ok(());
8273        }
8274        {
8275            let mut own = self.created()?;
8276            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
8277                *held = item;
8278                return Ok(());
8279            }
8280        }
8281        {
8282            let mut own = self.updated()?;
8283            match own.iter_mut().find(|held| held.id == item.id) {
8284                Some(held) => *held = item.clone(),
8285                None => own.push(item.clone()),
8286            }
8287        }
8288        if let Some(board) = self.board_cache()?.as_mut()
8289            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
8290        {
8291            *held = item.clone();
8292        }
8293        if let Some(found) = self.search_cache()?.as_mut()
8294            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
8295        {
8296            *held = item.clone();
8297        }
8298        for found in self.narrowed_cache()?.values_mut() {
8299            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8300                *held = item.clone();
8301            }
8302        }
8303        for (_, found) in self.children_cache()?.values_mut() {
8304            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8305                *held = item.clone();
8306            }
8307        }
8308        Ok(())
8309    }
8310
8311    /// Forget one item this process has just deleted, from every half of its own view.
8312    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
8313        if let Some(Deferred::Held { created, .. }) = self.deferred()?.as_mut() {
8314            created.retain(|held| held.content != *id);
8315        }
8316        self.resolved_cache()?.remove(id);
8317        self.read_ahead()?.remove(id);
8318        self.carried()?.remove(id);
8319        self.created()?.retain(|own| own.id != *id);
8320        self.updated()?.retain(|own| own.id != *id);
8321        self.commented()?.retain(|own| own != id);
8322        if let Some(board) = self.board_cache()?.as_mut() {
8323            board.items.retain(|item| item.id != *id);
8324        }
8325        if let Some(found) = self.search_cache()?.as_mut() {
8326            found.retain(|item| item.id != *id);
8327        }
8328        for found in self.narrowed_cache()?.values_mut() {
8329            found.retain(|item| item.id != *id);
8330        }
8331        for (_, found) in self.children_cache()?.values_mut() {
8332            found.retain(|item| item.id != *id);
8333        }
8334        Ok(())
8335    }
8336
8337    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
8338    fn narrowed_cache(
8339        &self,
8340    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
8341        self.narrowed_cache
8342            .lock()
8343            .map_err(|_| SourceError::Unavailable {
8344                message: "this source's view of a narrowed read was left inconsistent by an \
8345                      earlier failure; next: run the command again"
8346                    .into(),
8347            })
8348    }
8349
8350    /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
8351    /// is.
8352    /// The tasks `project` holds, as a copy's batched read found them this command, when it did.
8353    fn held_task_children(&self, project: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
8354        Ok(self.task_children()?.get(project).cloned())
8355    }
8356
8357    fn task_children(
8358        &self,
8359    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Vec<Resolved>>>, SourceError> {
8360        self.task_children
8361            .lock()
8362            .map_err(|_| SourceError::Unavailable {
8363                message: "this source's record of a project's tasks was left inconsistent by an \
8364                          earlier failure; next: run the command again"
8365                    .into(),
8366            })
8367    }
8368
8369    fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
8370        self.children_cache
8371            .lock()
8372            .map_err(|_| SourceError::Unavailable {
8373                message: "this source's view of a project's tasks was left inconsistent by an \
8374                      earlier failure; next: run the command again"
8375                    .into(),
8376            })
8377    }
8378
8379    /// Every page of the board, read from GitHub.
8380    async fn read_board(&self) -> Result<Board, SourceError> {
8381        let mut after: Option<String> = None;
8382        let mut items = Vec::new();
8383        let mut board;
8384        loop {
8385            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
8386            for item in page
8387                .pointer("/items/nodes")
8388                .and_then(Value::as_array)
8389                .ok_or_else(|| SourceError::Malformed {
8390                    message: "GitHub project items.nodes is not an array".into(),
8391                })?
8392            {
8393                if let Some(resolved) = self.resolve(item)? {
8394                    items.push(resolved);
8395                }
8396            }
8397            let info = page
8398                .pointer("/items/pageInfo")
8399                .ok_or_else(|| SourceError::Malformed {
8400                    message: "GitHub project items have no pageInfo".into(),
8401                })?;
8402            let has_next = required_bool(info, "hasNextPage")?;
8403            let next = has_next
8404                .then(|| required_str(info, "endCursor"))
8405                .transpose()?;
8406            board = page.clone();
8407            match next {
8408                Some(next) => {
8409                    validate_cursor_progress(after.as_deref(), next)?;
8410                    after = Some(next.to_owned());
8411                }
8412                None => break,
8413            }
8414        }
8415        Ok(Board {
8416            id: required_str(&board, "id")?.to_owned(),
8417            fields: board.get("fields").cloned().unwrap_or(Value::Null),
8418            items,
8419        })
8420    }
8421
8422    /// The existing items this source has written, for completing a narrowed read that is
8423    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
8424    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8425        self.updated.lock().map_err(|_| SourceError::Unavailable {
8426            message: "this source's record of what it wrote in this run was left inconsistent \
8427                      by an earlier failure; next: run the command again"
8428                .into(),
8429        })
8430    }
8431
8432    /// The issues this source has commented on in this command; see
8433    /// [`Self::commented`](GitHubProjectsSource::commented).
8434    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
8435        self.commented.lock().map_err(|_| SourceError::Unavailable {
8436            message: "this source's record of what it commented on in this run was left \
8437                      inconsistent by an earlier failure; next: run the command again"
8438                .into(),
8439        })
8440    }
8441
8442    /// Called only once GitHub has answered the comment write, so an issue whose comment
8443    /// failed is never made a candidate a later read would pay a node read for.
8444    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
8445        // A comment page held for this command is no longer what the issue holds.
8446        self.held_comments()?.remove(issue);
8447        let mut commented = self.commented()?;
8448        if !commented.contains(issue) {
8449            commented.push(issue.clone());
8450        }
8451        Ok(())
8452    }
8453
8454    /// The items this source has created, for completing a board read that is behind.
8455    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8456        self.created.lock().map_err(|_| SourceError::Unavailable {
8457            message: "this source's record of what it created in this run was left \
8458                      inconsistent by an earlier failure; next: run the command again"
8459                .into(),
8460        })
8461    }
8462
8463    /// One board item as this source reports it, or `None` for content it ignores.
8464    ///
8465    /// A pull request is neither a project nor a task — it is somebody's change, not a
8466    /// unit of plan — and an item whose content the token cannot see has nothing to
8467    /// report at all.
8468    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8469        let resolved = self.resolve_unremembered(item)?;
8470        if let Some(resolved) = &resolved {
8471            self.resolved_cache()?
8472                .insert(resolved.id.clone(), resolved.clone());
8473        }
8474        Ok(resolved)
8475    }
8476
8477    /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
8478    /// write's own response needs, which says nothing this process should answer a later read
8479    /// with.
8480    fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8481        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
8482            message: "GitHub project item is missing content".into(),
8483        })?;
8484        if content.is_null() {
8485            return Ok(None);
8486        }
8487        let content_kind = match required_str(content, "__typename")? {
8488            "Issue" => ContentKind::Issue,
8489            "DraftIssue" => ContentKind::DraftIssue,
8490            _ => return Ok(None),
8491        };
8492        let field_values = item
8493            .get("fieldValues")
8494            .ok_or_else(|| SourceError::Malformed {
8495                message: "GitHub project item is missing fieldValues".into(),
8496            })?;
8497        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
8498        let nodes = field_values
8499            .get("nodes")
8500            .and_then(Value::as_array)
8501            .ok_or_else(|| SourceError::Malformed {
8502                message: "GitHub project item fieldValues.nodes is not an array".into(),
8503            })?;
8504        if let Some(labels) = content.get("labels") {
8505            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
8506        }
8507        let raw_body = optional_str(content, "body")?.map(str::to_owned);
8508        let (body, slot) = self.visible(raw_body.clone())?;
8509        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
8510            .map(|id| NativeId(id.to_owned()));
8511        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
8512        // to read one from; it is a task, and never a project.
8513        let sub_issues = match content_kind {
8514            ContentKind::Issue => sub_issue_total(content)?,
8515            ContentKind::DraftIssue => 0,
8516        };
8517        let content_id = required_str(content, "id")?;
8518        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8519            message: format!("GitHub issue {content_id}: {message}"),
8520        })?;
8521        let raw_title = required_str(content, "title")?;
8522        // Read as children, a sub-issue is filed under no project and nothing is a project: the
8523        // GitHub parent is the parent relation instead.
8524        let (kind, parent, issue_parent) = match self.sub_issues {
8525            SubIssues::Project => (
8526                BoardKind::of(raw_title, parent.is_some(), sub_issues, marked),
8527                parent,
8528                None,
8529            ),
8530            SubIssues::Parent => (
8531                if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
8532                    BoardKind::Document
8533                } else {
8534                    BoardKind::Work(ItemKind::Task)
8535                },
8536                None,
8537                parent,
8538            ),
8539        };
8540        // The title a person wrote, which for a document is the one without the prefix —
8541        // the same way `content` above is the body without this source's metadata slot.
8542        let title = match kind {
8543            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
8544            BoardKind::Work(_) => raw_title.to_owned(),
8545        };
8546        let own_repository = content
8547            .pointer("/repository/nameWithOwner")
8548            .and_then(Value::as_str)
8549            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
8550            .transpose()
8551            .map_err(|message| SourceError::Malformed { message })?;
8552        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
8553            Repository::from_metadata(&slot)
8554                .map_err(|message| SourceError::Malformed { message })?
8555        } else {
8556            own_repository.clone().into_iter().collect()
8557        };
8558        let classification = Classification::from_metadata(&slot)
8559            .map_err(|message| SourceError::Malformed { message })?;
8560        let id = NativeId(content_id.to_owned());
8561        // Read only for a task, because only a task has either list: a project or a
8562        // document holding one of these keys holds nothing this source reports, and the
8563        // keys are left out of its caller-visible metadata all the same.
8564        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
8565            let listed = |key: &str| {
8566                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
8567                    .map_err(|message| SourceError::Malformed { message })
8568            };
8569            (
8570                listed(TaskRef::DELIVERS_KEY)?,
8571                listed(TaskRef::DELIVERED_BY_KEY)?,
8572            )
8573        } else {
8574            (Vec::new(), Vec::new())
8575        };
8576        let (option, closed, reason) = Self::status_parts(nodes, content)?;
8577        let priority = self.held_priority(nodes)?;
8578        let item_labels = labels(content)?;
8579        // Present when the item was reached through its own issue, whose board entry
8580        // names the board; a read of the board's own items has the board already. An
8581        // empty id names nothing a field write could address, so it is read as absent and
8582        // the write goes back to reading the board.
8583        let board_id = item
8584            .pointer("/project/id")
8585            .and_then(Value::as_str)
8586            .filter(|id| !id.is_empty());
8587        let resolved = Resolved {
8588            item_id: required_str(item, "id")?.to_owned(),
8589            id,
8590            content_kind,
8591            kind,
8592            title,
8593            body: body.filter(|value| !value.is_empty()),
8594            raw_body,
8595            status: self
8596                .statuses
8597                .status(kind.status_kind(), option, closed, reason),
8598            option: option.map(str::to_owned),
8599            priority,
8600            closed,
8601            reason: closed.then(|| reason.map(str::to_owned)).flatten(),
8602            delivers,
8603            delivered_by,
8604            labels: item_labels.clone(),
8605            parent,
8606            issue_parent,
8607            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
8608            projected: self.held_projections(nodes)?,
8609            number: match content_kind {
8610                ContentKind::Issue => Some(issue_number(content)?),
8611                // A draft is filed in no repository, so nothing ever numbered it:
8612                // `DraftIssue` declares no `number` at all, exactly as it declares no
8613                // `subIssuesSummary` the branch above reads.
8614                ContentKind::DraftIssue => None,
8615            },
8616            url: optional_str(content, "url")?.map(str::to_owned),
8617            created_at: optional_time(content, "createdAt")?,
8618            updated_at: optional_time(content, "updatedAt")?,
8619            item_updated_at: optional_time(item, "updatedAt")?,
8620            comment_count: content
8621                .pointer("/commentCount/totalCount")
8622                .and_then(Value::as_u64),
8623            relationship_events: content
8624                .pointer("/relationshipEvents/totalCount")
8625                .and_then(Value::as_u64),
8626            // The issue node is stamped wherever it was read; a board item a read of the issue
8627            // put together from its memberships is not.
8628            served_at: match optional_time(content, SERVED_AT)? {
8629                Some(served) => Some(served),
8630                None => optional_time(item, SERVED_AT)?,
8631            },
8632            // Both connections were read to their end above — one reporting a further page is
8633            // refused — so these are every field value and every label the item holds. A
8634            // response that did not select the labels at all says nothing of them, and gives
8635            // no facets.
8636            facets: content.get("labels").is_some().then(|| {
8637                change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
8638            }),
8639            own_repository,
8640            repositories,
8641            classification,
8642            slot,
8643            board_id: board_id.map(str::to_owned),
8644            fields: field_definitions(nodes),
8645            board_fields: Self::carried_board_fields(content, board_id)?,
8646            blocked_by: carried_blocked_by(content)?,
8647            assignees: if self.assignees {
8648                Some(assignees::assignee_logins(content)?)
8649            } else {
8650                None
8651            },
8652        };
8653        Ok(Some(resolved))
8654    }
8655
8656    /// The field definitions of the board `board_id` names — the project this issue's own
8657    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
8658    /// `None` when the read carried none, carried no entry for that board, or the board item
8659    /// named no board, which a write then answers by reading the board's fields itself.
8660    ///
8661    /// Matched by the board's node id and never by its number alone: a project number is
8662    /// unique only within its owner, so another owner's board numbered alike can sit on the
8663    /// same page, and its field and option ids address nothing on this one.
8664    fn carried_board_fields(
8665        content: &Value,
8666        board_id: Option<&str>,
8667    ) -> Result<Option<Value>, SourceError> {
8668        let (Some(nodes), Some(board_id)) = (
8669            content.pointer("/boards/nodes").and_then(Value::as_array),
8670            board_id,
8671        ) else {
8672            return Ok(None);
8673        };
8674        let Some(board) = nodes.iter().find_map(|node| {
8675            let project = node.get("project")?;
8676            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
8677        }) else {
8678            return Ok(None);
8679        };
8680        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
8681            return Ok(None);
8682        };
8683        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
8684        Ok(Some(fields.clone()))
8685    }
8686
8687    /// What one board item's `Priority` field says, through this instance's mapping.
8688    ///
8689    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
8690    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
8691    /// option the mapping does not name is kept as itself — never read as a level or as
8692    /// `none` — for a read of the task to report by name.
8693    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
8694        let Some(mapping) = &self.priorities else {
8695            return Ok(HeldPriority::Read(Priority::None));
8696        };
8697        // A value of the field that names no option — a text field someone called `Priority` —
8698        // is malformed rather than `none`: reading it as no priority would let the next copy
8699        // clear one a person set.
8700        let Some(option) = field_values
8701            .iter()
8702            .find(|value| {
8703                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
8704            })
8705            .map(|value| required_str(value, "name"))
8706            .transpose()?
8707        else {
8708            return Ok(HeldPriority::Read(Priority::None));
8709        };
8710        Ok(mapping.priority_of(option).map_or_else(
8711            || HeldPriority::Unmapped(option.to_owned()),
8712            HeldPriority::Read,
8713        ))
8714    }
8715
8716    /// What each projected metadata field holds for one board item, by field name — every text
8717    /// value GitHub answers, an empty one included.
8718    fn held_projections(
8719        &self,
8720        field_values: &[Value],
8721    ) -> Result<BTreeMap<String, String>, SourceError> {
8722        let mut held = BTreeMap::new();
8723        for projection in &self.metadata_fields {
8724            if let Some(text) = text_field(field_values, &projection.field)? {
8725                held.insert(projection.field.to_string(), text);
8726            }
8727        }
8728        Ok(held)
8729    }
8730
8731    /// What one board item's status is read from: its `Status` option, whether its issue
8732    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
8733    /// three into the status it reports.
8734    fn status_parts<'a>(
8735        field_values: &'a [Value],
8736        content: &'a Value,
8737    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
8738        let option = field_values
8739            .iter()
8740            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
8741            .map(|value| required_str(value, "name"))
8742            .transpose()?;
8743        let closed = optional_str(content, "state")? == Some("CLOSED");
8744        Ok((option, closed, optional_str(content, "stateReason")?))
8745    }
8746
8747    /// The board Status option this write selects, or the refusal that says why not.
8748    ///
8749    /// The mapped option is required for both open and terminal targets. A terminal write
8750    /// validates it before changing either representation, so it can never fall back to
8751    /// closing an issue whose board cannot display the matching status.
8752    ///
8753    /// Answers the field's id, the option's id, and the option's name as the board spells
8754    /// it — which is the name a read of the item reports once it sits there.
8755    fn column_for(
8756        &self,
8757        fields: &Value,
8758        kind: ItemKind,
8759        category: StatusCategory,
8760        target: &StatusTarget,
8761    ) -> Result<Option<(String, String, String)>, SourceError> {
8762        let Some(wanted) = target.option() else {
8763            return Ok(None);
8764        };
8765        let missing = |detail: &str| SourceError::Refused {
8766            message: format!(
8767                "{} status {} of source {} needs the board Status option {wanted:?}, and \
8768                 {detail}; next: add that option to the board, which `onetaskgraph sources \
8769                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
8770                 it has",
8771                kind.marker(),
8772                category_name(category),
8773                self.name,
8774                self.name,
8775                category_name(category),
8776                kind.marker()
8777            ),
8778        };
8779        let Some(field) = Board::field(fields, "Status")? else {
8780            return Err(missing("this board has no Status field"));
8781        };
8782        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8783            return Err(missing(
8784                "this board's Status field is not a single-select field",
8785            ));
8786        }
8787        let option = field
8788            .get("options")
8789            .and_then(Value::as_array)
8790            .and_then(|options| {
8791                options.iter().find(|option| {
8792                    option
8793                        .get("name")
8794                        .and_then(Value::as_str)
8795                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8796                })
8797            });
8798        match option {
8799            None => Err(missing("this board does not have it")),
8800            Some(option) => Ok(Some((
8801                required_str(field, "id")?.to_owned(),
8802                required_str(option, "id")?.to_owned(),
8803                required_str(option, "name")?.to_owned(),
8804            ))),
8805        }
8806    }
8807
8808    /// The refusal a status that closes an issue is answered with over a board draft.
8809    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
8810        SourceError::Refused {
8811            message: format!(
8812                "status {} of source {} closes the item's issue, and GitHub draft items have \
8813                 no open or closed state",
8814                category_name(category),
8815                self.name
8816            ),
8817        }
8818    }
8819
8820    /// What a status write to one item needs of the board: the board's id and the
8821    /// definition of its `Status` field, read off the item when the item says both.
8822    ///
8823    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
8824    /// and its `Status` value carries that field's definition, options and all. An item that
8825    /// does not say — no board id, or no `Status` value to read the field off — takes them
8826    /// from [`Self::board_fields`], which reads no item.
8827    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8828        if let Some(board) = item.carried_board() {
8829            return Ok(board);
8830        }
8831        if item.defines("Status")
8832            && let Some(board_id) = item.named_board()
8833        {
8834            return Ok(BoardFields {
8835                id: board_id,
8836                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8837            });
8838        }
8839        self.board_fields().await
8840    }
8841
8842    /// Set one task's or one project's status and nothing else; see
8843    /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
8844    async fn set_status(
8845        &self,
8846        id: &NativeId,
8847        kind: ItemKind,
8848        category: StatusCategory,
8849    ) -> Result<Option<Status>, SourceError> {
8850        // Refused before anything is read, in the words a write of the same status is.
8851        let target = self.resolved_target(kind, category)?;
8852        // An item read by issue reference carries only what this write needs, so what it
8853        // becomes is never remembered for a later read to be answered from.
8854        let held = self.take_held(id)?;
8855        let by_reference = held.is_some();
8856        let bound = match held {
8857            Some(item) => Some(item),
8858            None => self.bound_item(id).await?,
8859        };
8860        let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
8861            return Ok(None);
8862        };
8863        let board = self.status_board(&item).await?;
8864        let (field, option, name) = self
8865            .column_for(&board.fields, kind, category, &target)?
8866            .ok_or_else(|| SourceError::Malformed {
8867                message: format!(
8868                    "status {} of source {} names no board Status option",
8869                    category_name(category),
8870                    self.name
8871                ),
8872            })?;
8873        if item.status.category == category && item.option.as_deref() == Some(&name) {
8874            return Ok(Some(item.status));
8875        }
8876        match &target {
8877            StatusTarget::Terminal(_, reason) => {
8878                if item.content_kind == ContentKind::DraftIssue {
8879                    return Err(self.closes_a_draft(category));
8880                }
8881                // The option first, so a refused selection leaves an open issue open rather
8882                // than closed behind a card that still reads open.
8883                self.set_item_field(
8884                    board.id.as_str(),
8885                    &item.item_id,
8886                    &field,
8887                    json!({"singleSelectOptionId": option}),
8888                )
8889                .await?;
8890                // An issue already closed in a way that reads as this category keeps its state
8891                // and its reason: only its card was out of line, and closing it again would
8892                // overwrite a reason — a duplicate's — that says more than this write does.
8893                let already = item.closed && item.status.category == category;
8894                if !already {
8895                    self.update_content(
8896                        ContentKind::Issue,
8897                        &item.id,
8898                        json!({"stateInput": state_input(Some(&target))}),
8899                    )
8900                    .await?;
8901                }
8902                let reason = if already {
8903                    item.reason.clone()
8904                } else {
8905                    Some(reason.reason().to_owned())
8906                };
8907                item.closed = true;
8908                item.status = self
8909                    .statuses
8910                    .status(kind, Some(&name), true, reason.as_deref());
8911                item.option = Some(name);
8912                item.reason = reason;
8913            }
8914            StatusTarget::Column(_) => {
8915                // An option is what an open item's status is, so a closed issue is reopened
8916                // first — sitting closed in the column, it would read back as closed. A draft has
8917                // no state to reopen.
8918                if item.content_kind == ContentKind::Issue && item.closed {
8919                    self.update_content(
8920                        ContentKind::Issue,
8921                        &item.id,
8922                        json!({"stateInput": state_input(Some(&target))}),
8923                    )
8924                    .await?;
8925                    item.closed = false;
8926                }
8927                self.set_item_field(
8928                    board.id.as_str(),
8929                    &item.item_id,
8930                    &field,
8931                    json!({"singleSelectOptionId": option}),
8932                )
8933                .await?;
8934                item.status = self.statuses.status(kind, Some(&name), false, None);
8935                item.option = Some(name);
8936            }
8937            StatusTarget::Disabled(_) => {
8938                unreachable!("resolved_target refused a disabled status")
8939            }
8940        }
8941        let status = item.status.clone();
8942        if !by_reference {
8943            self.remember_written(item, false)?;
8944        }
8945        Ok(Some(status))
8946    }
8947
8948    /// Replace one task's `delivered_by` and nothing else; see
8949    /// [`TaskSource::set_delivered_by`].
8950    ///
8951    /// One update of the body, which differs from the body GitHub holds only inside the
8952    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
8953    async fn replace_delivered_by(
8954        &self,
8955        id: &NativeId,
8956        delivered_by: &[TaskRef],
8957    ) -> Result<Option<()>, SourceError> {
8958        let entries = TaskRef::listed(
8959            TaskRef::DELIVERED_BY_KEY,
8960            id,
8961            Some(&self.name),
8962            delivered_by.to_vec(),
8963        )
8964        .map_err(|message| SourceError::Refused { message })?;
8965        let Some(mut item) = self
8966            .bound_item(id)
8967            .await?
8968            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8969        else {
8970            return Ok(None);
8971        };
8972        let mut slot = item.slot.clone();
8973        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
8974        self.write_slot(&mut item, &slot).await?;
8975        item.delivered_by = entries;
8976        self.remember_written(item, false)?;
8977        Ok(Some(()))
8978    }
8979
8980    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
8981    /// see [`TaskSource::set_task_metadata`].
8982    ///
8983    /// `None` when this board holds no item by that id, or holds one of another kind. The
8984    /// answer is the item as this source now reads it, so what a caller is told the key
8985    /// holds is what the slot holds.
8986    ///
8987    /// A key already holding the value is answered without a write, compared as JSON rather
8988    /// than as the body's bytes: a slot a person spelled with other whitespace would
8989    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
8990    async fn set_slot_key(
8991        &self,
8992        id: &NativeId,
8993        kind: BoardKind,
8994        key: &MetadataKey,
8995        value: &Value,
8996    ) -> Result<Option<Resolved>, SourceError> {
8997        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8998            return Ok(None);
8999        };
9000        if item.slot.get(key.as_str()) == Some(value) {
9001            return Ok(Some(item));
9002        }
9003        let mut slot = item.slot.clone();
9004        slot.insert(key.as_str().to_owned(), value.clone());
9005        // A key a board text field projects moves that field first, so the body — the value's
9006        // one authoritative home — is written last, as every write of an existing item is.
9007        if self
9008            .metadata_fields
9009            .iter()
9010            .any(|projection| &*projection.key == key.as_str())
9011        {
9012            let board = self.projection_board(&item).await?;
9013            let (writes, projected) = self.projection_writes(
9014                &board.fields,
9015                Some(&item.projected),
9016                &slot,
9017                Some(&[key.as_str()]),
9018            )?;
9019            let (mut values, mut clears) = (Vec::new(), Vec::new());
9020            for write in &writes {
9021                write.join(&mut values, &mut clears);
9022            }
9023            self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
9024                .await?;
9025            item.projected = projected;
9026        }
9027        self.write_slot(&mut item, &slot).await?;
9028        self.remember_written(item.clone(), false)?;
9029        Ok(Some(item))
9030    }
9031
9032    /// What a projection write to `item` needs of the board, read off the item when it says
9033    /// enough, as [`Self::fields_for`] reads it.
9034    async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
9035        if let Some(board) = item.carried_board() {
9036            return Ok(board);
9037        }
9038        if let Some(board_id) = item.named_board()
9039            && self
9040                .metadata_fields
9041                .iter()
9042                .all(|projection| item.defines(&projection.field))
9043        {
9044            return Ok(BoardFields {
9045                id: board_id,
9046                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
9047            });
9048        }
9049        self.board_fields().await
9050    }
9051
9052    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
9053    /// `item` up to what that write left.
9054    ///
9055    /// The body sent differs from the body GitHub holds only inside the slot — see
9056    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
9057    /// the mutation the item's content takes, so a board draft's body is written with
9058    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
9059    async fn write_slot(
9060        &self,
9061        item: &mut Resolved,
9062        slot: &BTreeMap<String, Value>,
9063    ) -> Result<(), SourceError> {
9064        let held = item.raw_body.clone().unwrap_or_default();
9065        let body = with_slot(&held, slot)?;
9066        if body != held {
9067            self.update_content(item.content_kind, &item.id, json!({"body": body}))
9068                .await?;
9069        }
9070        let (visible, slot) = self.visible(Some(body.clone()))?;
9071        item.body = visible.filter(|value| !value.is_empty());
9072        item.raw_body = Some(body);
9073        item.slot = slot;
9074        Ok(())
9075    }
9076
9077    /// This instance's target for a category written to an item of `kind`, refusing one
9078    /// that kind has no option for — before anything is read or written.
9079    ///
9080    /// Nothing here mutates the board's option set to make room for a status. GitHub
9081    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
9082    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
9083    /// field and every item's status.
9084    fn resolved_target(
9085        &self,
9086        kind: ItemKind,
9087        category: StatusCategory,
9088    ) -> Result<StatusTarget, SourceError> {
9089        let target = self.statuses.target(kind, category).clone();
9090        let StatusTarget::Disabled(why) = target else {
9091            return Ok(target);
9092        };
9093        let refusal = why.refusal(&self.name, category, kind);
9094        // Why there is no shipped default, which is the question a person meeting this
9095        // refusal on a source that never mentioned the category asks.
9096        let shipped_none = match category {
9097            StatusCategory::Draft => Some(
9098                "draft has no shipped default because GitHub draft issues cannot have \
9099                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
9100            ),
9101            StatusCategory::Unknown => Some(
9102                "unknown has no shipped default because this board keeps no open-ended status \
9103                 word: every word classified unknown is written to the one board Status option \
9104                 status_mapping.unknown names",
9105            ),
9106            _ => None,
9107        };
9108        Err(match (refusal, shipped_none, why) {
9109            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
9110                SourceError::Refused {
9111                    message: format!("{message}; {note}"),
9112                }
9113            }
9114            (refusal, _, _) => refusal,
9115        })
9116    }
9117
9118    /// What writing `priority` does to one item's `Priority` field on this board, or the
9119    /// refusal naming what the board lacks.
9120    ///
9121    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
9122    /// none already, or of an item not created yet. Every other priority selects the option
9123    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
9124    /// without that option, is refused rather than given one: reads and writes never create
9125    /// a field or an option.
9126    fn priority_write(
9127        &self,
9128        fields: &Value,
9129        existing: Option<&Resolved>,
9130        priority: Priority,
9131    ) -> Result<Option<PriorityWrite>, SourceError> {
9132        let Some(mapping) = &self.priorities else {
9133            return Err(self.holds_no_priority());
9134        };
9135        let Some(wanted) = mapping.option(priority) else {
9136            if !existing.is_some_and(Resolved::holds_priority) {
9137                return Ok(None);
9138            }
9139            let field =
9140                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
9141                    message: format!(
9142                        "an item holding a {PRIORITY_FIELD} value was read without that field"
9143                    ),
9144                })?;
9145            return Ok(Some(PriorityWrite::Clear {
9146                field: required_str(field, "id")?.to_owned(),
9147            }));
9148        };
9149        let missing = |detail: &str| SourceError::Refused {
9150            message: format!(
9151                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
9152                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
9153                 it, or point priority_mapping.{priority} of this source at an option the board \
9154                 has",
9155                self.name, self.name
9156            ),
9157        };
9158        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
9159            return Err(missing(&format!(
9160                "this board has no {PRIORITY_FIELD} field"
9161            )));
9162        };
9163        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
9164            return Err(missing(&format!(
9165                "this board's {PRIORITY_FIELD} field is not a single-select field"
9166            )));
9167        }
9168        // An options list that is absent or not a list is an answer this source cannot read,
9169        // not a board lacking the option: `sources fields --apply` is no remedy for it.
9170        let option = field
9171            .get("options")
9172            .and_then(Value::as_array)
9173            .ok_or_else(|| SourceError::Malformed {
9174                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
9175            })?
9176            .iter()
9177            .find(|option| {
9178                option
9179                    .get("name")
9180                    .and_then(Value::as_str)
9181                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
9182            })
9183            .ok_or_else(|| missing("this board does not have it"))?;
9184        Ok(Some(PriorityWrite::Select {
9185            field: required_str(field, "id")?.to_owned(),
9186            option: required_str(option, "id")?.to_owned(),
9187        }))
9188    }
9189
9190    /// What writing `metadata` does to each projected text field of one item, and what those
9191    /// fields hold after it — or the refusal, before anything is sent.
9192    ///
9193    /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
9194    /// narrows the work to the entries projecting one of those metadata keys, for a write that
9195    /// touches only them; `None` is every entry. A string the field already holds, and nothing
9196    /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
9197    /// and so is a board without the field, or with a field of that name that is not a text
9198    /// field: reads and writes never create a field.
9199    fn projection_writes(
9200        &self,
9201        fields: &Value,
9202        held: Option<&BTreeMap<String, String>>,
9203        metadata: &BTreeMap<String, Value>,
9204        keys: Option<&[&str]>,
9205    ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
9206        let mut projected = held.cloned().unwrap_or_default();
9207        let mut writes = Vec::new();
9208        for projection in self
9209            .metadata_fields
9210            .iter()
9211            .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
9212        {
9213            let wanted = projection.projected(metadata, &self.name)?;
9214            let field = self.projection_field(fields, projection)?;
9215            let holding = projected.get(&*projection.field);
9216            match wanted {
9217                Projected::Text(text) if holding != Some(&text) => {
9218                    writes.push(ProjectionWrite::Set {
9219                        field,
9220                        text: text.clone(),
9221                    });
9222                    projected.insert(projection.field.to_string(), text);
9223                }
9224                Projected::Nothing if holding.is_some() => {
9225                    writes.push(ProjectionWrite::Clear { field });
9226                    projected.remove(&*projection.field);
9227                }
9228                Projected::Text(_) | Projected::Nothing => {}
9229            }
9230        }
9231        Ok((writes, projected))
9232    }
9233
9234    /// The id of the board text field one projection writes, or the refusal naming what the
9235    /// board has instead.
9236    fn projection_field(
9237        &self,
9238        fields: &Value,
9239        projection: &MetadataField,
9240    ) -> Result<String, SourceError> {
9241        let refuse = |detail: String| SourceError::Refused {
9242            message: format!(
9243                "source {} projects metadata key {:?} onto the board text field {:?}, and \
9244                 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
9245                 text field, or name another field in metadata_fields",
9246                self.name, &*projection.key, &*projection.field, self.name
9247            ),
9248        };
9249        let Some(field) = Board::field(fields, &projection.field)? else {
9250            return Err(refuse("this board has no field of that name".to_owned()));
9251        };
9252        let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
9253        let data_type = optional_str(field, "dataType")?;
9254        if typename != "ProjectV2Field" || data_type != Some("TEXT") {
9255            return Err(refuse(format!(
9256                "this board's field of that name is not a text field (it is a {}), so it is \
9257                 left as it is; rename or remove that field first",
9258                data_type.unwrap_or(typename)
9259            )));
9260        }
9261        Ok(required_str(field, "id")?.to_owned())
9262    }
9263
9264    /// Apply one priority write to one board item.
9265    async fn write_priority(
9266        &self,
9267        board_id: &str,
9268        item_id: &str,
9269        write: &PriorityWrite,
9270    ) -> Result<(), SourceError> {
9271        match write {
9272            PriorityWrite::Select { field, option } => {
9273                self.set_item_field(
9274                    board_id,
9275                    item_id,
9276                    field,
9277                    json!({"singleSelectOptionId": option}),
9278                )
9279                .await
9280            }
9281            PriorityWrite::Clear { field } => {
9282                let data = self
9283                    .graphql(
9284                        graphql::CLEAR_FIELD,
9285                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
9286                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
9287                    )
9288                    .await?;
9289                let returned = data
9290                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
9291                    .ok_or_else(|| SourceError::Malformed {
9292                        message: "GitHub field clear returned no project item".into(),
9293                    })?;
9294                if required_str(returned, "id")? != item_id {
9295                    return Err(SourceError::Malformed {
9296                        message: "GitHub field clear returned the wrong project item".into(),
9297                    });
9298                }
9299                Ok(())
9300            }
9301        }
9302    }
9303
9304    /// The refusal a priority is answered with by an instance configured with no
9305    /// `priority_mapping`, which holds none.
9306    fn holds_no_priority(&self) -> SourceError {
9307        SourceError::Refused {
9308            message: format!(
9309                "source {} holds no task priority: its configuration sets no priority_mapping; \
9310                 next: set priority_mapping on this source, then run `onetaskgraph sources \
9311                 fields {} --apply` to set its board up",
9312                self.name, self.name
9313            ),
9314        }
9315    }
9316
9317    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
9318    ///
9319    /// One field write — a select, or a clear for `none` — and no title, body, label, state
9320    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
9321    async fn set_priority(
9322        &self,
9323        id: &NativeId,
9324        priority: Priority,
9325    ) -> Result<Option<Priority>, SourceError> {
9326        if self.priorities.is_none() {
9327            return Err(self.holds_no_priority());
9328        }
9329        let Some(mut item) = self
9330            .bound_item(id)
9331            .await?
9332            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9333        else {
9334            return Ok(None);
9335        };
9336        if priority == Priority::None && !item.holds_priority() {
9337            return Ok(Some(priority));
9338        }
9339        // The item's own read carries the field's definition whenever it holds a value of
9340        // it, which a clear always does; a select onto an item holding none reads the board.
9341        let board = match (item.carried_board(), item.named_board()) {
9342            (Some(board), _) => board,
9343            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
9344                id,
9345                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
9346            },
9347            _ => self.board_fields().await?,
9348        };
9349        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
9350            return Ok(Some(priority));
9351        };
9352        let (document, root, input) = match write {
9353            PriorityWrite::Select { field, option } => (
9354                graphql::UPDATE_FIELD,
9355                "updateProjectV2ItemFieldValue",
9356                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
9357            ),
9358            PriorityWrite::Clear { field } => (
9359                graphql::CLEAR_FIELD,
9360                "clearProjectV2ItemFieldValue",
9361                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
9362            ),
9363        };
9364        let data = self
9365            .graphql(
9366                document,
9367                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
9368            )
9369            .await?;
9370        let returned = data
9371            .get(root)
9372            .and_then(|value| value.get("projectV2Item"))
9373            .ok_or_else(|| SourceError::Malformed {
9374                message: "GitHub priority write returned no project item".into(),
9375            })?;
9376        if required_str(returned, "id")? != item.item_id {
9377            return Err(SourceError::Malformed {
9378                message: "GitHub priority write returned the wrong project item".into(),
9379            });
9380        }
9381        let value = returned
9382            .get("fieldValueByName")
9383            .ok_or_else(|| SourceError::Malformed {
9384                message: "GitHub priority write returned no priority read-back".into(),
9385            })?;
9386        if !value.is_null()
9387            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
9388        {
9389            return Err(SourceError::Malformed {
9390                message: "GitHub priority read-back is not a Priority field value".into(),
9391            });
9392        }
9393        let values = if value.is_null() {
9394            Vec::new()
9395        } else {
9396            vec![value.clone()]
9397        };
9398        item.priority = self.held_priority(&values)?;
9399        let answer = item.task()?.priority;
9400        self.remember_written(item, false)?;
9401        Ok(Some(answer))
9402    }
9403
9404    /// Replace one task's visible body and nothing else; see
9405    /// [`TaskSource::set_task_content`].
9406    ///
9407    /// One update of the body, which differs from the body GitHub holds only outside the
9408    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
9409    /// this source keeps there reads back as it was. A body that would not change is not
9410    /// sent at all.
9411    async fn replace_content(
9412        &self,
9413        id: &NativeId,
9414        content: &str,
9415    ) -> Result<Option<()>, SourceError> {
9416        let Some(mut item) = self
9417            .bound_item(id)
9418            .await?
9419            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9420        else {
9421            return Ok(None);
9422        };
9423        let held = item.raw_body.clone().unwrap_or_default();
9424        // The task's `## Links` section, re-rendered from the links it holds, stays with it.
9425        let content = &onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &item.slot);
9426        let body = with_content(&held, &self.marked(content))?;
9427        // Checked before anything is sent: content ending in what this source reads as its own
9428        // metadata slot would read back as metadata rather than as the content it was.
9429        let (visible, slot) = self.visible(Some(body.clone()))?;
9430        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
9431            return Err(SourceError::Refused {
9432                message: format!(
9433                    "this content ends in what source {} reads as its own metadata slot \
9434                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9435                     as content; next: remove that trailing block from the content",
9436                    self.name
9437                ),
9438            });
9439        }
9440        if body != held {
9441            self.update_content(item.content_kind, &item.id, json!({"body": body}))
9442                .await?;
9443        }
9444        item.body = visible.filter(|value| !value.is_empty());
9445        item.raw_body = Some(body);
9446        item.slot = slot;
9447        self.remember_written(item, false)?;
9448        Ok(Some(()))
9449    }
9450
9451    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
9452    ///
9453    /// One read of the item — which carries the board's field definitions and the issue's
9454    /// `blockedBy`, so neither is read again — and then only what differs from it: the
9455    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
9456    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
9457    /// the title, the body — visible content and metadata slot together — and a state change.
9458    /// So an update naming any of title, body, metadata, status and priority is one read and
9459    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
9460    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
9461    /// as a whole write does; an open one selects its option and then reopens. The origin
9462    /// field is never written: an update is of an item that already exists, whose origin is
9463    /// what it is.
9464    ///
9465    /// The task answered is the item as those writes left it, built from the read and what was
9466    /// sent rather than read again — the same record a later read in this run answers from.
9467    ///
9468    /// Its `version` is the one the task holds after the writes: the change token the last
9469    /// mutation's own response spells — each sent as the twin selecting the whole issue, at
9470    /// the price of the plain document — or, where that mutation shows no whole issue (several
9471    /// board fields written together, or `blockedBy` alone), the token one read right after
9472    /// the writes spells. Never the token the item was read at before them. Against GitHub
9473    /// itself that token has not settled yet (`change_token::SETTLED`), so a read gives none
9474    /// until it has, and a write held to it before then is refused rather than let through;
9475    /// once settled, a read gives exactly this token unless the task changed again.
9476    async fn targeted_update(
9477        &self,
9478        id: &NativeId,
9479        update: &TaskUpdate,
9480    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9481        Box::pin(SPELLING.scope(
9482            std::ptr::from_ref(self) as usize,
9483            self.composed_update(id, update),
9484        ))
9485        .await
9486    }
9487
9488    /// [`Self::targeted_update`], inside the scope that has its mutations answer whole.
9489    async fn composed_update(
9490        &self,
9491        id: &NativeId,
9492        update: &TaskUpdate,
9493    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9494        // Everything this source can refuse without reading the item is refused first, in the
9495        // words a whole write of the same fields is refused with.
9496        update.consistent()?;
9497        if update.assignees.is_some() && !self.assignees {
9498            return Err(onetaskgraph_plugin_api::unwritable_field(KIND, "assignees"));
9499        }
9500        if update
9501            .title
9502            .as_deref()
9503            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
9504        {
9505            return Err(SourceError::Refused {
9506                message: format!(
9507                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
9508                     spells a document, so it would read back as one rather than as a task; \
9509                     retitle it",
9510                    self.name
9511                ),
9512            });
9513        }
9514        if let Some(delivers) = &update.delivers {
9515            TaskRef::listed(
9516                TaskRef::DELIVERS_KEY,
9517                id,
9518                Some(&self.name),
9519                delivers.clone(),
9520            )
9521            .map_err(|message| SourceError::Refused { message })?;
9522        }
9523        if self.priorities.is_none()
9524            && update
9525                .priority
9526                .is_some_and(|priority| priority != Priority::None)
9527        {
9528            return Err(self.holds_no_priority());
9529        }
9530        let target = update
9531            .status
9532            .as_ref()
9533            .map(|status| self.resolved_target(ItemKind::Task, status.category))
9534            .transpose()?;
9535        let Some(mut item) = self
9536            .bound_item(id)
9537            .await?
9538            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9539        else {
9540            return Ok(None);
9541        };
9542        let before = item.task()?;
9543        // The people it is to be assigned to, by node id, when they are not exactly whom it
9544        // holds: looked up — unless this process already has — and refused for a login GitHub
9545        // does not know, before anything is sent.
9546        let assignee_ids = match &update.assignees {
9547            Some(people)
9548                if item
9549                    .assignees
9550                    .as_deref()
9551                    .is_none_or(|held| !assignees::same_people(held, people)) =>
9552            {
9553                if item.content_kind == ContentKind::DraftIssue {
9554                    return Err(self.assigns_a_draft());
9555                }
9556                self.resolve_people(people).await?;
9557                Some(self.people_ids(people, &item.written_name())?)
9558            }
9559            _ => None,
9560        };
9561
9562        let mut status_move = None;
9563        if let (Some(status), Some(target)) = (&update.status, target) {
9564            let board = self.status_board(&item).await?;
9565            let (field, option, name) = self
9566                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
9567                .ok_or_else(|| SourceError::Malformed {
9568                    message: format!(
9569                        "status {} of source {} names no board Status option",
9570                        category_name(status.category),
9571                        self.name
9572                    ),
9573                })?;
9574            let terminal = matches!(target, StatusTarget::Terminal(_, _));
9575            if terminal && item.content_kind == ContentKind::DraftIssue {
9576                return Err(self.closes_a_draft(status.category));
9577            }
9578            let landed = match &target {
9579                StatusTarget::Terminal(_, reason) => {
9580                    self.statuses
9581                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
9582                }
9583                _ => self
9584                    .statuses
9585                    .status(ItemKind::Task, Some(&name), false, None),
9586            };
9587            let option_moves = item
9588                .option
9589                .as_deref()
9590                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
9591            let state_moves = item.content_kind == ContentKind::Issue
9592                && (item.closed != terminal || (terminal && item.status != landed));
9593            if let Some(moves) = Moves::of(option_moves, state_moves) {
9594                status_move = Some(StatusMove {
9595                    board: board.id,
9596                    field,
9597                    option,
9598                    name,
9599                    target,
9600                    landed,
9601                    moves,
9602                });
9603            }
9604        }
9605
9606        let mut priority_move = None;
9607        if let Some(priority) = update.priority
9608            && self.priorities.is_some()
9609            && item.priority != HeldPriority::Read(priority)
9610        {
9611            let board = match (item.carried_board(), item.named_board()) {
9612                (Some(board), _) => board,
9613                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
9614                    id: board,
9615                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
9616                },
9617                _ => self.board_fields().await?,
9618            };
9619            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
9620                priority_move = Some((board.id, write, priority));
9621            }
9622        }
9623
9624        // The metadata keys this update names that a board text field projects, and what
9625        // that does to those fields — refused, as a whole write refuses it, before anything
9626        // is sent.
9627        let touched: Vec<&str> = update
9628            .metadata_set
9629            .keys()
9630            .chain(update.metadata_remove.iter())
9631            .map(MetadataKey::as_str)
9632            .collect();
9633        let mut projection_move = None;
9634        if self
9635            .metadata_fields
9636            .iter()
9637            .any(|projection| touched.contains(&&*projection.key))
9638        {
9639            let mut metadata = item.slot.clone();
9640            for (key, value) in &update.metadata_set {
9641                metadata.insert(key.as_str().to_owned(), value.clone());
9642            }
9643            for key in &update.metadata_remove {
9644                metadata.remove(key.as_str());
9645            }
9646            let board = self.projection_board(&item).await?;
9647            let (writes, projected) = self.projection_writes(
9648                &board.fields,
9649                Some(&item.projected),
9650                &metadata,
9651                Some(&touched),
9652            )?;
9653            projection_move = Some((board.id, writes, projected));
9654        }
9655
9656        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
9657        // recorded in the slot, and the slot travels in the one body update below.
9658        let edges = match &update.depends_on {
9659            Some(edges) => Some(
9660                self.partition_edges(
9661                    BoardKind::Work(ItemKind::Task),
9662                    item.content_kind,
9663                    item.blocked_by.as_deref(),
9664                    edges,
9665                )
9666                .await?,
9667            ),
9668            None => None,
9669        };
9670
9671        // A record primed from what a producer wrote holds no body anybody read, so an update
9672        // changing the body reads it first: by the `Status` write's own answer when the update
9673        // moves the option — the mutation answering with the issue whole — and otherwise by
9674        // reading the issue. The body is then composed against what GitHub holds now.
9675        let mut option_selected = false;
9676        // The mark stays until a read lands, so an update that writes no body, or one whose
9677        // read fails, leaves the next update of it to read the body too.
9678        if (update.content.is_some()
9679            || !update.metadata_set.is_empty()
9680            || !update.metadata_remove.is_empty()
9681            || update.delivers.is_some()
9682            || update.repositories.is_some()
9683            || edges.is_some())
9684            && self.is_unread(id)
9685        {
9686            let live = match status_move.as_ref().filter(|moving| moving.moves.option()) {
9687                Some(moving) => {
9688                    option_selected = true;
9689                    Some(
9690                        self.select_reading_body(
9691                            moving.board.as_str(),
9692                            &item.item_id,
9693                            &moving.field,
9694                            &moving.option,
9695                        )
9696                        .await?,
9697                    )
9698                }
9699                None => {
9700                    self.forget(id)?;
9701                    let Some(read) = self.item_by_id(id).await? else {
9702                        return Ok(None);
9703                    };
9704                    read.raw_body
9705                }
9706            };
9707            let (visible, read_slot) = self.visible(live.clone())?;
9708            self.mark_read(id);
9709            item.raw_body = live;
9710            item.body = visible;
9711            item.slot = read_slot;
9712        }
9713
9714        let mut slot = item.slot.clone();
9715        for (key, value) in &update.metadata_set {
9716            slot.insert(key.as_str().to_owned(), value.clone());
9717        }
9718        for key in &update.metadata_remove {
9719            slot.remove(key.as_str());
9720        }
9721        if let Some(delivers) = &update.delivers {
9722            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
9723        }
9724        // The list a whole write records, by the same rule: none when it is exactly the issue's
9725        // own repository, or empty with no issue repository to derive, and otherwise the list.
9726        if let Some(repositories) = &update.repositories {
9727            let derivable = item
9728                .own_repository
9729                .as_ref()
9730                .map_or(repositories.is_empty(), |own| {
9731                    repositories.as_slice() == std::slice::from_ref(own)
9732                });
9733            if derivable {
9734                slot.remove(Repository::METADATA_KEY);
9735            } else {
9736                slot.insert(
9737                    Repository::METADATA_KEY.to_owned(),
9738                    Value::Array(
9739                        repositories
9740                            .iter()
9741                            .map(|repository| Value::String(repository.as_str().to_owned()))
9742                            .collect(),
9743                    ),
9744                );
9745            }
9746        }
9747        if let Some((_, recorded)) = &edges {
9748            record_edges(&mut slot, recorded);
9749        }
9750        let held = item.raw_body.clone().unwrap_or_default();
9751        // A content write keeps the task's `## Links` section, re-rendered from the links the
9752        // slot holds once written.
9753        let relinked = update
9754            .content
9755            .as_deref()
9756            .map(|content| onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot));
9757        let content = match &relinked {
9758            Some(content) => with_content(&held, &self.marked(content))?,
9759            None => held.clone(),
9760        };
9761        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
9762        // the body's bytes, as a metadata write compares it: a slot a person spelled with
9763        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
9764        let body = if slot == item.slot {
9765            content
9766        } else {
9767            with_slot(&content, &slot)?
9768        };
9769        // Checked before anything is sent, as a content write checks it: content ending in
9770        // what this source reads as its own slot would read back as metadata.
9771        let (visible, read) = self.visible(Some(body.clone()))?;
9772        let wanted = relinked.as_deref().or(item.body.as_deref());
9773        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
9774            return Err(SourceError::Refused {
9775                message: format!(
9776                    "this content ends in what source {} reads as its own metadata slot \
9777                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9778                     as content; next: remove that trailing block from the content",
9779                    self.name
9780                ),
9781            });
9782        }
9783        let recorded_moves =
9784            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
9785
9786        // One `updateIssue` carries all three, because every mutation spends the secondary
9787        // limiter and the title, body and state are one mutation's inputs.
9788        let mut fields = serde_json::Map::new();
9789        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
9790            fields.insert("title".to_owned(), json!(title));
9791        }
9792        if body != held {
9793            fields.insert("body".to_owned(), json!(body));
9794        }
9795        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
9796            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
9797        }
9798        // The assignees ride the same mutation: replacing whoever the issue holds, in the one
9799        // `updateIssue` this update already ends with — or as the one it sends for them alone.
9800        if let Some(ids) = &assignee_ids {
9801            fields.insert("assigneeIds".to_owned(), json!(ids));
9802        }
9803        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
9804        // GitHub runs no two requests as one, and runs one document's mutation fields in order
9805        // without undoing an earlier field when a later one fails — so a body written before a
9806        // board field the board then refused would be left changed. Written after every other
9807        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
9808        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
9809        // first, together in one request — a terminal option selected before the issue
9810        // closes, as a whole write does — then the `blockedBy` difference, then the body.
9811        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
9812        let mut clears: Vec<(&BoardId, &str)> = Vec::new();
9813        if let Some(moving) = status_move
9814            .as_ref()
9815            .filter(|moving| moving.moves.option() && !option_selected)
9816        {
9817            board_writes.push((
9818                &moving.board,
9819                (
9820                    moving.field.clone(),
9821                    json!({"singleSelectOptionId": moving.option}),
9822                ),
9823            ));
9824        }
9825        match &priority_move {
9826            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
9827                board,
9828                (field.clone(), json!({"singleSelectOptionId": option})),
9829            )),
9830            Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
9831            None => {}
9832        }
9833        if let Some((board, writes, _)) = &projection_move {
9834            for write in writes {
9835                let (mut values, mut cleared) = (Vec::new(), Vec::new());
9836                write.join(&mut values, &mut cleared);
9837                board_writes.extend(values.into_iter().map(|value| (board, value)));
9838                clears.extend(cleared.into_iter().map(|field| (board, field)));
9839            }
9840        }
9841        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
9842        boards.extend(clears.iter().map(|(board, _)| *board));
9843        boards.dedup_by(|one, other| one.as_str() == other.as_str());
9844        for board in boards {
9845            let writes = board_writes
9846                .iter()
9847                .filter(|(on, _)| on.as_str() == board.as_str())
9848                .map(|(_, write)| write.clone())
9849                .collect::<Vec<_>>();
9850            let cleared = clears
9851                .iter()
9852                .filter(|(on, _)| on.as_str() == board.as_str())
9853                .map(|(_, field)| *field)
9854                .collect::<Vec<_>>();
9855            self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
9856                .await?;
9857        }
9858        // Whether anything is sent: what the version answered has to come after.
9859        let sends =
9860            option_selected || !board_writes.is_empty() || !clears.is_empty() || !fields.is_empty();
9861        let mut blocked_by_moved = false;
9862        if let Some((native, _)) = &edges
9863            && item.content_kind == ContentKind::Issue
9864        {
9865            blocked_by_moved = self
9866                .reconcile_blocked_by(
9867                    &item.id,
9868                    native,
9869                    Issue::Existing(item.blocked_by.as_deref()),
9870                )
9871                .await?;
9872        }
9873        if !fields.is_empty() {
9874            self.update_content(item.content_kind, &item.id, Value::Object(fields))
9875                .await?;
9876        }
9877
9878        // The `blockedBy` this item was read with is what the write just changed, so it is no
9879        // answer any more: a later write in this command would reconcile against it — re-adding
9880        // what it kept and never removing what it dropped — and a forward-edge read would
9881        // answer with it. Left unknown, each asks GitHub instead.
9882        if blocked_by_moved {
9883            item.blocked_by = None;
9884        }
9885        if let Some(title) = &update.title {
9886            item.title.clone_from(title);
9887        }
9888        item.body = visible.filter(|value| !value.is_empty());
9889        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
9890        item.slot = slot;
9891        if let Some(delivers) = &update.delivers {
9892            item.delivers.clone_from(delivers);
9893        }
9894        if let Some(repositories) = &update.repositories {
9895            item.repositories.clone_from(repositories);
9896        }
9897        if let Some(moving) = status_move {
9898            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
9899                && item.content_kind == ContentKind::Issue;
9900            item.status = moving.landed;
9901            item.option = Some(moving.name);
9902        }
9903        if let Some((_, _, priority)) = priority_move {
9904            item.priority = HeldPriority::Read(priority);
9905        }
9906        if let Some((_, _, projected)) = projection_move {
9907            item.projected = projected;
9908        }
9909        // Only a write that assigned anyone moves whom the item holds, and they are who GitHub
9910        // says they are; one naming the people it already holds leaves its read as it was.
9911        if let (Some(people), Some(_)) = (&update.assignees, &assignee_ids) {
9912            item.assignees = Some(self.spelled(people));
9913        }
9914        let mut task = item.task()?;
9915        let mut written = update.changed(&before, &task);
9916        if blocked_by_moved || recorded_moves {
9917            written.insert(UpdatedField::DependsOn);
9918        }
9919        if sends || blocked_by_moved {
9920            // The token the item was read at is not the one it holds now. A batch of updates
9921            // holding this one's writes back reads every task it wrote once they are sent.
9922            task.version = if self.holding_writes_of(id) {
9923                None
9924            } else {
9925                self.version_after_write(id).await
9926            };
9927        }
9928        self.remember_written(item, false)?;
9929        Ok(Some(TaskUpdateOutcome {
9930            task,
9931            written,
9932            delivers_before: before.delivers,
9933        }))
9934    }
9935
9936    /// The change token the task `id` holds after the writes a targeted update just sent: the
9937    /// one the last mutation's own response showed, or, where none showed the whole issue, the
9938    /// one a read made now spells. `None` when that read cannot say — never the token the
9939    /// task was read at before the writes.
9940    async fn version_after_write(&self, id: &NativeId) -> Option<String> {
9941        // A response that showed the issue whole answers, a token or none: a read made now
9942        // would read the same parts.
9943        if let Some(answered) = self.written_answer(id) {
9944            return answered;
9945        }
9946        // An item this command created is answered from its own record, not read.
9947        if self.created().ok()?.iter().any(|own| own.id == *id) {
9948            return None;
9949        }
9950        let read = self.item_by_id(id).await.ok()??;
9951        let token = read.written_version()?;
9952        self.remember_written_token(&read, token.clone());
9953        Some(token)
9954    }
9955
9956    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
9957    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
9958    ///
9959    /// One update of the body: the content outside the slot, and inside it that one entry,
9960    /// every other entry kept as it was. This source keeps no template answers — an issue has
9961    /// no room beside itself that is not its body, and answers written there would duplicate
9962    /// what the content already says and count against GitHub's body limit — so `answers`
9963    /// reaches nothing here. A body that would not change is not sent at all.
9964    async fn replace_rendering(
9965        &self,
9966        id: &NativeId,
9967        kind: BoardKind,
9968        content: &str,
9969        provenance: &Value,
9970        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
9971    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9972        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
9973            return Ok(None);
9974        };
9975        let held = item.raw_body.clone().unwrap_or_default();
9976        let mut slot = item.slot.clone();
9977        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
9978        let rewritten;
9979        let content = if let Some(assets) = assets {
9980            let uploads = self
9981                .upload_assets(item.own_repository.as_ref(), assets)
9982                .await?;
9983            rewritten =
9984                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
9985            rewritten.as_str()
9986        } else {
9987            content
9988        };
9989        let relinked = onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot);
9990        let content = relinked.as_str();
9991        let body = with_slot(&with_content(&held, &self.marked(content))?, &slot)?;
9992        // Checked before anything is sent, as a content write checks it.
9993        let (visible, read) = self.visible(Some(body.clone()))?;
9994        if visible.as_deref().unwrap_or_default() != content || read != slot {
9995            return Err(SourceError::Refused {
9996                message: format!(
9997                    "this content ends in what source {} reads as its own metadata slot \
9998                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9999                     as content; next: remove that trailing block from the template",
10000                    self.name
10001                ),
10002            });
10003        }
10004        if body != held {
10005            self.update_content(item.content_kind, &item.id, json!({"body": body}))
10006                .await?;
10007        }
10008        item.body = visible.filter(|value| !value.is_empty());
10009        item.raw_body = Some(body);
10010        // The content as a read answers it: without the `## Links` section of a linked task.
10011        let answered = onetaskgraph_plugin_api::read_content(Some(content.to_owned()), &read);
10012        item.slot = read;
10013        self.remember_written(item, false)?;
10014        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
10015            id: id.clone(),
10016            content: answered,
10017        }))
10018    }
10019
10020    async fn set_item_field(
10021        &self,
10022        board_id: &str,
10023        item_id: &str,
10024        field_id: &str,
10025        value: Value,
10026    ) -> Result<(), SourceError> {
10027        let data = self
10028            .graphql(
10029                graphql::UPDATE_FIELD,
10030                json!({"input":{
10031                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
10032                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
10033            )
10034            .await?;
10035        field_update_item(&data, item_id).map(|_| ())
10036    }
10037
10038    /// GitHub accepts one value per field mutation; aliases combine those mutations in
10039    /// one request — writes in the order given, then clears. A lone write is
10040    /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
10041    /// request's slots hold go in further requests of the same document, in order. Every
10042    /// returned item id is checked, including optional aliases.
10043    async fn set_item_fields(
10044        &self,
10045        board: &str,
10046        item: &str,
10047        fields: &[(String, Value)],
10048        clears: &[&str],
10049    ) -> Result<(), SourceError> {
10050        if !(fields.is_empty() && clears.is_empty())
10051            && self.hold_fields(board, item, fields, clears)?
10052        {
10053            return Ok(());
10054        }
10055        match (fields, clears) {
10056            ([], []) => return Ok(()),
10057            ([(field, value)], []) => {
10058                return self.set_item_field(board, item, field, value.clone()).await;
10059            }
10060            ([], [field]) => {
10061                return self
10062                    .write_priority(
10063                        board,
10064                        item,
10065                        &PriorityWrite::Clear {
10066                            field: (*field).to_owned(),
10067                        },
10068                    )
10069                    .await;
10070            }
10071            _ => {}
10072        }
10073        // Any field of the request stands in for an input a slot leaves out, which GitHub
10074        // still requires to be well formed although it never runs it.
10075        let filler = fields
10076            .first()
10077            .map_or_else(|| clears[0], |(field, _)| field.as_str());
10078        let mut writes = fields.iter();
10079        let mut cleared = clears.iter();
10080        loop {
10081            let batch: Vec<&(String, Value)> =
10082                writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
10083            let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
10084            if batch.is_empty() && batch_clears.is_empty() {
10085                return Ok(());
10086            }
10087            let mut variables = serde_json::Map::new();
10088            for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
10089                let input = match batch.get(index) {
10090                    Some((field, value)) => {
10091                        json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
10092                    }
10093                    None => json!({"projectId":board,"itemId":item,"fieldId":filler,
10094                                   "value":{"text":""}}),
10095                };
10096                variables.insert(slot.variable.to_owned(), input);
10097                variables.insert(slot.include.to_owned(), json!(index < batch.len()));
10098            }
10099            for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
10100                let field = batch_clears.get(index).map_or(filler, |field| **field);
10101                variables.insert(
10102                    slot.variable.to_owned(),
10103                    json!({"projectId":board,"itemId":item,"fieldId":field}),
10104                );
10105                variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
10106            }
10107            let data = self
10108                .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
10109                .await?;
10110            let sent = FIELD_WRITE_SLOTS[..batch.len()]
10111                .iter()
10112                .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
10113            for slot in sent {
10114                let alias = slot.alias;
10115                let returned = data
10116                    .get(alias)
10117                    .and_then(|value| value.get("projectV2Item"))
10118                    .ok_or_else(|| SourceError::Malformed {
10119                        message: format!("GitHub field update {alias} returned no project item"),
10120                    })?;
10121                if required_str(returned, "id")? != item {
10122                    return Err(SourceError::Malformed {
10123                        message: format!(
10124                            "GitHub field update {alias} returned the wrong project item"
10125                        ),
10126                    });
10127                }
10128            }
10129        }
10130    }
10131
10132    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
10133        let mut after: Option<String> = None;
10134        let mut ids = Vec::new();
10135        loop {
10136            let data = self
10137                .graphql(
10138                    graphql::ISSUE_DEPENDENCIES,
10139                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
10140                )
10141                .await?;
10142            let connection =
10143                data.pointer("/node/blockedBy")
10144                    .ok_or_else(|| SourceError::Malformed {
10145                        message: "GitHub dependency response has no blockedBy connection".into(),
10146                    })?;
10147            ids.extend(
10148                connection
10149                    .get("nodes")
10150                    .and_then(Value::as_array)
10151                    .ok_or_else(|| SourceError::Malformed {
10152                        message: "GitHub dependency response nodes is not an array".into(),
10153                    })?
10154                    .iter()
10155                    .map(|value| required_str(value, "id").map(str::to_owned))
10156                    .collect::<Result<Vec<_>, _>>()?,
10157            );
10158            let next = next_cursor(connection)?;
10159            if let Some(next) = &next {
10160                validate_cursor_progress(after.as_deref(), &next.0)?;
10161            }
10162            after = next.map(|cursor| cursor.0);
10163            if after.is_none() {
10164                return Ok(ids);
10165            }
10166        }
10167    }
10168
10169    async fn dependencies(
10170        &self,
10171        id: &NativeId,
10172        near_kind: ItemKind,
10173        direction: Direction,
10174        page: &PageRequest,
10175    ) -> Result<Page<DependencyEdge>, SourceError> {
10176        validate_page(page)?;
10177        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
10178        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
10179        let recorded = recorded_offset(cursor, direction)?;
10180        // What this issue is blocked by, when a read of it by its own id in this command
10181        // already carried the whole connection — a copy reads the item it writes before it
10182        // reads its edges — and the page asked for is the whole of it, or the recorded tail
10183        // after it. Answered from that read, in the shape the dependency read answers in;
10184        // anything else is asked of GitHub.
10185        let carried = match direction {
10186            Direction::DependsOn => self
10187                .resolved_cache()?
10188                .get(id)
10189                .filter(|item| item.content_kind == ContentKind::Issue)
10190                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
10191            Direction::DependedOnBy => None,
10192        }
10193        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
10194        // Asked for even in the recorded phase, whose page reads nothing from the
10195        // connection: `__typename` is what says whether this item has a native
10196        // relationship at all, and that is what decides which far ends the reserved key is
10197        // allowed to hold.
10198        let data = match carried {
10199            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
10200                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
10201            None => {
10202                let after = if recorded.is_some() { None } else { cursor };
10203                let first = page.limit.min(MAX_PAGE_SIZE);
10204                match direction {
10205                    Direction::DependsOn => {
10206                        self.graphql(
10207                            graphql::ISSUE_DEPENDS_ON,
10208                            json!({"id":id.0,"first":first,"after":after,"owner":self.owner,
10209                                   "number":self.project_number,
10210                                   "boardItems":BOARD_ITEMS_PAGE_SIZE}),
10211                        )
10212                        .await?
10213                    }
10214                    Direction::DependedOnBy => {
10215                        self.graphql(
10216                            graphql::ISSUE_DEPENDED_ON_BY,
10217                            json!({"id":id.0,"first":first,"after":after}),
10218                        )
10219                        .await?
10220                    }
10221                }
10222            }
10223        };
10224        let draft_slot = draft_slot(&data, id, self.project_number)?;
10225        let node =
10226            data.get("node")
10227                .filter(|v| !v.is_null())
10228                .ok_or_else(|| SourceError::Refused {
10229                    message: format!(
10230                        "GitHub item {} was not found or does not support dependencies",
10231                        id.0
10232                    ),
10233                })?;
10234        let connection_name = match direction {
10235            Direction::DependsOn => "blockedBy",
10236            Direction::DependedOnBy => "blocking",
10237        };
10238        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
10239        // named natively and the reserved key may hold any far end. An issue's connections
10240        // hold issues, and this source reads them at the near item's own level.
10241        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
10242        if let Some(offset) = recorded {
10243            return Ok(recorded_page(
10244                self.recorded_edges(
10245                    id,
10246                    near_kind,
10247                    direction,
10248                    natively_names,
10249                    node,
10250                    draft_slot.as_ref(),
10251                )
10252                .await?,
10253                offset,
10254                limit,
10255            ));
10256        }
10257        if natively_names.is_none() {
10258            return Ok(recorded_page(
10259                self.recorded_edges(
10260                    id,
10261                    near_kind,
10262                    direction,
10263                    natively_names,
10264                    node,
10265                    draft_slot.as_ref(),
10266                )
10267                .await?,
10268                0,
10269                limit,
10270            ));
10271        }
10272        let connection = node
10273            .get(connection_name)
10274            .ok_or_else(|| SourceError::Malformed {
10275                message: "GitHub dependency response is missing its connection".into(),
10276            })?;
10277        let nodes = connection
10278            .get("nodes")
10279            .and_then(Value::as_array)
10280            .ok_or_else(|| SourceError::Malformed {
10281                message: "GitHub dependency response nodes is not an array".into(),
10282            })?;
10283        // `from` depends on `to`, always. GitHub spells the same relationship from either
10284        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
10285        // it — so the near item is `from` in one direction and `to` in the other.
10286        let items = nodes
10287            .iter()
10288            .map(|value| {
10289                let related = NativeId(required_str(value, "id")?.into());
10290                let related_kind = related_kind(value, self.sub_issues)?;
10291                let (from, to) = match direction {
10292                    Direction::DependsOn => (
10293                        DependencyEndpoint::from_native(id.clone(), near_kind),
10294                        DependencyEndpoint::from_native(related, related_kind),
10295                    ),
10296                    Direction::DependedOnBy => (
10297                        DependencyEndpoint::from_native(related, related_kind),
10298                        DependencyEndpoint::from_native(id.clone(), near_kind),
10299                    ),
10300                };
10301                Ok(DependencyEdge {
10302                    from,
10303                    to,
10304                    kind: DependencyKind::Blocks,
10305                })
10306            })
10307            .collect::<Result<Vec<_>, SourceError>>()?;
10308        let mut next = next_cursor(connection)?;
10309        if let Some(next) = &next {
10310            validate_cursor_progress(cursor, &next.0)?;
10311        }
10312        // The recorded far ends ride on the last page of `blockedBy`, whose answer carried the
10313        // body they are recorded in, as far as the page has room — so they cost no request of
10314        // their own — and only what does not fit is resumed by a cursor.
10315        let mut items = items;
10316        if next.is_none() {
10317            let recorded = self
10318                .recorded_edges(
10319                    id,
10320                    near_kind,
10321                    direction,
10322                    natively_names,
10323                    node,
10324                    draft_slot.as_ref(),
10325                )
10326                .await?;
10327            let room = limit.saturating_sub(items.len());
10328            let total = recorded.len();
10329            items.extend(recorded.into_iter().take(room));
10330            if total > room {
10331                next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
10332            }
10333        }
10334        Ok(Page { items, next })
10335    }
10336
10337    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
10338    /// a far end in another source has to live: no GitHub issue relationship can name one.
10339    ///
10340    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
10341    /// source never writes one down.
10342    ///
10343    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
10344    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
10345    /// request beyond the read already made, and reading the board for them would be a
10346    /// walk of every item for one field of one. A draft has no body in that answer, because
10347    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
10348    /// listing of the board, which can be behind on the very item asked about.
10349    async fn recorded_edges(
10350        &self,
10351        id: &NativeId,
10352        near_kind: ItemKind,
10353        direction: Direction,
10354        natively_names: Option<ItemKind>,
10355        node: &Value,
10356        draft_slot: Option<&Option<BTreeMap<String, Value>>>,
10357    ) -> Result<Vec<DependencyEdge>, SourceError> {
10358        if direction != Direction::DependsOn {
10359            return Ok(Vec::new());
10360        }
10361        let slot = match (node.get("body"), draft_slot) {
10362            (Some(body), _) if natively_names.is_some() => {
10363                metadata_body(body.as_str().map(str::to_owned))?.1
10364            }
10365            // A draft the dependency read itself answered: this board's, or none of its.
10366            (_, Some(Some(slot))) => slot.clone(),
10367            (_, Some(None)) => return Ok(Vec::new()),
10368            _ => {
10369                let Some(item) = self.bound_item(id).await? else {
10370                    return Ok(Vec::new());
10371                };
10372                item.slot
10373            }
10374        };
10375        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
10376            .map_err(|message| SourceError::Malformed { message })
10377    }
10378
10379    /// Why `creation_owners` refuses a create filed in `target`, or `None` when it allows it —
10380    /// every create when the list is empty.
10381    fn owner_refusal(&self, target: &RepositoryTarget) -> Option<String> {
10382        if self.creation_owners.is_empty()
10383            || self
10384                .creation_owners
10385                .iter()
10386                .any(|owner| owner.eq_ignore_ascii_case(&target.owner))
10387        {
10388            return None;
10389        }
10390        Some(format!(
10391            "source {} would create the issue in repository {}, owned by {}, and its \
10392             creation_owners allows only repositories of {}",
10393            self.name,
10394            target.slug(),
10395            target.owner,
10396            self.creation_owners.join(", ")
10397        ))
10398    }
10399
10400    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
10401        self.repository
10402            .as_ref()
10403            .ok_or_else(|| SourceError::Refused {
10404                message: format!(
10405                    "source {} has no repository configured, and a GitHub Projects board has no \
10406                 repository of its own to create an issue in; set repository: owner/name on \
10407                 this source",
10408                    self.name
10409                ),
10410            })
10411    }
10412
10413    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
10414    /// states.
10415    ///
10416    /// The fallback is demanded first, whichever arm answers: a write without a configured
10417    /// repository is refused naming the field exactly as it was before the rule existed,
10418    /// so a source that could not write before cannot write now, rather than writing for
10419    /// the one item whose own field happens to decide it.
10420    ///
10421    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
10422    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
10423    /// entry owned by someone other than the owner of the parent issue's repository —
10424    /// GitHub accepts a sub-issue from another repository of the same owner and from no
10425    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
10426    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
10427    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
10428    /// and is visible to the token is checked where its node id is resolved, still before
10429    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
10430    /// looked up in a listing of the board, which can be minutes behind an issue its own
10431    /// `projectItems` already places on it — and that read answers first from this process's
10432    /// own record, so a project created moments ago in this command answers though GitHub
10433    /// has not caught up.
10434    async fn creation_target(
10435        &self,
10436        incoming: &Incoming<'_>,
10437    ) -> Result<RepositoryTarget, SourceError> {
10438        let what = format!(
10439            "{} {:?}",
10440            incoming.written.kind().describes(),
10441            incoming.title
10442        );
10443        let target = self
10444            .creation_rule(incoming.repositories, incoming.parent, &what)
10445            .await?;
10446        // The engine asks `creation_refusal` before anything of a copy is written; this holds
10447        // every other create to the same restriction, before `createIssue`.
10448        match self.owner_refusal(&target) {
10449            Some(message) => Err(SourceError::Refused { message }),
10450            None => Ok(target),
10451        }
10452    }
10453
10454    /// [`Self::creation_target`]'s rule over what it reads of the item — its `repositories`
10455    /// and the project issue it is filed under — with `what` naming the item in a refusal: the
10456    /// one place the rule is, so the create, [`TaskSource::creation_repository`] and the
10457    /// write-time read of who can read the issue ([`TaskSource::visibility`]) cannot answer
10458    /// differently.
10459    ///
10460    /// On a source declared private, an item's one named repository is read for its
10461    /// visibility first — once per engine hold, see the `visibility` module — and one that
10462    /// does not read private decides nothing: the issue is created in the parent's
10463    /// repository, else the configured one. A read that cannot be answered refuses.
10464    async fn creation_rule(
10465        &self,
10466        repositories: &[Repository],
10467        parent: Option<&NativeId>,
10468        what: &str,
10469    ) -> Result<RepositoryTarget, SourceError> {
10470        let parent = match parent {
10471            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
10472                SourceError::Refused {
10473                    message: format!(
10474                        "GitHub project issue {} was not found on the board of source {}, so {} \
10475                         cannot be filed under it",
10476                        parent.0, self.name, what
10477                    ),
10478                }
10479            })?),
10480            None => None,
10481        };
10482        let parents_repository = parent
10483            .as_ref()
10484            .map(|parent| {
10485                // A draft is on the board and so is found, but it has no repository to
10486                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
10487                // would refuse the task only once `createIssue` had made it.
10488                if parent.content_kind == ContentKind::DraftIssue {
10489                    return Err(SourceError::Refused {
10490                        message: format!(
10491                            "GitHub project item {} on the board of source {} is a draft, \
10492                             which cannot have sub-issues, so {} cannot be filed under it",
10493                            parent.id.0, self.name, what
10494                        ),
10495                    });
10496                }
10497                // An issue's repository is where a sub-issue is placed and whose owner it
10498                // is compared against, so a parent whose repository this source cannot
10499                // spell as `owner/name` — GitHub's login grammar is wider than this
10500                // source's floor — is one nothing can be filed under.
10501                parent
10502                    .own_repository
10503                    .as_ref()
10504                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
10505                    .ok_or_else(|| SourceError::Malformed {
10506                        message: format!(
10507                            "GitHub project issue {} on the board of source {} is in {}, which \
10508                             is not a {}/owner/name repository this source can place {} in",
10509                            parent.id.0,
10510                            self.name,
10511                            parent
10512                                .own_repository
10513                                .as_ref()
10514                                .map_or("no repository", Repository::as_str),
10515                            RepositoryTarget::HOST,
10516                            what
10517                        ),
10518                    })
10519            })
10520            .transpose()?;
10521        self.creation_rule_in_repository(repositories, parents_repository, what)
10522            .await
10523    }
10524
10525    async fn creation_rule_in_repository(
10526        &self,
10527        repositories: &[Repository],
10528        parents_repository: Option<RepositoryTarget>,
10529        what: &str,
10530    ) -> Result<RepositoryTarget, SourceError> {
10531        let fallback = self.configured_repository()?;
10532        match repositories {
10533            [named] => {
10534                let target =
10535                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
10536                        message: format!(
10537                            "{} names repository {}, which is not a {}/owner/name repository \
10538                             source {} can create an issue in; name one that is, or name none",
10539                            what,
10540                            named.as_str(),
10541                            RepositoryTarget::HOST,
10542                            self.name
10543                        ),
10544                    })?;
10545                // On a source declared private, an issue whose one named repository does not
10546                // read private is created where an item naming none is: the item still names
10547                // that repository in its metadata slot, and its issue stays private.
10548                if self.is_declared_private()
10549                    && self.repository_visibility(&target).await?
10550                        != onetaskgraph_plugin_api::Visibility::Private
10551                {
10552                    return Ok(parents_repository.unwrap_or_else(|| fallback.clone()));
10553                }
10554                if let Some(parents) = &parents_repository
10555                    && parents.owner != target.owner
10556                {
10557                    return Err(SourceError::Refused {
10558                        message: format!(
10559                            "{} names repository {}, owned by {}, but its project's issue is in \
10560                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
10561                             of the same owner as its parent issue; name a repository of {}, or \
10562                             name none",
10563                            what,
10564                            target.slug(),
10565                            target.owner,
10566                            parents.slug(),
10567                            parents.owner,
10568                            parents.owner
10569                        ),
10570                    });
10571                }
10572                Ok(target)
10573            }
10574            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
10575        }
10576    }
10577
10578    /// The node id of the repository `incoming` is being created in, or the refusal naming
10579    /// the item and the repository the token cannot see.
10580    ///
10581    /// Resolved once per command per repository; see [`Self::repository_cache`].
10582    async fn repository_id(
10583        &self,
10584        repository: &RepositoryTarget,
10585        incoming: &Incoming<'_>,
10586    ) -> Result<String, SourceError> {
10587        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
10588            return Ok(id);
10589        }
10590        let data = self
10591            .graphql(
10592                graphql::REPOSITORY,
10593                json!({"owner":repository.owner,"name":repository.name}),
10594            )
10595            .await?;
10596        self.repository_read(&data, repository, incoming)
10597    }
10598
10599    /// The repository's node id out of an answer carrying the `repository` root, held for
10600    /// the rest of this command, or the refusal naming the item that cannot be created in it.
10601    fn repository_read(
10602        &self,
10603        data: &Value,
10604        repository: &RepositoryTarget,
10605        incoming: &Incoming<'_>,
10606    ) -> Result<String, SourceError> {
10607        let node = data
10608            .get("repository")
10609            .filter(|value| !value.is_null())
10610            .ok_or_else(|| SourceError::Refused {
10611                message: format!(
10612                    "GitHub repository {} was not found or is not visible to the token, so {} \
10613                     {:?} cannot be created in it",
10614                    repository.slug(),
10615                    incoming.written.kind().describes(),
10616                    incoming.title
10617                ),
10618            })?;
10619        let id = required_str(node, "id")?.to_owned();
10620        self.repository_cache()?
10621            .insert(repository.clone(), id.clone());
10622        Ok(id)
10623    }
10624
10625    fn repository_cache(
10626        &self,
10627    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
10628        self.repository_cache
10629            .lock()
10630            .map_err(|_| SourceError::Unavailable {
10631                message: "this source's record of the destination repository was left \
10632                          inconsistent by an earlier failure; next: run the command again"
10633                    .into(),
10634            })
10635    }
10636
10637    /// Create or update one board item, whichever kind it is.
10638    async fn write_item(
10639        &self,
10640        incoming: &Incoming<'_>,
10641        target: Option<&NativeId>,
10642        depends_on: &[DependencyEdge],
10643    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10644        let prepared = self.prepare_write(incoming, target, depends_on).await?;
10645        let landed = match &prepared.destination {
10646            // Its content is written last, by `complete_write`, once everything else has landed.
10647            WriteDestination::Existing(item) => Landed {
10648                content_id: item.id.clone(),
10649                item_id: Some(item.item_id.clone()),
10650                url: item.url.clone(),
10651                number: item.number,
10652            },
10653            WriteDestination::New(target) => {
10654                if self.deferring()? {
10655                    self.create_filed_issue(
10656                        prepared.board.id.as_str(),
10657                        target,
10658                        incoming,
10659                        &prepared.body,
10660                    )
10661                    .await?
10662                } else {
10663                    self.create_and_file_issue(
10664                        prepared.board.id.as_str(),
10665                        target,
10666                        incoming,
10667                        &prepared.body,
10668                    )
10669                    .await?
10670                }
10671            }
10672        };
10673        self.complete_write(incoming, prepared, landed).await
10674    }
10675
10676    /// Everything one write of `incoming` reads and decides before it sends anything: what is
10677    /// there now, where a new item is created, the board's fields, and every value refused while
10678    /// nothing has been written — the item's body included.
10679    async fn prepare_write(
10680        &self,
10681        incoming: &Incoming<'_>,
10682        target: Option<&NativeId>,
10683        depends_on: &[DependencyEdge],
10684    ) -> Result<Prepared, SourceError> {
10685        // Refused before anything is read or written: a task or a project titled the way
10686        // this board spells a document would land as an issue this same source reads back
10687        // as a document, so the field this destination cannot carry is named rather than
10688        // written and silently reclassified.
10689        if let Written::Work(kind, _) = incoming.written
10690            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
10691        {
10692            return Err(SourceError::Refused {
10693                message: format!(
10694                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
10695                     spells a document, so it would read back as one rather than as a {}; \
10696                     retitle it, or copy it as a document",
10697                    kind.marker(),
10698                    self.name,
10699                    kind.marker()
10700                ),
10701            });
10702        }
10703        // The destination is read by its own id, and whether this board holds it is decided
10704        // by that read — its own `projectItems` — rather than by whether a listing of the
10705        // board happens to include it yet. See the module documentation.
10706        // An existing issue is never moved; a new one is created where the rule says — and
10707        // knowing where is what lets the board's fields and that repository's id be read
10708        // together, before anything below needs either.
10709        let destination = match target {
10710            Some(target) => {
10711                WriteDestination::Existing(Box::new(self.bound_item(target).await?.ok_or_else(
10712                    || SourceError::Refused {
10713                        message: format!("GitHub destination item {} was not found", target.0),
10714                    },
10715                )?))
10716            }
10717            None => {
10718                let target = self.creation_target(incoming).await?;
10719                self.creation_context(&target, incoming).await?;
10720                WriteDestination::New(target)
10721            }
10722        };
10723        let existing = destination.existing();
10724        let board = self
10725            .fields_for(
10726                existing,
10727                incoming.metadata.contains_key(ORIGIN_KEY),
10728                incoming.written.status().is_some(),
10729                incoming
10730                    .priority
10731                    .is_some_and(|priority| priority != Priority::None),
10732            )
10733            .await?;
10734        let status_target = incoming
10735            .written
10736            .work_status()
10737            .map(|(kind, status)| self.resolved_target(kind, status.category))
10738            .transpose()?;
10739        let column = match (incoming.written.work_status(), status_target.as_ref()) {
10740            (Some((kind, status)), Some(target)) => {
10741                self.column_for(&board.fields, kind, status.category, target)?
10742            }
10743            _ => None,
10744        };
10745        // Resolved before anything is created, for the reason the column above is: a
10746        // priority this board has no option for is refused while nothing has been written.
10747        let priority_write = match incoming.priority {
10748            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
10749            None => None,
10750        };
10751        // And for the reason the priority is: a value no projected field can hold, or a board
10752        // that cannot hold the field, is refused while nothing has been written.
10753        let (projections, projected) = self.projection_writes(
10754            &board.fields,
10755            existing.map(|item| &item.projected),
10756            incoming.metadata,
10757            None,
10758        )?;
10759        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
10760        if content_kind == ContentKind::DraftIssue {
10761            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
10762                (status_target.as_ref(), incoming.written.status())
10763            {
10764                return Err(self.closes_a_draft(status.category));
10765            }
10766            if incoming.parent.is_some() {
10767                return Err(SourceError::Refused {
10768                    message: "GitHub draft items cannot be a project's sub-issue".into(),
10769                });
10770            }
10771        }
10772        // The people it is to be assigned to are looked up — unless this process already has —
10773        // and a login GitHub does not know refused, while nothing has been written.
10774        if let Some(people) = incoming.assignees
10775            && existing.is_none_or(|item| {
10776                item.assignees
10777                    .as_deref()
10778                    .is_none_or(|held| !assignees::same_people(held, people))
10779            })
10780        {
10781            if content_kind == ContentKind::DraftIssue {
10782                return Err(self.assigns_a_draft());
10783            }
10784            self.resolve_people(people).await?;
10785            self.people_ids(
10786                people,
10787                &existing.map_or_else(
10788                    || format!("the new {}", incoming.title),
10789                    Resolved::written_name,
10790                ),
10791            )?;
10792        }
10793        match existing {
10794            Some(item) if content_kind == ContentKind::Issue => {
10795                if item.labels != incoming.labels {
10796                    return Err(SourceError::Refused {
10797                        message: "GitHub issue labels differ from the labels being written".into(),
10798                    });
10799                }
10800            }
10801            _ => {
10802                if !incoming.labels.is_empty() {
10803                    return Err(SourceError::Refused {
10804                        message: "GitHub items created by this destination carry no labels".into(),
10805                    });
10806                }
10807            }
10808        }
10809
10810        // The repository the issue really lives in is what the slot below is written against,
10811        // so a single entry that is where the issue is created travels as no key at all, and
10812        // the read side derives it back from the issue.
10813        let own_repository = match &destination {
10814            WriteDestination::Existing(item) => item.own_repository.clone(),
10815            WriteDestination::New(target) => Some(
10816                Repository::try_from(target.origin())
10817                    .map_err(|message| SourceError::Config { message })?,
10818            ),
10819        };
10820        let (native, fallback) = self
10821            .partition_edges(
10822                incoming.written.kind(),
10823                content_kind,
10824                existing.and_then(|item| item.blocked_by.as_deref()),
10825                depends_on,
10826            )
10827            .await?;
10828        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
10829        let content = match incoming.assets {
10830            Some(assets) => {
10831                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
10832                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
10833                    incoming.content.unwrap_or_default(),
10834                    &mut slot,
10835                    &uploads,
10836                );
10837                incoming.content.map(|_| rewritten)
10838            }
10839            None => incoming.content.map(str::to_owned),
10840        };
10841        // A write over an existing item keeps its `## Links` section, re-rendered from the
10842        // links it holds.
10843        let content = content.map(|content| {
10844            onetaskgraph_plugin_api::relinked(
10845                &content,
10846                existing.and_then(|item| item.body.as_deref()),
10847                incoming.metadata,
10848            )
10849        });
10850        let marked = content.as_deref().map(|content| self.marked(content));
10851        let body = compose_body(marked.as_deref(), &slot)?;
10852        // Read before anything is created, for the reason the field below is: a value
10853        // this destination cannot store has to refuse, and refusing after `createIssue`
10854        // would leave an issue behind that nothing asked for. The engine writes a
10855        // qualified id here; a caller handing this key anything else is told so rather
10856        // than having it silently stored as no origin at all.
10857        // 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.
10858        let origin = match incoming.metadata.get(ORIGIN_KEY) {
10859            None => "",
10860            Some(Value::String(origin)) => origin.as_str(),
10861            Some(other) => {
10862                return Err(SourceError::Refused {
10863                    message: format!(
10864                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
10865                         is {other}"
10866                    ),
10867                });
10868            }
10869        };
10870        // Resolved before anything is created: a board that cannot carry the copy origin
10871        // has to refuse the write, and refusing it after `createIssue` would leave an
10872        // issue behind that nothing asked for.
10873        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
10874            Some(field) => {
10875                if required_str(field, "__typename")? != "ProjectV2Field" {
10876                    return Err(SourceError::Refused {
10877                        message: format!(
10878                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
10879                        ),
10880                    });
10881                }
10882                Some(required_str(field, "id")?.to_owned())
10883            }
10884            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
10885                return Err(SourceError::Refused {
10886                    message: format!(
10887                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
10888                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
10889                         the board"
10890                    ),
10891                });
10892            }
10893            None => None,
10894        };
10895
10896        Ok(Prepared {
10897            origin: origin.to_owned(),
10898            destination,
10899            board,
10900            status_target,
10901            column,
10902            priority_write,
10903            projections,
10904            projected,
10905            content_kind,
10906            own_repository,
10907            native,
10908            slot,
10909            content,
10910            body,
10911            origin_field,
10912        })
10913    }
10914
10915    /// Everything one write does once the item it writes exists: its board fields, its
10916    /// relationships and — for an item that was already there — its content last, then what this
10917    /// process remembers of it.
10918    ///
10919    /// While this source is holding writes back — see [`TaskSource::begin_deferred_writes`] — an
10920    /// item this command created has its fields, its closing and its edges held for
10921    /// [`TaskSource::finish_writes`] rather than sent here.
10922    async fn complete_write(
10923        &self,
10924        incoming: &Incoming<'_>,
10925        prepared: Prepared,
10926        landed: Landed,
10927    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10928        let Prepared {
10929            destination,
10930            board,
10931            status_target,
10932            column,
10933            priority_write,
10934            projections,
10935            projected,
10936            content_kind,
10937            own_repository,
10938            native,
10939            slot,
10940            content,
10941            body,
10942            origin,
10943            origin_field,
10944        } = prepared;
10945        let existing = destination.existing();
10946        let origin = origin.as_str();
10947        let Landed {
10948            content_id,
10949            item_id,
10950            url,
10951            number,
10952        } = landed;
10953
10954        // Only what differs from the item as this write read it is sent: a priority the item
10955        // already holds is not, so an unchanged re-copy writes no field and a person's edit of
10956        // it since that read is left alone.
10957        let priority_write = priority_write.filter(|_| {
10958            !existing.is_some_and(|item| {
10959                incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
10960            })
10961        });
10962        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
10963        let column = column
10964            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
10965            .map(|(field, option, _)| (field, option));
10966        // Creating an item here is several calls — `createIssue`, which files it on the
10967        // board, then its board fields, the parent and the dependencies — and GitHub can fail
10968        // at any of them. Everything this source can refuse *before* the first of those is
10969        // already checked above, so what is left is GitHub itself failing part way. When it
10970        // does over an item this call created, the issue is taken back: a write that
10971        // refused must not leave an item behind that nobody asked for, and one that does
10972        // makes the retry create a second.
10973        // Whether the board-field write carrying a moved origin was answered as landing whole.
10974        // When it was refused, GitHub does not say which of its fields ran before the one that
10975        // failed, so the origin may or may not have moved.
10976        let mut origin_landed = false;
10977        // An item this command created while writes are held back has every part that can land
10978        // later held for `finish_writes` — its own create filed it on the board and under its
10979        // project already — and so does a later write of it in the same command.
10980        let holding = (existing.is_none() && self.deferring()?) || self.holds_back(&content_id)?;
10981        let landed = if holding {
10982            self.hold_back(
10983                &board.id,
10984                &content_id,
10985                existing,
10986                origin_field.as_deref(),
10987                origin,
10988                column,
10989                status_target.as_ref(),
10990                priority_write.as_ref(),
10991                &projections,
10992                &native,
10993            )
10994        } else if let Some(item_id) = item_id.as_deref() {
10995            self.finish_write(
10996                board.id.as_str(),
10997                incoming,
10998                &content_id,
10999                item_id,
11000                content_kind,
11001                existing,
11002                origin_field.as_deref(),
11003                origin,
11004                column,
11005                status_target.as_ref(),
11006                priority_write.as_ref(),
11007                &projections,
11008                &native,
11009                &mut origin_landed,
11010            )
11011            .await
11012        } else {
11013            // An issue a create filed on the board itself has no board item this process
11014            // knows until `finish_writes` reads it, so nothing but a held-back write may land
11015            // on it before then.
11016            Err(SourceError::Malformed {
11017                message: format!(
11018                    "issue {} was written before the board item GitHub filed it as was read",
11019                    content_id.0
11020                ),
11021            })
11022        };
11023        // An existing item's title, body and state go last, in one `updateIssue`, once its board
11024        // fields and its relationships have landed: a refusal of any of those then leaves its
11025        // body — and the metadata slot inside it — exactly as it stood.
11026        let landed = match (landed, existing) {
11027            (Ok(()), Some(item)) => {
11028                self.update_existing(item, incoming, &body, status_target.as_ref())
11029                    .await
11030            }
11031            (landed, _) => landed,
11032        };
11033        if let Err(error) = landed {
11034            match existing {
11035                // Best effort, and the write's own failure is what the caller is told: a
11036                // refusal naming the tidy-up would hide why the write failed at all.
11037                None => {
11038                    let _ = self.delete_issue(&content_id).await;
11039                }
11040                // The origin field is the one piece of an existing item's metadata written
11041                // before its body, so a write refused after it puts it back as it was. When
11042                // that is refused too, the write's own failure is still what the caller is
11043                // told — with what it left behind added, because the item's metadata is then
11044                // not as it stood and a caller retrying has to know which key moved.
11045                Some(item) => {
11046                    let before = item.origin.as_deref().unwrap_or("");
11047                    if let Some(field) = origin_field.as_deref()
11048                        && before != origin
11049                        && let Err(restore) = self
11050                            .set_item_field(
11051                                board.id.as_str(),
11052                                &item.item_id,
11053                                field,
11054                                json!({"text": before}),
11055                            )
11056                            .await
11057                    {
11058                        let left = if origin_landed {
11059                            format!(
11060                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
11061                                 not be put back to {before:?} ({restore}), so item {} still \
11062                                 holds {origin:?} there",
11063                                item.id.0
11064                            )
11065                        } else {
11066                            format!(
11067                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
11068                                 {origin:?}, GitHub does not say whether that part of it ran, \
11069                                 and putting it back to {before:?} was refused ({restore}), so \
11070                                 item {} holds {origin:?} or {before:?} there",
11071                                item.id.0
11072                            )
11073                        };
11074                        return Err(noting(
11075                            error,
11076                            &format!(
11077                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
11078                                 run the write again"
11079                            ),
11080                        ));
11081                    }
11082                }
11083            }
11084            return Err(error);
11085        }
11086
11087        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
11088            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
11089                self.statuses
11090                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
11091            }
11092            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
11093                self.statuses
11094                    .status(kind, written_option.as_deref(), false, None)
11095            }
11096            (Some((_, status)), _) => status.clone(),
11097            (None, _) => Status {
11098                category: StatusCategory::Unknown,
11099                name: "Open".to_owned(),
11100            },
11101        };
11102
11103        // So the rest of this command reads what it just did rather than what the board
11104        // said before it. See `remember_written` for which half takes it.
11105        let remembered = Resolved {
11106            // Empty for an issue whose board item is not read yet: `finish_writes` reads it
11107            // and `learn_item` puts it here.
11108            // 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.
11109            item_id: item_id.unwrap_or_default(),
11110            id: content_id.clone(),
11111            content_kind,
11112            kind: incoming.written.kind(),
11113            title: incoming.title.to_owned(),
11114            // The visible half of the body this write composed, split back off it the
11115            // way a read splits it — so what this record reports is what a read of the
11116            // same issue reports, rather than the person's text with the metadata slot
11117            // still on the end of it.
11118            body: self.visible(body.clone())?.0,
11119            raw_body: body.clone(),
11120            // A document has no status of its own; what it reads back as is whatever
11121            // the issue's own state says, which is what a re-read reports.
11122            status: written_status,
11123            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
11124            priority: match incoming.priority {
11125                Some(priority) => HeldPriority::Read(priority),
11126                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
11127                    item.priority.clone()
11128                }),
11129            },
11130            // What `state_input` asked for: closed for a terminal target, open for any other
11131            // status, and the issue's own state left as it was by a document write.
11132            closed: content_kind == ContentKind::Issue
11133                && match status_target.as_ref() {
11134                    Some(StatusTarget::Terminal(_, _)) => true,
11135                    Some(_) => false,
11136                    None => existing.is_some_and(|item| item.closed),
11137                },
11138            reason: match status_target.as_ref() {
11139                Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
11140                    Some(reason.reason().to_owned())
11141                }
11142                Some(_) => None,
11143                None => existing.and_then(|item| item.reason.clone()),
11144            },
11145            delivers: incoming.delivers.to_vec(),
11146            delivered_by: incoming.delivered_by.to_vec(),
11147            labels: incoming.labels.to_vec(),
11148            parent: incoming.parent.cloned(),
11149            issue_parent: None,
11150            origin: (!origin.is_empty()).then(|| origin.to_owned()),
11151            projected,
11152            number,
11153            // In the update path this is the item's own url, read off `existing` where the
11154            // record above was bound, so one expression serves both halves.
11155            url,
11156            created_at: existing.and_then(|item| item.created_at),
11157            updated_at: existing.and_then(|item| item.updated_at),
11158            // What this write moved is GitHub's to say, so a record of it gives no token until
11159            // it is read again.
11160            item_updated_at: None,
11161            comment_count: None,
11162            relationship_events: None,
11163            served_at: None,
11164            facets: None,
11165            own_repository,
11166            repositories: incoming.repositories.to_vec(),
11167            classification: incoming.classification,
11168            slot,
11169            board_id: Some(board.id.as_str().to_owned()),
11170            fields: board
11171                .fields
11172                .get("nodes")
11173                .and_then(Value::as_array)
11174                .cloned()
11175                .unwrap_or_default(),
11176            board_fields: Some(board.fields.clone()),
11177            // What this write left the relationship holding is known by id alone, and a
11178            // later read of its edges needs each far end's kind, so it reads them again.
11179            blocked_by: None,
11180            // The people it named, which its own answer was held to; none named keeps the
11181            // item's, and creates a task of an opted-in source unassigned.
11182            assignees: match incoming.assignees {
11183                Some(people) => Some(people.to_vec()),
11184                None => match existing {
11185                    Some(item) => item.assignees.clone(),
11186                    None => (self.assignees
11187                        && matches!(incoming.written, Written::Work(ItemKind::Task, _)))
11188                    .then(Vec::new),
11189                },
11190            },
11191        };
11192        self.remember_written(remembered, existing.is_none())?;
11193        Ok(onetaskgraph_plugin_api::AssetsWritten {
11194            id: content_id,
11195            content,
11196        })
11197    }
11198
11199    /// Everything a write does after the item exists: its board fields, its parent, and
11200    /// its dependencies.
11201    ///
11202    /// Split out of `write_item` so there is one place a failure past the point of no
11203    /// return is caught, rather than a tidy-up repeated at each `?` above.
11204    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
11205    // so there is one place a failure past the point of no return is caught, and its
11206    // arguments are exactly the values that tail already had in scope. Bundling them into a
11207    // struct would describe no concept — it would be "the arguments of this function" — and
11208    // would put the whole of `write_item`'s locals behind one more indirection.
11209    #[allow(clippy::too_many_arguments)]
11210    async fn finish_write(
11211        &self,
11212        board_id: &str,
11213        incoming: &Incoming<'_>,
11214        content_id: &NativeId,
11215        item_id: &str,
11216        content_kind: ContentKind,
11217        existing: Option<&Resolved>,
11218        origin_field: Option<&str>,
11219        origin: &str,
11220        column: Option<(String, String)>,
11221        status_target: Option<&StatusTarget>,
11222        priority: Option<&PriorityWrite>,
11223        projections: &[ProjectionWrite],
11224        native: &[String],
11225        origin_landed: &mut bool,
11226    ) -> Result<(), SourceError> {
11227        let (fields, clears) = field_writes(
11228            existing,
11229            origin_field,
11230            origin,
11231            column,
11232            priority,
11233            projections,
11234        );
11235        self.set_item_fields(board_id, item_id, &fields, &clears)
11236            .await?;
11237        *origin_landed = true;
11238
11239        // An existing issue closes in the `updateIssue` its write ends with; one created just
11240        // now closes here, once its option is selected.
11241        if existing.is_none()
11242            && content_kind == ContentKind::Issue
11243            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
11244        {
11245            self.update_content(
11246                ContentKind::Issue,
11247                content_id,
11248                json!({"stateInput":state_input(status_target)}),
11249            )
11250            .await?;
11251        }
11252
11253        if content_kind == ContentKind::Issue {
11254            self.reparent(
11255                existing.and_then(|item| item.parent.clone()),
11256                content_id,
11257                incoming.parent,
11258            )
11259            .await?;
11260            // A document takes part in no dependency graph, so writing one neither reads
11261            // nor changes the issue's own `blockedBy` relationships. Reconciling them
11262            // against the empty list a document write carries would *delete* whatever
11263            // relationships a person had made on that issue, which is a write nobody
11264            // asked for.
11265            if incoming.written.kind() != BoardKind::Document {
11266                let issue = match existing {
11267                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
11268                    None => Issue::Created,
11269                };
11270                self.reconcile_blocked_by(content_id, native, issue).await?;
11271            }
11272        }
11273        Ok(())
11274    }
11275
11276    /// Whether this source is holding writes back — see [`TaskSource::begin_deferred_writes`].
11277    fn deferring(&self) -> Result<bool, SourceError> {
11278        Ok(self.deferred()?.is_some())
11279    }
11280
11281    /// Whether `content` is an item this command created whose parts are held back.
11282    fn holds_back(&self, content: &NativeId) -> Result<bool, SourceError> {
11283        Ok(self
11284            .deferred()?
11285            .as_ref()
11286            .is_some_and(|deferred| deferred.holds(content)))
11287    }
11288
11289    fn deferred(&self) -> Result<std::sync::MutexGuard<'_, Option<Deferred>>, SourceError> {
11290        self.deferred.lock().map_err(|_| SourceError::Unavailable {
11291            message: "this source's record of the writes it is holding back was left \
11292                      inconsistent by an earlier failure; next: run the command again"
11293                .into(),
11294        })
11295    }
11296
11297    /// Creates one issue filed on the board and under its project by the `createIssue` itself —
11298    /// `projectV2Ids` naming the board and `parentIssueId` the project — which is what a copy
11299    /// creates with: one request where adding it to the board and filing it under its project
11300    /// were two more. GitHub adds the board item after answering, so its id is not known here;
11301    /// [`TaskSource::finish_writes`] reads it.
11302    async fn create_filed_issue(
11303        &self,
11304        board_id: &str,
11305        repository: &RepositoryTarget,
11306        incoming: &Incoming<'_>,
11307        body: &Option<String>,
11308    ) -> Result<Landed, SourceError> {
11309        let repository_id = self.repository_id(repository, incoming).await?;
11310        let mut input = filed_input(&repository_id, board_id, incoming, body);
11311        self.assigning(&mut input, incoming)?;
11312        let data = match self
11313            .graphql(graphql::CREATE_ISSUE, json!({"input": &input}))
11314            .await
11315        {
11316            Ok(data) => data,
11317            Err(error) => return Err(self.refusing_people(&input, error)),
11318        };
11319        let created = data
11320            .pointer("/createIssue/issue")
11321            .filter(|value| !value.is_null())
11322            .ok_or_else(|| SourceError::Malformed {
11323                message: "GitHub issue creation returned no issue".into(),
11324            })?;
11325        let landed = self.created_landed(created).await?;
11326        // An issue GitHub created assigned otherwise than asked is refused and taken back; a
11327        // delete GitHub refuses too is answered beside the refusal, naming the issue left.
11328        if let Err(error) = self.assigned_as_asked(&input, Some(created)) {
11329            return Err(self.taken_back(&landed.content_id, created, error).await);
11330        }
11331        Ok(landed)
11332    }
11333
11334    /// Keep the address `createIssue` answered for the issue `content`, for
11335    /// [`TaskSource::written_location`].
11336    fn note_written(&self, content: &NativeId, url: Option<&str>) {
11337        if let Some(url) = url {
11338            self.written
11339                .lock()
11340                .unwrap_or_else(std::sync::PoisonError::into_inner)
11341                .insert(content.clone(), url.to_owned());
11342        }
11343    }
11344
11345    /// What one `createIssue` answered, as the item it created — or, when its number cannot be
11346    /// read, the refusal, after taking the issue back.
11347    async fn created_landed(&self, created: &Value) -> Result<Landed, SourceError> {
11348        let content_id = NativeId(required_nonblank_str(created, "id")?.to_owned());
11349        let url = optional_str(created, "url")?.map(str::to_owned);
11350        let number = match created_issue_number(created) {
11351            Ok(number) => number,
11352            Err(error) => {
11353                let _ = self.delete_issue(&content_id).await;
11354                return Err(error);
11355            }
11356        };
11357        self.note_written(&content_id, url.as_deref());
11358        Ok(Landed {
11359            content_id,
11360            item_id: None,
11361            url,
11362            number,
11363        })
11364    }
11365
11366    /// Hold back everything a write of the created item `content` would send after creating it,
11367    /// for [`TaskSource::finish_writes`]: the same field values, closing and edges
11368    /// `finish_write` sends, the parent excepted, which its create already named. A later write
11369    /// of the same item replaces what it changes.
11370    // 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.
11371    #[allow(clippy::too_many_arguments)]
11372    fn hold_back(
11373        &self,
11374        board_id: &BoardId,
11375        content: &NativeId,
11376        existing: Option<&Resolved>,
11377        origin_field: Option<&str>,
11378        origin: &str,
11379        column: Option<(String, String)>,
11380        status_target: Option<&StatusTarget>,
11381        priority: Option<&PriorityWrite>,
11382        projections: &[ProjectionWrite],
11383        native: &[String],
11384    ) -> Result<(), SourceError> {
11385        let status = column.map(|(field, option)| {
11386            let name = self
11387                .fields_cache()
11388                .ok()
11389                .and_then(|fields| {
11390                    fields.as_ref().and_then(|board| {
11391                        option_name(&board.fields, &field, &option).map(str::to_owned)
11392                    })
11393                })
11394                .unwrap_or_default();
11395            (field, option, name)
11396        });
11397        let (fields, clears) =
11398            field_writes(existing, origin_field, origin, None, priority, projections);
11399        let close = match status_target {
11400            Some(StatusTarget::Terminal(_, reason)) => Some(*reason),
11401            _ => None,
11402        };
11403        let mut deferred = self.deferred()?;
11404        let deferred = deferred.get_or_insert_with(Deferred::default);
11405        if let Deferred::Nothing = deferred {
11406            *deferred = Deferred::Held {
11407                board: board_id.clone(),
11408                created: Vec::new(),
11409            };
11410        }
11411        let Deferred::Held { created, .. } = deferred else {
11412            unreachable!("held back on the line above");
11413        };
11414        let held = match created.iter().position(|held| held.content == *content) {
11415            Some(at) => &mut created[at],
11416            None => {
11417                created.push(HeldBack {
11418                    content: content.clone(),
11419                    fields: Vec::new(),
11420                    status: None,
11421                    clears: Vec::new(),
11422                    close: None,
11423                    edges: Vec::new(),
11424                });
11425                created.last_mut().expect("just held")
11426            }
11427        };
11428        for (field, value) in fields {
11429            held.fields.retain(|(held, _)| *held != field);
11430            held.clears.retain(|held| *held != field);
11431            held.fields.push((field, value));
11432        }
11433        for field in clears {
11434            held.fields.retain(|(held, _)| held != field);
11435            if !held.clears.iter().any(|held| held == field) {
11436                held.clears.push(field.to_owned());
11437            }
11438        }
11439        if status.is_some() {
11440            held.status = status;
11441        }
11442        if close.is_some() {
11443            held.close = close;
11444        }
11445        // Nothing of an item created in this command has been sent but its create, so the edges
11446        // it is to have are exactly the ones its latest write names.
11447        held.edges = native.iter().map(|far| NativeId(far.clone())).collect();
11448        Ok(())
11449    }
11450
11451    /// Create every task of `writes`, together, filed on the board and under each one's
11452    /// project: [`graphql::CREATE_ISSUES`], [`MUTATION_BATCH`] to a request.
11453    ///
11454    /// Each is prepared as [`Self::write_item`] prepares one — every refusal a write owes made
11455    /// before the first request — then created, then completed with its parts held back. A
11456    /// request refused part way leaves the issues it did create, which are taken back, with the
11457    /// rest of this call's, before the refusal is answered.
11458    async fn create_together(
11459        &self,
11460        writes: &[ItemWrite<Task>],
11461    ) -> Result<Vec<NativeId>, SourceError> {
11462        let incomings: Vec<Incoming<'_>> = writes
11463            .iter()
11464            .map(|write| self.task_incoming(write, None))
11465            .collect::<Result<_, _>>()?;
11466        // Every person these creates assign, looked up together rather than one write at a time.
11467        let people: Vec<String> = writes
11468            .iter()
11469            .filter_map(|write| write.item.assignees.clone())
11470            .flatten()
11471            .collect();
11472        self.resolve_people(&people).await?;
11473        let mut prepared = Vec::with_capacity(writes.len());
11474        for (incoming, write) in incomings.iter().zip(writes) {
11475            if write.target.is_some() {
11476                return Err(SourceError::Malformed {
11477                    message: "a write naming its target was handed to a batch of creates".into(),
11478                });
11479            }
11480            prepared.push(
11481                self.prepare_write(incoming, None, &write.depends_on)
11482                    .await?,
11483            );
11484        }
11485        let mut inputs = Vec::with_capacity(prepared.len());
11486        for (incoming, prepared) in incomings.iter().zip(&prepared) {
11487            let WriteDestination::New(target) = &prepared.destination else {
11488                return Err(SourceError::Malformed {
11489                    message: "an update was decided for a write handed to a batch of creates"
11490                        .into(),
11491                });
11492            };
11493            let repository_id = self.repository_id(target, incoming).await?;
11494            let mut input = filed_input(
11495                &repository_id,
11496                prepared.board.id.as_str(),
11497                incoming,
11498                &prepared.body,
11499            );
11500            self.assigning(&mut input, incoming)?;
11501            inputs.push(input);
11502        }
11503        let mut landed: Vec<Landed> = Vec::with_capacity(inputs.len());
11504        let refused = 'create: {
11505            for chunk in inputs.chunks(MUTATION_BATCH) {
11506                let mut variables = serde_json::Map::new();
11507                for slot in 0..MUTATION_BATCH {
11508                    variables.insert(
11509                        format!("create{slot}"),
11510                        chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11511                    );
11512                    variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11513                }
11514                let data = match self
11515                    .graphql(graphql::CREATE_ISSUES, Value::Object(variables))
11516                    .await
11517                {
11518                    Ok(data) => data,
11519                    Err(error) => break 'create Some(error),
11520                };
11521                let mut refusal = data.get(ANSWER_ERRORS).map(|errors| {
11522                    let said: Vec<&str> = errors
11523                        .as_array()
11524                        .into_iter()
11525                        .flatten()
11526                        .filter_map(|error| error.get("message").and_then(Value::as_str))
11527                        .collect();
11528                    SourceError::Refused {
11529                        message: if said.is_empty() {
11530                            "GitHub returned GraphQL errors".to_owned()
11531                        } else {
11532                            said.join("; ")
11533                        },
11534                    }
11535                });
11536                for (slot, input) in chunk.iter().enumerate() {
11537                    match data
11538                        .pointer(&format!("/create{slot}/issue"))
11539                        .filter(|value| !value.is_null())
11540                    {
11541                        Some(created) => match self.created_landed(created).await {
11542                            Ok(landed_one) => {
11543                                landed.push(landed_one);
11544                                // Taken back with the rest, as any refusal here is.
11545                                if let Err(error) = self.assigned_as_asked(input, Some(created)) {
11546                                    refusal.get_or_insert(error);
11547                                }
11548                            }
11549                            Err(error) => {
11550                                refusal.get_or_insert(error);
11551                            }
11552                        },
11553                        // An alias GitHub refused answers nothing, and the refusal says why.
11554                        None => {
11555                            refusal.get_or_insert(SourceError::Malformed {
11556                                message: "GitHub issue creation returned no issue".into(),
11557                            });
11558                        }
11559                    }
11560                }
11561                if let Some(error) = refusal {
11562                    break 'create Some(error);
11563                }
11564            }
11565            None
11566        };
11567        if let Some(error) = refused {
11568            // None of these ids reaches the caller, so an issue GitHub will not take back is
11569            // named here or by nobody.
11570            let ids: Vec<&NativeId> = landed.iter().map(|created| &created.content_id).collect();
11571            let left = self.delete_issues(&ids).await;
11572            let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11573            return Err(left_behind(error, &left));
11574        }
11575        let created: Vec<NativeId> = landed
11576            .iter()
11577            .map(|landed| landed.content_id.clone())
11578            .collect();
11579        let mut ids = Vec::with_capacity(landed.len());
11580        for ((incoming, prepared), landed) in incomings.iter().zip(prepared).zip(landed) {
11581            match self.complete_write(incoming, prepared, landed).await {
11582                Ok(written) => ids.push(written.id),
11583                // 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.
11584                Err(error) => {
11585                    let ids: Vec<&NativeId> = created.iter().collect();
11586                    let left = self.delete_issues(&ids).await;
11587                    let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11588                    return Err(left_behind(error, &left));
11589                }
11590            }
11591        }
11592        Ok(ids)
11593    }
11594
11595    /// Hold back the update of the existing issue `issue` — `members`, what its own
11596    /// [`graphql::UPDATE_ISSUE`] would send beside its id — while writes are being held back — so the updates a copy makes one after
11597    /// another go to GitHub as one request. Whatever this source held of the issue is dropped
11598    /// as the mutation itself would drop it. Answers whether it was held: never while a write
11599    /// the store's cache expects runs, which records each mutation's own answer, and never for
11600    /// an issue an update is already held for, which that one is sent ahead of.
11601    fn hold_update(&self, issue: &NativeId, members: &Value) -> Result<bool, SourceError> {
11602        if self.expected.journaling() || self.deferred()?.is_none() {
11603            return Ok(false);
11604        }
11605        let mut held = self
11606            .held_updates
11607            .lock()
11608            .map_err(|_| SourceError::Unavailable {
11609                message: "issue updates held back were left inconsistent; run the command again"
11610                    .into(),
11611            })?;
11612        if held.iter().any(|earlier| earlier.issue == *issue) {
11613            return Ok(false);
11614        }
11615        let named = |value: &str| issue.0 == value;
11616        self.resolved_cache()?
11617            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11618        self.read_ahead()?.retain(|id, item| {
11619            !named(&id.0) && !item.as_ref().is_some_and(|item| named(&item.item_id))
11620        });
11621        self.carried()?
11622            .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11623        held.push(HeldUpdate::new(issue, members));
11624        Ok(true)
11625    }
11626
11627    /// Send every update [`Self::hold_update`] held back: one alone as [`graphql::UPDATE_ISSUE`]
11628    /// sends it, several as aliased `updateIssue`s of [`graphql::UPDATE_ISSUES`],
11629    /// [`MUTATION_BATCH`] to a request. Each counts one write, as it would alone.
11630    async fn send_held_updates(&self) -> Result<(), SourceError> {
11631        let held = std::mem::take(&mut *self.held_updates.lock().map_err(|_| {
11632            SourceError::Unavailable {
11633                message: "issue updates held back were left inconsistent; run the command again"
11634                    .into(),
11635            }
11636        })?);
11637        if let [one] = held.as_slice() {
11638            return self
11639                .send_update(
11640                    graphql::UPDATE_ISSUE,
11641                    "/updateIssue/issue",
11642                    &one.issue,
11643                    one.input.clone(),
11644                )
11645                .await;
11646        }
11647        for chunk in held.chunks(MUTATION_BATCH) {
11648            let mut variables = serde_json::Map::new();
11649            for slot in 0..MUTATION_BATCH {
11650                variables.insert(
11651                    format!("update{slot}"),
11652                    chunk.get(slot).unwrap_or(&chunk[0]).input.clone(),
11653                );
11654                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11655            }
11656            let data = match self
11657                .graphql(graphql::UPDATE_ISSUES, Value::Object(variables))
11658                .await
11659            {
11660                Ok(data) => data,
11661                Err(error) => {
11662                    for held in chunk {
11663                        self.forget_people(&held.input);
11664                    }
11665                    return Err(error);
11666                }
11667            };
11668            let inputs: Vec<&Value> = chunk.iter().map(|held| &held.input).collect();
11669            check_aliased_answers(
11670                graphql::UPDATE_ISSUES,
11671                &data,
11672                "update",
11673                &inputs,
11674                &[("issue", "id")],
11675            )?;
11676            for (slot, held) in chunk.iter().enumerate() {
11677                self.assigned_as_asked(&held.input, data.pointer(&format!("/update{slot}/issue")))?;
11678            }
11679        }
11680        Ok(())
11681    }
11682
11683    /// Send everything held back since [`TaskSource::begin_deferred_writes`]: one read of the
11684    /// created items' board items, then their field values and edges together in requests of
11685    /// [`WRITE_BATCH`], then their closes in requests of [`MUTATION_BATCH`]. Anything refused
11686    /// takes back every issue held, [`DELETE_BATCH`] to a request, and answers the refusal.
11687    async fn send_held_back(&self) -> Result<(), SourceError> {
11688        let Some(Deferred::Held { board, created }) = self.deferred()?.take() else {
11689            return Ok(());
11690        };
11691        if created.is_empty() {
11692            return Ok(());
11693        }
11694        let sent = self.send_deferred(board.as_str(), &created).await;
11695        if let Err(error) = sent {
11696            // Best effort, and the failure is what the caller is told, as a write of one item
11697            // that fails part way takes back what it created: the copy that held these back
11698            // journalled every one, so one this cannot take back its undo tries again, and says
11699            // so when it cannot either.
11700            let ids: Vec<&NativeId> = created.iter().map(|held| &held.content).collect();
11701            let _ = self.delete_issues(&ids).await;
11702            return Err(error);
11703        }
11704        Ok(())
11705    }
11706
11707    /// The requests [`Self::send_held_back`] makes.
11708    async fn send_deferred(&self, board: &str, created: &[HeldBack]) -> Result<(), SourceError> {
11709        let items = self.created_board_items(board, created).await?;
11710        let mut fields: Vec<Value> = Vec::new();
11711        let mut closes: Vec<Value> = Vec::new();
11712        let mut edges: Vec<Value> = Vec::new();
11713        for held in created {
11714            let (item, status) = &items[&held.content];
11715            let mut values = held.fields.clone();
11716            // A status the board already shows — set by a workflow a person turned on to file
11717            // new items at it — is not written again.
11718            if let Some((field, option, name)) = &held.status
11719                && status.as_deref() != Some(name.as_str())
11720            {
11721                values.insert(0, (field.clone(), json!({"singleSelectOptionId": option})));
11722            }
11723            for (field, value) in values {
11724                fields.push(json!({"projectId":board,"itemId":item,"fieldId":field,"value":value}));
11725            }
11726            if let Some(reason) = held.close {
11727                closes.push(json!({"id": held.content.0, "stateInput": closed_input(reason)}));
11728            }
11729            for far in &held.edges {
11730                edges.push(json!({"issueId": held.content.0, "blockingIssueId": far.0}));
11731            }
11732            if !held.clears.is_empty() {
11733                let clears: Vec<&str> = held.clears.iter().map(String::as_str).collect();
11734                self.set_item_fields(board, item, &[], &clears).await?;
11735            }
11736        }
11737        // Each alias answers with what it wrote, which is what its input named: the board item a
11738        // field value is set on, the issue closed, the issue blocked and the one blocking it.
11739        let field_answer: &[(&str, &str)] = &[("projectV2Item", "itemId")];
11740        let close_answer: &[(&str, &str)] = &[("issue", "id")];
11741        let block_answer: &[(&str, &str)] =
11742            &[("issue", "issueId"), ("blockingIssue", "blockingIssueId")];
11743        // Every field value, then every edge, as one ordered list sent `WRITE_BATCH` aliases to a
11744        // request: a request holding both kinds is the one document carrying both, so a plan's
11745        // values and edges cost one request where they fit in one.
11746        let writes: Vec<HeldWrite<'_>> = fields
11747            .iter()
11748            .map(HeldWrite::Field)
11749            .chain(edges.iter().map(HeldWrite::Edge))
11750            .collect();
11751        for chunk in writes.chunks(WRITE_BATCH) {
11752            let values: Vec<&Value> = chunk
11753                .iter()
11754                .filter_map(|write| match write {
11755                    HeldWrite::Field(input) => Some(*input),
11756                    HeldWrite::Edge(_) => None,
11757                })
11758                .collect();
11759            let blocks: Vec<&Value> = chunk
11760                .iter()
11761                .filter_map(|write| match write {
11762                    HeldWrite::Edge(input) => Some(*input),
11763                    HeldWrite::Field(_) => None,
11764                })
11765                .collect();
11766            let (document, sends) = match (values.is_empty(), blocks.is_empty()) {
11767                (false, false) => (graphql::WRITE_FIELDS_AND_EDGES, ["sendField", "sendBlock"]),
11768                (false, true) => (graphql::WRITE_FIELD_VALUES, ["send", "send"]),
11769                _ => (graphql::ADD_BLOCKED_BYS, ["send", "send"]),
11770            };
11771            let mut variables = serde_json::Map::new();
11772            let kinds = [
11773                ("field", &values, sends[0], field_answer),
11774                ("block", &blocks, sends[1], block_answer),
11775            ];
11776            for (prefix, inputs, send, _) in &kinds {
11777                if inputs.is_empty() {
11778                    continue;
11779                }
11780                for slot in 0..WRITE_BATCH {
11781                    variables.insert(
11782                        format!("{prefix}{slot}"),
11783                        (*inputs.get(slot).unwrap_or(&inputs[0])).clone(),
11784                    );
11785                    variables.insert(format!("{send}{slot}"), json!(slot < inputs.len()));
11786                }
11787            }
11788            let data = self.graphql(document, Value::Object(variables)).await?;
11789            for (prefix, inputs, _, answers) in kinds {
11790                check_aliased_answers(document, &data, prefix, inputs, answers)?;
11791            }
11792        }
11793        for chunk in closes.chunks(MUTATION_BATCH) {
11794            let mut variables = serde_json::Map::new();
11795            for slot in 0..MUTATION_BATCH {
11796                variables.insert(
11797                    format!("close{slot}"),
11798                    chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11799                );
11800                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11801            }
11802            let data = self
11803                .graphql(graphql::CLOSE_ISSUES, Value::Object(variables))
11804                .await?;
11805            let inputs: Vec<&Value> = chunk.iter().collect();
11806            check_aliased_answers(graphql::CLOSE_ISSUES, &data, "close", &inputs, close_answer)?;
11807        }
11808        for held in created {
11809            self.learn_item(&held.content, &items[&held.content].0)?;
11810        }
11811        Ok(())
11812    }
11813
11814    /// The board item each of `created` is on this board, with its `Status` as the board set it:
11815    /// [`graphql::CREATED_BOARD_ITEMS`], read again — after a wait of a second, then two, four,
11816    /// eight and sixteen — for only those GitHub has not filed yet.
11817    ///
11818    /// **Membership is never assumed.** `createIssue`'s `projectV2Ids` files an issue some time
11819    /// after GitHub answered the create — the feasibility spike saw 0.45 to 1.84 seconds, and no
11820    /// create answer carrying the item — so only a read says it is there. One still absent once
11821    /// those reads are spent is filed with [`graphql::ADD_TO_BOARD`], one write each; GitHub
11822    /// filing it meanwhile refuses that "Content already exists in this project", which says it
11823    /// is placed, and one more read finds the item rather than a second one being made.
11824    async fn created_board_items(
11825        &self,
11826        board: &str,
11827        created: &[HeldBack],
11828    ) -> Result<BTreeMap<NativeId, (String, Option<String>)>, SourceError> {
11829        let mut found: BTreeMap<NativeId, (String, Option<String>)> = BTreeMap::new();
11830        let unfiled = |found: &BTreeMap<NativeId, (String, Option<String>)>| -> Vec<NativeId> {
11831            created
11832                .iter()
11833                .map(|held| held.content.clone())
11834                .filter(|id| !found.contains_key(id))
11835                .collect()
11836        };
11837        let mut wait = Duration::from_secs(1);
11838        for attempt in 0..6 {
11839            let absent = unfiled(&found);
11840            if absent.is_empty() {
11841                return Ok(found);
11842            }
11843            if attempt > 0 {
11844                self.clock
11845                    .sleep_because(wait, pacing::cause::BOARD_FILING)
11846                    .await;
11847                wait = wait.saturating_mul(2);
11848            }
11849            self.read_created_items(board, &absent, &mut found).await?;
11850        }
11851        let mut placed_meanwhile = Vec::new();
11852        for id in unfiled(&found) {
11853            match self
11854                .graphql(
11855                    graphql::ADD_TO_BOARD,
11856                    json!({"input":{"projectId":board,"contentId":id.0}}),
11857                )
11858                .await
11859            {
11860                Ok(added) => {
11861                    let item = added
11862                        .pointer("/addProjectV2ItemById/item")
11863                        .filter(|value| !value.is_null())
11864                        .ok_or_else(|| SourceError::Malformed {
11865                            message: "GitHub board addition returned no project item".into(),
11866                        })?;
11867                    found.insert(id, (required_nonblank_str(item, "id")?.to_owned(), None));
11868                }
11869                Err(error) if already_on_the_board(&error) => placed_meanwhile.push(id),
11870                Err(error) => return Err(error),
11871            }
11872        }
11873        if !placed_meanwhile.is_empty() {
11874            self.read_created_items(board, &placed_meanwhile, &mut found)
11875                .await?;
11876        }
11877        let absent = unfiled(&found);
11878        if absent.is_empty() {
11879            return Ok(found);
11880        }
11881        let absent: Vec<&str> = absent.iter().map(|id| id.0.as_str()).collect();
11882        Err(SourceError::Unavailable {
11883            message: format!(
11884                "GitHub created {} filed on board {board} and, after 31 seconds and adding {} \
11885                 to it, still answers {} on no item of it: {}; next: run the copy again once \
11886                 GitHub has caught up",
11887                if absent.len() == 1 {
11888                    "an issue"
11889                } else {
11890                    "issues"
11891                },
11892                if absent.len() == 1 { "it" } else { "them" },
11893                if absent.len() == 1 { "it" } else { "them" },
11894                absent.join(", ")
11895            ),
11896        })
11897    }
11898
11899    /// One [`graphql::CREATED_BOARD_ITEMS`] read of each of `ids`, [`COPY_DETAIL_BATCH`] to a
11900    /// request, adding to `found` the board item of every one GitHub has filed on `board`.
11901    async fn read_created_items(
11902        &self,
11903        board: &str,
11904        ids: &[NativeId],
11905        found: &mut BTreeMap<NativeId, (String, Option<String>)>,
11906    ) -> Result<(), SourceError> {
11907        for chunk in ids.chunks(COPY_DETAIL_BATCH) {
11908            let mut variables = serde_json::Map::new();
11909            for slot in 0..COPY_DETAIL_BATCH {
11910                variables.insert(
11911                    format!("id{slot}"),
11912                    json!(chunk.get(slot).unwrap_or(&chunk[0]).0),
11913                );
11914                variables.insert(format!("read{slot}"), json!(slot < chunk.len()));
11915            }
11916            variables.insert("boardItems".into(), json!(BOARD_ITEMS_PAGE_SIZE));
11917            variables.insert("statusName".into(), json!(STATUS_FIELD));
11918            let data = self
11919                .graphql(graphql::CREATED_BOARD_ITEMS, Value::Object(variables))
11920                .await?;
11921            for (slot, id) in chunk.iter().enumerate() {
11922                // An answer for another issue than the one asked about says nothing of
11923                // where that one is filed, and its board item is not this one's.
11924                let node = data.get(format!("i{slot}")).filter(|node| !node.is_null());
11925                let named = node.map(|node| node.get("id").and_then(Value::as_str));
11926                if let Some(named) = named
11927                    && named != Some(id.0.as_str())
11928                {
11929                    return Err(SourceError::Malformed {
11930                        message: format!(
11931                            "GitHub answered the board items of {} when asked for those of {}",
11932                            named.unwrap_or("an issue it did not name"),
11933                            id.0
11934                        ),
11935                    });
11936                }
11937                // An issue GitHub does not answer yet is not filed yet; one it answers is
11938                // read whole, its connection, each board item's board and its status, and
11939                // any of them unreadable is refused rather than read as not filed.
11940                let Some(node) = node else { continue };
11941                let malformed = |what: &str| SourceError::Malformed {
11942                    message: format!("GitHub answered the board items of {} with {what}", id.0),
11943                };
11944                let nodes = node
11945                    .pointer("/projectItems/nodes")
11946                    .and_then(Value::as_array)
11947                    .ok_or_else(|| malformed("no list of board items"))?;
11948                let mut item = None;
11949                for node in nodes {
11950                    let on = node
11951                        .pointer("/project/id")
11952                        .and_then(Value::as_str)
11953                        .ok_or_else(|| malformed("a board item naming no board"))?;
11954                    if on == board {
11955                        item = Some(node);
11956                    }
11957                }
11958                if let Some(item) = item {
11959                    let status = match item.get("status") {
11960                        None | Some(Value::Null) => None,
11961                        Some(status) => Some(
11962                            status
11963                                .get("name")
11964                                .and_then(Value::as_str)
11965                                .ok_or_else(|| malformed("a status that names no option"))?
11966                                .to_owned(),
11967                        ),
11968                    };
11969                    found.insert(
11970                        id.clone(),
11971                        (required_nonblank_str(item, "id")?.to_owned(), status),
11972                    );
11973                }
11974            }
11975        }
11976        Ok(())
11977    }
11978
11979    /// The board item id of `content`, which this process created and remembered without one,
11980    /// in every half of its own view.
11981    fn learn_item(&self, content: &NativeId, item: &str) -> Result<(), SourceError> {
11982        if let Some(held) = self.resolved_cache()?.get_mut(content) {
11983            held.item_id = item.to_owned();
11984        }
11985        if let Some(held) = self.created()?.iter_mut().find(|held| held.id == *content) {
11986            held.item_id = item.to_owned();
11987        }
11988        Ok(())
11989    }
11990
11991    /// Ask together which items each of `origins` names, and what creating an item reads; see
11992    /// [`TaskSource::read_ahead_origins_for_copy`]. An origin whose answer runs past its first
11993    /// page is left to its own lookup.
11994    async fn origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
11995        if self.repository.is_none() {
11996            return Ok(());
11997        }
11998        let unasked: Vec<&String> = {
11999            let held = self.narrowed_cache()?;
12000            let mut unasked: Vec<&String> = Vec::new();
12001            for origin in origins {
12002                if !held.contains_key(&Narrowing::Origin(origin.clone()).key())
12003                    && !unasked.contains(&origin)
12004                {
12005                    unasked.push(origin);
12006                }
12007            }
12008            unasked
12009        };
12010        if unasked.is_empty() {
12011            return Ok(());
12012        }
12013        // One batch is held back for the read ahead that follows, which may carry a project's
12014        // members beside it in the same request.
12015        if unasked.len() <= ORIGIN_BATCH {
12016            let mut pending =
12017                self.pending_origins
12018                    .lock()
12019                    .map_err(|_| SourceError::Unavailable {
12020                        message:
12021                            "origins held for a read ahead were left inconsistent; run the command \
12022                          again"
12023                                .into(),
12024                    })?;
12025            for origin in unasked {
12026                if !pending.contains(origin) {
12027                    pending.push(origin.clone());
12028                }
12029            }
12030            return Ok(());
12031        }
12032        let unasked: Vec<String> = unasked.into_iter().cloned().collect();
12033        self.send_origin_lookups(&unasked, None).await
12034    }
12035
12036    /// Hold, for [`TaskSource::member_classifications`] of `project`, each member's
12037    /// classification and repositories as the members page `data` carries them — when that page
12038    /// is every member, each one names `project` as its parent, and every record it holds is
12039    /// readable. Any other page holds nothing, so the members are read as they always were.
12040    fn hold_member_classes(&self, project: &NativeId, data: &Value) -> Result<(), SourceError> {
12041        let Some(page) = data
12042            .pointer("/members/subIssues")
12043            .filter(|page| !page.is_null())
12044        else {
12045            return Ok(());
12046        };
12047        if page
12048            .pointer("/pageInfo/hasNextPage")
12049            .and_then(Value::as_bool)
12050            != Some(false)
12051        {
12052            return Ok(());
12053        }
12054        let Some(nodes) = page.get("nodes").and_then(Value::as_array) else {
12055            return Ok(());
12056        };
12057        let mut members = Vec::with_capacity(nodes.len());
12058        for node in nodes {
12059            if node.pointer("/parent/id").and_then(Value::as_str) != Some(project.0.as_str()) {
12060                return Ok(());
12061            }
12062            let Some(body) = node.get("body").and_then(Value::as_str) else {
12063                return Ok(());
12064            };
12065            // Every node here is filed under `project`, which `BoardKind::of` reads as one of its
12066            // tasks whatever its marker says, so each is a member the full read would classify.
12067            let Ok((_, slot)) = metadata_body(Some(body.to_owned())) else {
12068                return Ok(());
12069            };
12070            let Ok(classification) = Classification::from_metadata(&slot) else {
12071                return Ok(());
12072            };
12073            let repositories = if slot.contains_key(Repository::METADATA_KEY) {
12074                let Ok(repositories) = Repository::from_metadata(&slot) else {
12075                    return Ok(());
12076                };
12077                repositories
12078            } else {
12079                let Some(origin) = node
12080                    .pointer("/repository/nameWithOwner")
12081                    .and_then(Value::as_str)
12082                else {
12083                    return Ok(());
12084                };
12085                let Ok(own) = Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST))
12086                else {
12087                    return Ok(());
12088                };
12089                vec![own]
12090            };
12091            members.push((classification, repositories));
12092        }
12093        self.member_classes
12094            .lock()
12095            .map_err(|_| SourceError::Unavailable {
12096                message: "a project's members held for its classification were left \
12097                          inconsistent; run the command again"
12098                    .into(),
12099            })?
12100            .insert(project.clone(), members);
12101        Ok(())
12102    }
12103
12104    /// Send every origin held back for the read ahead, beside the members of `members` when it
12105    /// names a project — or nothing, when none is held.
12106    async fn send_pending_origins(&self, members: Option<&NativeId>) -> Result<(), SourceError> {
12107        let pending = std::mem::take(&mut *self.pending_origins.lock().map_err(|_| {
12108            SourceError::Unavailable {
12109                message: "origins held for a read ahead were left inconsistent; run the command \
12110                          again"
12111                    .into(),
12112            }
12113        })?);
12114        if pending.is_empty() {
12115            return Ok(());
12116        }
12117        self.send_origin_lookups(&pending, members).await
12118    }
12119
12120    /// [`graphql::ORIGIN_LOOKUPS`] of `unasked`, [`ORIGIN_BATCH`] to a request — the first one
12121    /// [`graphql::ORIGIN_LOOKUPS_WITH_MEMBERS`], carrying the members of `members`, when it names
12122    /// a project — holding each origin's answer and, from a members page that is the whole of
12123    /// them, each member's classification.
12124    async fn send_origin_lookups(
12125        &self,
12126        unasked: &[String],
12127        members: Option<&NativeId>,
12128    ) -> Result<(), SourceError> {
12129        let Some(repository) = self.repository.clone() else {
12130            return Ok(());
12131        };
12132        let mut members = members;
12133        for chunk in unasked.chunks(ORIGIN_BATCH) {
12134            let mut variables = json!({"owner":self.owner,"number":self.project_number,
12135                "type":"ISSUE","lookupFirst":ORIGIN_LOOKUPS_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
12136                "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true,
12137                "repositoryOwner":repository.owner,"repositoryName":repository.name});
12138            for slot in 0..ORIGIN_BATCH {
12139                let origin = chunk.get(slot).unwrap_or(&chunk[0]);
12140                variables[format!("filter{slot}")] =
12141                    json!(format!("{ORIGIN_FIELD}:{}", quoted(origin)));
12142                variables[format!("search{slot}")] = json!(
12143                    self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))))
12144                );
12145                variables[format!("ask{slot}")] = json!(slot < chunk.len());
12146            }
12147            let project = members.take();
12148            let document = match project {
12149                Some(project) => {
12150                    variables["project"] = json!(project.0);
12151                    variables["memberFirst"] = json!(MEMBER_PAGE_SIZE);
12152                    graphql::ORIGIN_LOOKUPS_WITH_MEMBERS
12153                }
12154                None => graphql::ORIGIN_LOOKUPS,
12155            };
12156            let data = self.graphql(document, variables).await?;
12157            self.fields_read(&data)?;
12158            if let Some(project) = project {
12159                self.hold_member_classes(project, &data)?;
12160            }
12161            if let Some(node) = data.get("repository").filter(|value| !value.is_null()) {
12162                let id = required_nonblank_str(node, "id")?.to_owned();
12163                self.repository_cache()?.insert(repository.clone(), id);
12164                if let Some(numeric) = node
12165                    .get("databaseId")
12166                    .and_then(Value::as_u64)
12167                    .and_then(std::num::NonZeroU64::new)
12168                {
12169                    self.numeric_repositories
12170                        .lock()
12171                        .await
12172                        .insert(repository.clone(), numeric);
12173                }
12174            }
12175            for (slot, origin) in chunk.iter().enumerate() {
12176                let items = data
12177                    .pointer(&format!("/o{slot}/projectV2/items"))
12178                    .filter(|value| !value.is_null())
12179                    .ok_or_else(|| SourceError::Refused {
12180                        message: format!(
12181                            "GitHub project {}/{} was not found or is not visible to the token",
12182                            self.owner, self.project_number
12183                        ),
12184                    })?;
12185                let searched =
12186                    data.get(format!("s{slot}"))
12187                        .ok_or_else(|| SourceError::Malformed {
12188                            message: "GitHub search response has no search connection".into(),
12189                        })?;
12190                if resumed(items, None)?.has_more() || resumed(searched, None)?.has_more() {
12191                    continue;
12192                }
12193                let mut found: Vec<Resolved> = Vec::new();
12194                for item in optional_nodes(Some(items), "project items")?
12195                    .into_iter()
12196                    .flatten()
12197                {
12198                    if let Some(resolved) = self.resolve(item)?
12199                        && resolved.content_kind == ContentKind::Issue
12200                        && !found.iter().any(|held| held.id == resolved.id)
12201                    {
12202                        found.push(resolved);
12203                    }
12204                }
12205                for node in optional_nodes(Some(searched), "search")?
12206                    .into_iter()
12207                    .flatten()
12208                {
12209                    if let Some(resolved) = self.resolve_issue(node).await?
12210                        && !found.iter().any(|held| held.id == resolved.id)
12211                    {
12212                        found.push(resolved);
12213                    }
12214                }
12215                self.narrowed_cache()?
12216                    .insert(Narrowing::Origin((*origin).clone()).key(), found);
12217            }
12218        }
12219        Ok(())
12220    }
12221
12222    /// Delete every issue of `ids`, [`DELETE_BATCH`] to a request with
12223    /// [`graphql::DELETE_ISSUES`], and answer those still there: each a request's answer does not
12224    /// confirm, every one of a request refused whole included.
12225    async fn delete_issues(&self, ids: &[&NativeId]) -> Vec<NativeId> {
12226        let mut left = Vec::new();
12227        for chunk in ids.chunks(DELETE_BATCH) {
12228            let mut variables = serde_json::Map::new();
12229            for slot in 0..DELETE_BATCH {
12230                let id = chunk.get(slot).unwrap_or(&chunk[0]);
12231                variables.insert(format!("delete{slot}"), json!({"issueId": id.0}));
12232                variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
12233            }
12234            let data = self
12235                .graphql(graphql::DELETE_ISSUES, Value::Object(variables))
12236                .await
12237                .unwrap_or(Value::Null);
12238            for (slot, id) in chunk.iter().enumerate() {
12239                // Confirmed only by the repository the deleted issue was in, named by a
12240                // non-blank id: anything else says nothing of whether the issue is gone.
12241                let confirmed = data
12242                    .pointer(&format!("/delete{slot}/repository/id"))
12243                    .and_then(Value::as_str)
12244                    .is_some_and(|id| !id.trim().is_empty());
12245                if !confirmed {
12246                    left.push((*id).clone());
12247                }
12248            }
12249        }
12250        left
12251    }
12252
12253    /// Delete one issue, which takes its board item with it.
12254    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
12255        let data = self
12256            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
12257            .await?;
12258        data.pointer("/deleteIssue/repository")
12259            .filter(|value| !value.is_null())
12260            .ok_or_else(|| SourceError::Malformed {
12261                message: "GitHub issue deletion returned no repository".into(),
12262            })?;
12263        self.forget(id)?;
12264        Ok(())
12265    }
12266
12267    /// Remove one item this copy created, so a copy that could not finish leaves the board
12268    /// as it found it.
12269    ///
12270    /// Deleting the issue takes its board item with it, so there is no second mutation to
12271    /// keep in step. An id the board does not hold is not an error: the item is already
12272    /// gone, which is the state this asks for. Which that is, is decided by reading the item
12273    /// by its own id — a listing of the board can still be missing an item it holds, and
12274    /// reading that as *already gone* would leave behind the very item this was asked to
12275    /// take back.
12276    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
12277        let Some(item) = self.bound_item(id).await? else {
12278            return Ok(());
12279        };
12280        if item.content_kind == ContentKind::DraftIssue {
12281            return Err(SourceError::Refused {
12282                message: format!(
12283                    "GitHub item {} is a draft, and this source removes an item by deleting \
12284                     its issue; next: remove it from the board by hand",
12285                    id.0
12286                ),
12287            });
12288        }
12289        let data = self
12290            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
12291            .await?;
12292        data.pointer("/deleteIssue/repository")
12293            .filter(|value| !value.is_null())
12294            .ok_or_else(|| SourceError::Malformed {
12295                message: "GitHub issue deletion returned no repository".into(),
12296            })?;
12297        self.forget(id)?;
12298        Ok(())
12299    }
12300
12301    /// The issue a comment call on `task` is about, or `None` when this board holds no such
12302    /// task.
12303    ///
12304    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
12305    /// read of the task cannot disagree about which ids name one: a project or a document of
12306    /// this board is not a task here either.
12307    ///
12308    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
12309    /// issues and a draft is not one. It is refused rather than answered with an empty page,
12310    /// which would read as a task nobody has commented on yet.
12311    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
12312        let cached = self.resolved_cache()?.get(task).cloned();
12313        let Some(item) = (match cached {
12314            Some(item) => Some(item),
12315            None => self.item_by_id(task).await?,
12316        })
12317        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
12318            return Ok(None);
12319        };
12320        if item.content_kind == ContentKind::DraftIssue {
12321            return Err(self.draft_has_no_comments(task));
12322        }
12323        Ok(Some(item.id))
12324    }
12325
12326    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
12327    /// issues, and a draft is not one.
12328    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
12329        SourceError::Refused {
12330            message: format!(
12331                "task {} of source {} is a draft item on the board, and GitHub keeps \
12332                 comments on issues alone, so a draft has none to read or write; next: \
12333                 convert the draft to an issue on the board, then comment on the issue it \
12334                 becomes",
12335                task.0, self.name
12336            ),
12337        }
12338    }
12339
12340    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
12341    /// request — or `None` when this board holds no task by that id.
12342    ///
12343    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
12344    /// is answered with the draft and the refusal, at the price of the draft's own read.
12345    async fn issue_detail(
12346        &self,
12347        id: &NativeId,
12348        page: &PageRequest,
12349    ) -> Result<Option<TaskDetailRead>, SourceError> {
12350        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
12351        let asked = self
12352            .graphql(
12353                graphql::ISSUE_DETAIL,
12354                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
12355                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
12356                       "duplicates":true}),
12357            )
12358            .await;
12359        let data = match asked {
12360            Ok(data) => data,
12361            Err(error) if unresolvable_node(&error) => return Ok(None),
12362            Err(error) => return Err(error),
12363        };
12364        // `node` is null for an id that names nothing, and absent only from an answer this
12365        // source cannot read — never the same thing.
12366        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
12367            message: format!("GitHub answered the read of {} with no node", id.0),
12368        })?;
12369        self.detail_of(id, node, true, after).await
12370    }
12371
12372    /// Several tasks, each with the first page of its comments when `comments` is set, read
12373    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
12374    /// order.
12375    ///
12376    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
12377    /// one item at a time, so that id is answered as missing and the others as themselves; any
12378    /// other refusal is every id of that batch's answer.
12379    async fn issue_details(
12380        &self,
12381        ids: &[NativeId],
12382        comments: Option<&PageRequest>,
12383    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
12384        let mut read = Vec::with_capacity(ids.len());
12385        for batch in ids.chunks(DETAIL_BATCH) {
12386            match self
12387                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
12388                .await
12389            {
12390                Ok(data) => {
12391                    for (slot, id) in batch.iter().enumerate() {
12392                        // Every alias asked for is answered, null for an id naming nothing;
12393                        // one missing is an answer this source cannot read.
12394                        let read_one = match data.get(format!("i{slot}")) {
12395                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
12396                            None => Err(SourceError::Malformed {
12397                                message: format!(
12398                                    "GitHub answered a batch read with no item for {}",
12399                                    id.0
12400                                ),
12401                            }),
12402                        };
12403                        read.push(read_one);
12404                    }
12405                }
12406                Err(error) if unresolvable_node(&error) => {
12407                    for id in batch {
12408                        read.push(match comments {
12409                            Some(page) => self.issue_detail(id, page).await,
12410                            None => self.task_read(id).await,
12411                        });
12412                    }
12413                }
12414                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
12415            }
12416        }
12417        read
12418    }
12419
12420    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
12421    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
12422        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
12423            task,
12424            comments: None,
12425        }))
12426    }
12427
12428    /// What one node a detail read reached says: the task this board holds by `id`, with the
12429    /// page of comments the node carries when `commented` — or `None` for a node that is no
12430    /// task of this board.
12431    ///
12432    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
12433    /// and an item this process created answers from this process's own record, which a node
12434    /// read taken moments after the write can still be behind.
12435    async fn detail_of(
12436        &self,
12437        id: &NativeId,
12438        node: &Value,
12439        commented: bool,
12440        after: Option<&str>,
12441    ) -> Result<Option<TaskDetailRead>, SourceError> {
12442        if node.is_null() {
12443            return Ok(None);
12444        }
12445        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
12446        // An issue answered under one id is that id's, or the answer is not one this source
12447        // can report: reporting another issue's task and comments under the qualified id asked
12448        // for would be the one wrong answer here. A draft's own read checks the same.
12449        if !draft
12450            && optional_str(node, "__typename")? == Some("Issue")
12451            && required_str(node, "id")? != id.0
12452        {
12453            return Err(SourceError::Malformed {
12454                message: format!(
12455                    "GitHub answered the read of {} with issue {}",
12456                    id.0,
12457                    required_str(node, "id")?
12458                ),
12459            });
12460        }
12461        let item = if draft {
12462            self.draft_by_id(id).await?
12463        } else {
12464            self.resolve_issue(node).await?
12465        };
12466        // A read that carried the whole of what the issue is blocked by — a copy's — is kept
12467        // for this command, so the dependency read the copy makes next is answered from it.
12468        if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
12469            self.resolved_cache()?.insert(read.id.clone(), read.clone());
12470        }
12471        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
12472            return Ok(None);
12473        };
12474        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
12475        let task = own.unwrap_or(item).task()?;
12476        let comments = match (commented, draft) {
12477            (false, _) => None,
12478            (true, true) => Some(Err(self.draft_has_no_comments(id))),
12479            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
12480        };
12481        if commented && !draft && after.is_none() {
12482            self.expected.learn_comments(id, node);
12483        }
12484        Ok(Some(TaskDetailRead { task, comments }))
12485    }
12486
12487    /// Whether the comment `comment` is one of `issue`'s own.
12488    ///
12489    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
12490    /// comment's id and nothing else: a comment id given against the wrong task would
12491    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
12492    /// names something that is not an issue comment, is a comment this task does not have —
12493    /// which is what GitHub refusing to resolve it means too.
12494    async fn comment_is_on(
12495        &self,
12496        issue: &NativeId,
12497        comment: &NativeId,
12498    ) -> Result<bool, SourceError> {
12499        Ok(self.comment_on(issue, comment).await?.is_some())
12500    }
12501
12502    /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
12503    /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
12504    async fn comment_on(
12505        &self,
12506        issue: &NativeId,
12507        comment: &NativeId,
12508    ) -> Result<Option<String>, SourceError> {
12509        // A write the store's cache expects knows which comments its issue held, and their
12510        // bodies, at the version it expects: what this read would answer.
12511        if let Some(primed) = self.expected.primed_comment(issue, comment) {
12512            return Ok(primed);
12513        }
12514        let asked = self
12515            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
12516            .await;
12517        let data = match asked {
12518            Ok(data) => data,
12519            Err(error) if unresolvable_node(&error) => return Ok(None),
12520            Err(error) => return Err(error),
12521        };
12522        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
12523            return Ok(None);
12524        };
12525        if optional_str(node, "__typename")? != Some("IssueComment") {
12526            return Ok(None);
12527        }
12528        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
12529            message: format!("GitHub issue comment {} names no issue", comment.0),
12530        })?;
12531        if required_str(on, "id")? != issue.0 {
12532            return Ok(None);
12533        }
12534        Ok(Some(required_str(node, "body")?.to_owned()))
12535    }
12536
12537    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
12538    async fn partition_edges(
12539        &self,
12540        near_kind: BoardKind,
12541        near_content: ContentKind,
12542        carried: Option<&[Value]>,
12543        depends_on: &[DependencyEdge],
12544    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
12545        let mut native = Vec::new();
12546        let mut fallback = Vec::new();
12547        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
12548            .iter()
12549            .map(|edge| {
12550                // An opaque far end names another tracker, so it is never this board's own.
12551                let same_source = edge.to.designator().is_none()
12552                    && edge
12553                        .to
12554                        .source()
12555                        .is_none_or(|source| source == self.name.as_str());
12556                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
12557                // `DependencyEndpoint::source` both read it that way — and a native id may hold
12558                // colons of its own, so the far end is everything after that one separator.
12559                // Splitting at the last would truncate `work:urn:task:7` to `7`.
12560                let far_id = if edge.to.is_qualified() {
12561                    edge.to
12562                        .id()
12563                        .split_once(':')
12564                        .map_or(edge.to.id(), |(_, native)| native)
12565                } else {
12566                    edge.to.id()
12567                };
12568                // One that already blocks the near issue was answered by that issue's own
12569                // read, which carried each of its blockers' kinds — an issue every one — so it
12570                // is not read again.
12571                let blocking = carried.and_then(|nodes| {
12572                    nodes
12573                        .iter()
12574                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
12575                });
12576                (edge, far_id, same_source, blocking)
12577            })
12578            .collect();
12579        // Every other same-source far end is read by its own id, exactly as the item it is a
12580        // far end of is: whether this board holds it is that read's answer, never a listing's.
12581        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
12582        let mut unread: Vec<NativeId> = Vec::new();
12583        for (_, far_id, same_source, blocking) in &far_ends {
12584            let id = NativeId((*far_id).to_owned());
12585            if *same_source && blocking.is_none() && !unread.contains(&id) {
12586                unread.push(id);
12587            }
12588        }
12589        let read: BTreeMap<NativeId, Option<Resolved>> = unread
12590            .iter()
12591            .cloned()
12592            .zip(self.items_by_ids(&unread).await?)
12593            .collect();
12594        for (edge, far_id, same_source, blocking) in far_ends {
12595            let far = match (same_source, blocking) {
12596                (false, _) => None,
12597                (true, Some(node)) => Some(FarEnd {
12598                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
12599                        BoardKind::Document
12600                    } else {
12601                        BoardKind::Work(related_kind(node, self.sub_issues)?)
12602                    },
12603                    content_kind: ContentKind::Issue,
12604                }),
12605                (true, None) => {
12606                    let read = read
12607                        .get(&NativeId(far_id.to_owned()))
12608                        .cloned()
12609                        .flatten()
12610                        .ok_or_else(|| SourceError::Refused {
12611                            message: format!("GitHub dependency item {far_id} was not found"),
12612                        })?;
12613                    Some(FarEnd {
12614                        kind: read.kind,
12615                        content_kind: read.content_kind,
12616                    })
12617                }
12618            };
12619            let far = far.as_ref();
12620            // The caller says which kind the far end is, and this board holds the far end
12621            // itself, so a disagreement is settled here rather than stored: recorded, the
12622            // wrong kind would read back as a cross-level edge that never existed; written
12623            // natively, it would name a relationship of a different level than the caller
12624            // asked for.
12625            //
12626            // A far end this board holds as a *document* fails the same comparison and is
12627            // refused by the same sentence: `ItemKind` has no document variant because
12628            // nothing may point at one, so no caller can name it correctly and the refusal
12629            // is the only honest answer.
12630            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
12631                return Err(SourceError::Refused {
12632                    message: format!(
12633                        "GitHub dependency item {far_id} is a {} of this board, and this item \
12634                         names it as a {}; record the kind it is",
12635                        disagreeing.kind.describes(),
12636                        edge.to.kind.marker()
12637                    ),
12638                });
12639            }
12640            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
12641            // however the far end is spelled — and one classified native here would be
12642            // written nowhere at all, because a draft's native reconciliation never runs.
12643            let native_here = near_content == ContentKind::Issue
12644                && far.is_some_and(|far| {
12645                    far.content_kind == ContentKind::Issue
12646                        && BoardKind::Work(edge.to.kind) == near_kind
12647                });
12648            if native_here {
12649                native.push(far_id.to_owned());
12650            } else {
12651                fallback.push(edge.clone());
12652            }
12653        }
12654        Ok((native, fallback))
12655    }
12656
12657    async fn update_existing(
12658        &self,
12659        item: &Resolved,
12660        incoming: &Incoming<'_>,
12661        body: &Option<String>,
12662        status_target: Option<&StatusTarget>,
12663    ) -> Result<(), SourceError> {
12664        let title = incoming.written_title();
12665        // A terminal status closes the issue here, in the same mutation as its body: its board
12666        // option was selected before this, so a close never lands on an item whose board cannot
12667        // show it.
12668        let mut fields = match item.content_kind {
12669            ContentKind::DraftIssue => json!({"title":title,"body":body}),
12670            ContentKind::Issue => json!({"title":title,"body":body,
12671                                         "stateInput":state_input(status_target)}),
12672        };
12673        // The people it is to be assigned to, replacing whoever it holds — sent only when they
12674        // are not exactly those, and looked up before anything of this write was sent.
12675        if let Some(people) = incoming.assignees
12676            && item
12677                .assignees
12678                .as_deref()
12679                .is_none_or(|held| !assignees::same_people(held, people))
12680        {
12681            if item.content_kind == ContentKind::DraftIssue {
12682                return Err(self.assigns_a_draft());
12683            }
12684            fields["assigneeIds"] = json!(self.people_ids(people, &item.written_name())?);
12685        }
12686        // Only the members whose value differs from the item as this write read it are sent,
12687        // and nothing at all when none does — whether or not the store's cache expects a
12688        // version of it. A member left out is one GitHub leaves as it is, so a person's edit
12689        // of it since that read is kept rather than overwritten with what it already held,
12690        // and a re-copy of an unchanged item costs no mutation.
12691        if let Some(members) = fields.as_object_mut() {
12692            if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
12693                members.remove("title");
12694            }
12695            let body_unchanged = match (members.get("body"), item.raw_body.as_deref()) {
12696                (Some(Value::String(sent)), Some(held)) => sent == held,
12697                (Some(Value::Null), held) => held.is_none_or(str::is_empty),
12698                _ => false,
12699            };
12700            if body_unchanged {
12701                members.remove("body");
12702            }
12703            // An issue already in the state the target asks for keeps it: an open one stays
12704            // open, and a closed one whose closure reads as the target's category keeps its
12705            // reason — a duplicate's says more than this write would.
12706            let unchanged_state = match status_target {
12707                Some(StatusTarget::Terminal(_, reason)) => {
12708                    item.closed
12709                        && closed_category(item.reason.as_deref())
12710                            == closed_category(Some(reason.reason()))
12711                }
12712                Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => !item.closed,
12713                None => true,
12714            };
12715            if unchanged_state {
12716                members.remove("stateInput");
12717            }
12718            if members.is_empty() {
12719                return Ok(());
12720            }
12721        }
12722        self.update_content(item.content_kind, &item.id, fields)
12723            .await
12724    }
12725
12726    /// Update one board item's content with exactly `fields` beside its id, through the
12727    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
12728    /// a draft.
12729    ///
12730    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
12731    /// is what lets a narrow write carry the one thing it changes and nothing else.
12732    async fn update_content(
12733        &self,
12734        kind: ContentKind,
12735        id: &NativeId,
12736        fields: Value,
12737    ) -> Result<(), SourceError> {
12738        let (operation, id_key, pointer) = match kind {
12739            ContentKind::DraftIssue => (
12740                graphql::UPDATE_DRAFT,
12741                "draftIssueId",
12742                "/updateProjectV2DraftIssue/draftIssue",
12743            ),
12744            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
12745        };
12746        if kind == ContentKind::Issue && self.hold_issue(&fields)? {
12747            return Ok(());
12748        }
12749        if kind == ContentKind::Issue && self.hold_update(id, &fields)? {
12750            return Ok(());
12751        }
12752        let mut input = fields;
12753        input[id_key] = json!(id.0);
12754        self.send_update(operation, pointer, id, input).await
12755    }
12756
12757    /// Send one item's update, `input`, as `operation`, and hold GitHub's answer at `pointer`
12758    /// to naming that item, `id`.
12759    async fn send_update(
12760        &self,
12761        operation: &str,
12762        pointer: &str,
12763        id: &NativeId,
12764        input: Value,
12765    ) -> Result<(), SourceError> {
12766        let data = match self.graphql(operation, json!({"input":&input})).await {
12767            Ok(data) => data,
12768            Err(error) => return Err(self.refusing_people(&input, error)),
12769        };
12770        let returned = data
12771            .pointer(pointer)
12772            .ok_or_else(|| SourceError::Malformed {
12773                message: "GitHub item update returned no item".into(),
12774            })?;
12775        if required_str(returned, "id")? != id.0 {
12776            return Err(SourceError::Malformed {
12777                message: "GitHub item update returned the wrong item".into(),
12778            });
12779        }
12780        self.assigned_as_asked(&input, Some(returned))
12781    }
12782
12783    /// Creates one issue, files it on the board, and reports what a read of it would say:
12784    /// its content id, its board item id, and the web address GitHub gave it.
12785    ///
12786    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
12787    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
12788    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
12789    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
12790    /// "Content already exists in this project". A terminal status is not written here:
12791    /// `finish_write` selects its option first and closes the issue after, so a close never
12792    /// lands on an item whose board cannot show it.
12793    ///
12794    /// The address and the number come back here because this is the only place either is
12795    /// known before GitHub's own board read catches up — an item this run created answers
12796    /// the reads that follow it out of the record below, and one remembered without them
12797    /// would report no location and no key for the rest of the run.
12798    async fn create_and_file_issue(
12799        &self,
12800        board_id: &str,
12801        repository: &RepositoryTarget,
12802        incoming: &Incoming<'_>,
12803        body: &Option<String>,
12804    ) -> Result<Landed, SourceError> {
12805        let repository_id = self.repository_id(repository, incoming).await?;
12806        let mut input = json!({
12807            "repositoryId":repository_id,"title":incoming.written_title(),"body":body
12808        });
12809        self.assigning(&mut input, incoming)?;
12810        let data = match self
12811            .graphql(graphql::CREATE_ISSUE, json!({"input": &input}))
12812            .await
12813        {
12814            Ok(data) => data,
12815            Err(error) => return Err(self.refusing_people(&input, error)),
12816        };
12817        let created = data
12818            .pointer("/createIssue/issue")
12819            .filter(|value| !value.is_null())
12820            .ok_or_else(|| SourceError::Malformed {
12821                message: "GitHub issue creation returned no issue".into(),
12822            })?;
12823        let content_id = NativeId(required_str(created, "id")?.to_owned());
12824        // Assigned otherwise than asked, the issue is refused and taken back as the filed
12825        // create's is, a refused delete answered beside the refusal.
12826        if let Err(error) = self.assigned_as_asked(&input, Some(created)) {
12827            return Err(self.taken_back(&content_id, created, error).await);
12828        }
12829        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
12830        // a response without it is not worth failing a landed write over — the item simply
12831        // reports no location until the board read catches up, which is what it did before.
12832        let url = optional_str(created, "url")?.map(str::to_owned);
12833        // The issue exists from here on, so an unreadable number and a refused board
12834        // filing below each try, best effort, to take it back: an issue in the repository
12835        // that is on no board is an item nobody asked for and nothing here would find again.
12836        //
12837        // Its number is optional on the same terms its address is — a landed write is not
12838        // worth failing over a member that came back missing, and such an item reports no
12839        // handle until a board read catches up. A number that is *present* and is not an
12840        // unsigned integer is still a response this source cannot read.
12841        let number = match created_issue_number(created) {
12842            Ok(number) => number,
12843            Err(error) => {
12844                let _ = self.delete_issue(&content_id).await;
12845                return Err(error);
12846            }
12847        };
12848        let added = match self
12849            .graphql(
12850                graphql::ADD_TO_BOARD,
12851                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
12852            )
12853            .await
12854        {
12855            Ok(added) => added,
12856            Err(error) => {
12857                let _ = self.delete_issue(&content_id).await;
12858                return Err(error);
12859            }
12860        };
12861        let item = added
12862            .pointer("/addProjectV2ItemById/item")
12863            .filter(|value| !value.is_null())
12864            .ok_or_else(|| SourceError::Malformed {
12865                message: "GitHub board addition returned no project item".into(),
12866            })?;
12867        self.note_written(&content_id, url.as_deref());
12868        Ok(Landed {
12869            content_id,
12870            item_id: Some(required_str(item, "id")?.to_owned()),
12871            url,
12872            number,
12873        })
12874    }
12875
12876    /// Move one issue under the project it now belongs to, or out of the one it left.
12877    async fn reparent(
12878        &self,
12879        held: Option<NativeId>,
12880        child: &NativeId,
12881        wanted: Option<&NativeId>,
12882    ) -> Result<(), SourceError> {
12883        if held.as_ref() == wanted {
12884            return Ok(());
12885        }
12886        if let Some(held) = &held {
12887            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
12888                .await?;
12889        }
12890        if let Some(wanted) = wanted {
12891            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
12892                .await?;
12893        }
12894        Ok(())
12895    }
12896
12897    async fn sub_issue(
12898        &self,
12899        operation: &str,
12900        parent: &NativeId,
12901        child: &NativeId,
12902        root: &str,
12903    ) -> Result<(), SourceError> {
12904        let data = self
12905            .graphql(
12906                operation,
12907                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
12908            )
12909            .await?;
12910        let issue =
12911            data.pointer(&format!("/{root}/issue"))
12912                .ok_or_else(|| SourceError::Malformed {
12913                    message: "GitHub sub-issue update returned no issue".into(),
12914                })?;
12915        let sub =
12916            data.pointer(&format!("/{root}/subIssue"))
12917                .ok_or_else(|| SourceError::Malformed {
12918                    message: "GitHub sub-issue update returned no sub-issue".into(),
12919                })?;
12920        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
12921            return Err(SourceError::Malformed {
12922                message: "GitHub sub-issue update returned the wrong issues".into(),
12923            });
12924        }
12925        Ok(())
12926    }
12927
12928    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
12929    /// whether there was one.
12930    ///
12931    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
12932    /// relationships are not read: there is nothing a read of them could find.
12933    async fn reconcile_blocked_by(
12934        &self,
12935        content_id: &NativeId,
12936        native: &[String],
12937        issue: Issue<'_>,
12938    ) -> Result<bool, SourceError> {
12939        let current = match issue {
12940            Issue::Created => Vec::new(),
12941            Issue::Existing(Some(held)) => held
12942                .iter()
12943                .map(|far| required_str(far, "id").map(str::to_owned))
12944                .collect::<Result<Vec<_>, _>>()?,
12945            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
12946        };
12947        let mut changed = false;
12948        for (operation, far_id) in current
12949            .iter()
12950            .filter(|id| !native.contains(id))
12951            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
12952            .chain(
12953                native
12954                    .iter()
12955                    .filter(|id| !current.contains(id))
12956                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
12957            )
12958        {
12959            let data = self
12960                .graphql(
12961                    operation,
12962                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
12963                )
12964                .await?;
12965            let root = if operation == graphql::ADD_BLOCKED_BY {
12966                "addBlockedBy"
12967            } else {
12968                "removeBlockedBy"
12969            };
12970            let issue =
12971                data.pointer(&format!("/{root}/issue"))
12972                    .ok_or_else(|| SourceError::Malformed {
12973                        message: "GitHub dependency update returned no issue".into(),
12974                    })?;
12975            let blocker = data
12976                .pointer(&format!("/{root}/blockingIssue"))
12977                .ok_or_else(|| SourceError::Malformed {
12978                    message: "GitHub dependency update returned no blocking issue".into(),
12979                })?;
12980            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
12981            {
12982                return Err(SourceError::Malformed {
12983                    message: "GitHub dependency update returned the wrong issues".into(),
12984                });
12985            }
12986            changed = true;
12987        }
12988        Ok(changed)
12989    }
12990}
12991
12992/// The board field values one write sends, in the order GitHub runs them, and the fields it
12993/// clears after them: the origin field when it moves, the `Status` option `column` names, the
12994/// priority, then each projected field.
12995fn field_writes<'a>(
12996    existing: Option<&Resolved>,
12997    origin_field: Option<&str>,
12998    origin: &str,
12999    column: Option<(String, String)>,
13000    priority: Option<&'a PriorityWrite>,
13001    projections: &'a [ProjectionWrite],
13002) -> (Vec<(String, Value)>, Vec<&'a str>) {
13003    let mut fields = Vec::new();
13004    if let Some(field_id) = origin_field
13005        && existing.map_or(!origin.is_empty(), |item| {
13006            item.origin.as_deref().unwrap_or("") != origin
13007        })
13008    {
13009        fields.push((field_id.to_owned(), json!({"text":origin})));
13010    }
13011    if let Some((field_id, option_id)) = column {
13012        fields.push((field_id, json!({"singleSelectOptionId":option_id})));
13013    }
13014    let mut clears = Vec::new();
13015    match priority {
13016        Some(PriorityWrite::Select { field, option }) => {
13017            fields.push((field.clone(), json!({"singleSelectOptionId":option})));
13018        }
13019        Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
13020        None => {}
13021    }
13022    for projection in projections {
13023        projection.join(&mut fields, &mut clears);
13024    }
13025    (fields, clears)
13026}
13027
13028/// Everything one write decided before sending anything: see
13029/// [`GitHubProjectsSource::prepare_write`].
13030struct Prepared {
13031    /// What the write lands on.
13032    destination: WriteDestination,
13033    board: BoardFields,
13034    status_target: Option<StatusTarget>,
13035    /// The `Status` field, the option and the option's name the status lands on.
13036    // 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.
13037    column: Option<(String, String, String)>,
13038    priority_write: Option<PriorityWrite>,
13039    projections: Vec<ProjectionWrite>,
13040    projected: BTreeMap<String, String>,
13041    content_kind: ContentKind,
13042    own_repository: Option<Repository>,
13043    /// The far ends of the item's native blocked-by relationships.
13044    // 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.
13045    native: Vec<String>,
13046    slot: BTreeMap<String, Value>,
13047    content: Option<String>,
13048    body: Option<String>,
13049    /// The copy origin the item records, empty for none.
13050    // 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.
13051    origin: String,
13052    origin_field: Option<String>,
13053}
13054
13055/// One write a copy held back of the kinds sent together: a board field value, or a blocked-by
13056/// edge, each its input.
13057enum HeldWrite<'a> {
13058    Field(&'a Value),
13059    Edge(&'a Value),
13060}
13061
13062/// Whether `error` is GitHub refusing to file an issue on a board because it is filed there
13063/// already — what `addProjectV2ItemById` answers once `createIssue`'s `projectV2Ids` has placed
13064/// it, rather than answering with the item it holds.
13065fn already_on_the_board(error: &SourceError) -> bool {
13066    error
13067        .to_string()
13068        .contains("Content already exists in this project")
13069}
13070
13071/// Hold every alias `inputs` sent under `prefix` in `data` to answering with what its input
13072/// named — each `(root, named)` of `answers` — and refuse one that answered nothing or another
13073/// item.
13074fn check_aliased_answers(
13075    document: &str,
13076    data: &Value,
13077    prefix: &str,
13078    inputs: &[&Value],
13079    answers: &[(&str, &str)],
13080) -> Result<(), SourceError> {
13081    for (slot, input) in inputs.iter().enumerate() {
13082        for (root, named) in answers {
13083            let answered = data
13084                .pointer(&format!("/{prefix}{slot}/{root}/id"))
13085                .and_then(Value::as_str)
13086                .ok_or_else(|| SourceError::Malformed {
13087                    message: format!(
13088                        "GitHub answered {} with nothing at {prefix}{slot}",
13089                        operation_description(document)
13090                    ),
13091                })?;
13092            let asked = input.get(named).and_then(Value::as_str).unwrap_or_default();
13093            if answered != asked {
13094                return Err(SourceError::Malformed {
13095                    message: format!(
13096                        "GitHub answered {} at {prefix}{slot} with {answered}, which is not \
13097                         {asked}, the item it was asked to write",
13098                        operation_description(document)
13099                    ),
13100                });
13101            }
13102        }
13103    }
13104    Ok(())
13105}
13106
13107/// `error`, which stopped a batch of creates, and — when GitHub refused to take any of them
13108/// back — each issue of `left` it created that is still in the repository, which its caller,
13109/// never handed their ids, could not name.
13110fn left_behind(error: SourceError, left: &[&str]) -> SourceError {
13111    if left.is_empty() {
13112        return error;
13113    }
13114    SourceError::Refused {
13115        message: format!(
13116            "{error}; GitHub then refused to take back {}, which this write created and which \
13117             are still in the repository; next: delete {} before writing again",
13118            left.join(", "),
13119            if left.len() == 1 { "it" } else { "them" }
13120        ),
13121    }
13122}
13123
13124/// What one write lands on: the item it updates, as it stood, or where it creates a new one.
13125enum WriteDestination {
13126    /// The item as it stood, for an update.
13127    Existing(Box<Resolved>),
13128    /// Where a new item is created.
13129    New(RepositoryTarget),
13130}
13131
13132impl WriteDestination {
13133    /// The item an update writes over, as it stood; `None` for a create.
13134    fn existing(&self) -> Option<&Resolved> {
13135        match self {
13136            Self::Existing(item) => Some(item),
13137            Self::New(_) => None,
13138        }
13139    }
13140}
13141
13142/// One existing issue's update held back by [`GitHubProjectsSource::hold_update`]: the issue,
13143/// and the `UpdateIssueInput` its own [`graphql::UPDATE_ISSUE`] would have sent — the members
13144/// it writes beside that issue's id, which only [`Self::new`] sets.
13145struct HeldUpdate {
13146    issue: NativeId,
13147    // 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.
13148    input: Value,
13149}
13150
13151impl HeldUpdate {
13152    /// The update writing `members` — an `UpdateIssueInput` without its id — to `issue`.
13153    fn new(issue: &NativeId, members: &Value) -> Self {
13154        let mut input = members.clone();
13155        input["id"] = json!(issue.0);
13156        Self {
13157            issue: issue.clone(),
13158            input,
13159        }
13160    }
13161}
13162
13163/// What a copy has held back of the items it created; see
13164/// [`GitHubProjectsSource::hold_back`].
13165#[derive(Debug, Default)]
13166enum Deferred {
13167    /// Writes are being held back, and nothing has been held yet.
13168    #[default]
13169    Nothing,
13170    /// The items held back, every one of them on `board`.
13171    Held {
13172        /// The board every one of them is on.
13173        board: BoardId,
13174        /// Each created item, in the order it was created.
13175        created: Vec<HeldBack>,
13176    },
13177}
13178
13179impl Deferred {
13180    /// Whether `content` is one of the items held back.
13181    fn holds(&self, content: &NativeId) -> bool {
13182        match self {
13183            Self::Nothing => false,
13184            Self::Held { created, .. } => created.iter().any(|held| held.content == *content),
13185        }
13186    }
13187}
13188
13189/// One created item's parts that land once [`TaskSource::finish_writes`] is called.
13190#[derive(Debug)]
13191struct HeldBack {
13192    /// The issue.
13193    content: NativeId,
13194    /// Its board field values other than its `Status`, in the order they are written.
13195    fields: Vec<(String, Value)>,
13196    /// The `Status` field, and the option and its name that the item's status lands on.
13197    // llmlint: ignore[invalid_states_unrepresentable] The same triple `column_for` returns and `Prepared::column` carries, held back unchanged until it is sent.
13198    status: Option<(String, String, String)>,
13199    /// The fields it clears after those are written.
13200    clears: Vec<String>,
13201    /// The reason it is closed for, once its option is selected; `None` leaves it open.
13202    close: Option<ClosedState>,
13203    /// The issues it is blocked by.
13204    edges: Vec<NativeId>,
13205}
13206
13207/// The member [`answer_with_errors`] carries a response's GraphQL errors under, beside its data:
13208/// no GitHub type has a field of this name.
13209const ANSWER_ERRORS: &str = "onetaskgraphErrors";
13210
13211/// A response's `data` with its GraphQL errors beside it under [`ANSWER_ERRORS`], when it has
13212/// both — or `None`, for an answer [`GitHubProjectsSource::answer`] reads as it always has.
13213///
13214/// What [`graphql::CREATE_ISSUES`] is answered with: GitHub runs a document's mutation fields in
13215/// order and answers each one that ran, so a refusal of one alias leaves the issues the others
13216/// created named in the data beside it — the ids a copy needs to take them back.
13217fn answer_with_errors(body: &str) -> Option<Value> {
13218    let parsed: Value = serde_json::from_str(body).ok()?;
13219    let errors = parsed
13220        .get("errors")?
13221        .as_array()
13222        .filter(|errors| !errors.is_empty())?;
13223    let mut data = parsed.get("data").filter(|data| data.is_object())?.clone();
13224    data[ANSWER_ERRORS] = Value::Array(errors.clone());
13225    Some(data)
13226}
13227
13228/// The `createIssue` input that files the new issue on `board` and, when `incoming` names one,
13229/// under its project — see [`GitHubProjectsSource::create_filed_issue`].
13230fn filed_input(
13231    repository: &str,
13232    board: &str,
13233    incoming: &Incoming<'_>,
13234    body: &Option<String>,
13235) -> Value {
13236    let mut input = json!({"repositoryId":repository,"title":incoming.written_title(),
13237                           "body":body,"projectV2Ids":[board]});
13238    if let Some(parent) = incoming.parent {
13239        input["parentIssueId"] = json!(parent.0);
13240    }
13241    input
13242}
13243
13244/// The name of option `option` of field `field` among a board's `fields`.
13245fn option_name<'a>(fields: &'a Value, field: &str, option: &str) -> Option<&'a str> {
13246    fields
13247        .get("nodes")?
13248        .as_array()?
13249        .iter()
13250        .find(|node| node.get("id").and_then(Value::as_str) == Some(field))?
13251        .get("options")?
13252        .as_array()?
13253        .iter()
13254        .find(|candidate| candidate.get("id").and_then(Value::as_str) == Some(option))?
13255        .get("name")?
13256        .as_str()
13257}
13258
13259/// What a write needs to know of one far end it names: which kind of item it is, and whether
13260/// it is an issue a native relationship can name.
13261struct FarEnd {
13262    kind: BoardKind,
13263    content_kind: ContentKind,
13264}
13265
13266/// Whether the issue one write reconciles was created by that write or was already there.
13267#[derive(Clone, Copy, PartialEq, Eq)]
13268enum Issue<'a> {
13269    /// Created by this write, so it holds no relationships yet.
13270    Created,
13271    /// On the board before this write, holding whatever relationships it holds — the far
13272    /// ends of its whole `blockedBy`, when the read that reached it carried them.
13273    Existing(Option<&'a [Value]>),
13274}
13275
13276/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
13277enum Reached {
13278    /// An issue this board holds, resolved into everything this source reports about it.
13279    Held(Box<Resolved>),
13280    /// Nothing this board holds: no such node, or a node on some other board.
13281    Nothing,
13282    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
13283    /// again by [`GitHubProjectsSource::draft_by_id`].
13284    Draft,
13285}
13286
13287/// What GitHub says when a string is not a node id it can resolve.
13288///
13289/// Matched because it is the ordinary answer to a project selector naming a project by its
13290/// *name*, and reporting that as a failure would make naming one impossible. It is read
13291/// off the refusal GitHub sent, never guessed from the shape of the string: this source
13292/// does not define the syntax of a GitHub node id and would be wrong about it.
13293const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
13294
13295/// `error` with `note` added to the end of what it says, its kind and every other member
13296/// unchanged — so a caller still branches on the failure that happened, and reads beside it
13297/// what that failure left behind.
13298fn noting(error: SourceError, note: &str) -> SourceError {
13299    match error {
13300        SourceError::Config { message } => SourceError::Config {
13301            message: message + note,
13302        },
13303        SourceError::Auth { message } => SourceError::Auth {
13304            message: message + note,
13305        },
13306        SourceError::Refused { message } => SourceError::Refused {
13307            message: message + note,
13308        },
13309        SourceError::RateLimited {
13310            retry_after_seconds,
13311            message,
13312        } => SourceError::RateLimited {
13313            retry_after_seconds,
13314            message: Some(message.unwrap_or_default() + note),
13315        },
13316        SourceError::Unavailable { message } => SourceError::Unavailable {
13317            message: message + note,
13318        },
13319        SourceError::Malformed { message } => SourceError::Malformed {
13320            message: message + note,
13321        },
13322    }
13323}
13324
13325/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
13326/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
13327fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest, children: bool) -> Value {
13328    let mut variables = serde_json::Map::new();
13329    for slot in 0..COPY_DETAIL_BATCH {
13330        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
13331        variables.insert(format!("id{slot}"), json!(id));
13332    }
13333    variables.insert("project".to_owned(), json!(batch.first().map(|id| &id.0)));
13334    variables.insert("children".to_owned(), json!(children));
13335    variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
13336    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
13337    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13338    variables.insert("duplicates".to_owned(), json!(true));
13339    Value::Object(variables)
13340}
13341
13342/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
13343/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
13344/// for them.
13345///
13346/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
13347/// is read again at no added price.
13348fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
13349    let mut variables = serde_json::Map::new();
13350    for slot in 0..DETAIL_BATCH {
13351        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
13352        variables.insert(format!("id{slot}"), json!(id));
13353    }
13354    variables.insert(
13355        "first".to_owned(),
13356        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
13357    );
13358    variables.insert("comments".to_owned(), json!(comments.is_some()));
13359    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
13360    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13361    variables.insert("duplicates".to_owned(), json!(true));
13362    Value::Object(variables)
13363}
13364
13365/// Whether this refusal is GitHub saying the id names no node at all.
13366fn unresolvable_node(error: &SourceError) -> bool {
13367    matches!(error, SourceError::Refused { message }
13368        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
13369}
13370
13371/// One project name, as a search qualifier which filters on it at the server.
13372///
13373/// Quoted so the whole title is one phrase rather than a bag of words, with the two
13374/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
13375/// the way it documents. A title matched here is still compared for equality afterwards:
13376/// the qualifier narrows what the server sends, and this source decides what it names.
13377fn title_qualifier(name: &str) -> String {
13378    format!("in:title {}", quoted(name))
13379}
13380
13381/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
13382/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
13383/// it documents — so a value holding a qualifier's spelling is searched for rather than
13384/// obeyed.
13385fn quoted(value: &str) -> String {
13386    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
13387    format!("\"{escaped}\"")
13388}
13389
13390/// The search qualifier for the issues updated at or after `since`.
13391///
13392/// Written to the second, rounded down, which can only widen what the search returns.
13393fn updated_qualifier(since: DateTime<Utc>) -> String {
13394    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
13395}
13396
13397/// The search terms that narrow a board-scoped issue search to a task query's text and
13398/// metadata predicates, or `None` when it carries neither.
13399///
13400/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
13401/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
13402/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
13403/// matches each in any field the `in:` qualifier names, so a query naming a title search and
13404/// a metadata value searches both fields for both — wider than asked, never narrower, and
13405/// every candidate is confirmed in process afterwards.
13406///
13407/// **This narrows a text search, and that is this source's declared semantics.** GitHub
13408/// matches whole tokens where a substring rule would match inside a word, so an item holding
13409/// the text only inside a longer word is not returned. A text of nothing but whitespace
13410/// matches every item, so it narrows nothing and is not sent.
13411fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
13412    let text = query
13413        .text
13414        .as_ref()
13415        .filter(|text| !text.terms.trim().is_empty());
13416    if text.is_none() && query.metadata.is_empty() {
13417        return None;
13418    }
13419    let (title, body) = match text.map(|text| text.fields) {
13420        None => (false, true),
13421        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
13422        Some(TextFields::Content) => (false, true),
13423        Some(TextFields::TitleOrContent) => (true, true),
13424    };
13425    let fields = match (title, body) {
13426        (true, true) => "in:title,body",
13427        (true, false) => "in:title",
13428        _ => "in:body",
13429    };
13430    let phrases = text
13431        .map(|text| text.terms.clone())
13432        .into_iter()
13433        .chain(
13434            query
13435                .metadata
13436                .iter()
13437                .map(|wanted| as_stored(wanted.value())),
13438        )
13439        .map(|phrase| quoted(&phrase))
13440        .collect::<Vec<_>>();
13441    Some(format!("{fields} {}", phrases.join(" ")))
13442}
13443
13444/// Whether every status category `query` keeps reads only off an open issue or a draft: a
13445/// closed issue reads `done`, `cancelled` or — closed for a reason that implies no category —
13446/// `unknown`, so a status filter naming none of those three can ask every issue search it sends
13447/// for open issues alone; each candidate's category is still confirmed from its own item.
13448fn open_only(query: &TaskQuery) -> bool {
13449    !query.statuses.is_empty()
13450        && query.statuses.iter().all(|category| {
13451            !matches!(
13452                category,
13453                StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
13454            )
13455        })
13456}
13457
13458/// What a task query is narrowed by beside a board-scoped issue search's scope — its comment
13459/// activity and its text and metadata, with `is:open` when [`open_only`] — or `None` for a query
13460/// carrying none of those, which no narrowed search answers.
13461///
13462/// A narrowed search never answers a draft, which is no issue, so narrowing it to open issues
13463/// too leaves out nothing such a search could have answered.
13464fn search_qualifiers(query: &TaskQuery) -> Option<String> {
13465    let mut qualifiers: Vec<String> = [
13466        query.commented_since.map(updated_qualifier),
13467        narrowing_qualifiers(query),
13468    ]
13469    .into_iter()
13470    .flatten()
13471    .collect();
13472    if qualifiers.is_empty() {
13473        return None;
13474    }
13475    if open_only(query) {
13476        qualifiers.push(OPEN_QUALIFIER.to_owned());
13477    }
13478    Some(qualifiers.join(" "))
13479}
13480
13481/// The search terms that narrow a board-scoped issue search to a project or document query's
13482/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
13483/// carrying that text alone is sent as by [`narrowing_qualifiers`].
13484fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
13485    narrowing_qualifiers(&TaskQuery {
13486        text: text.cloned(),
13487        ..TaskQuery::default()
13488    })
13489}
13490
13491/// Refuses a project or document query's text GitHub's issue search cannot find, before
13492/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
13493/// query's.
13494fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
13495    refuse_unsearchable(&TaskQuery {
13496        text: text.cloned(),
13497        ..TaskQuery::default()
13498    })
13499}
13500
13501/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
13502/// before anything is asked of GitHub.
13503///
13504/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
13505/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
13506/// left out, the search is every issue of the board. So this source says it cannot answer
13507/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
13508/// nothing GitHub could search for, and keeps the board read it always had.
13509fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
13510    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
13511                       letter or digit with a bounded query";
13512    if let Some(text) = &query.text
13513        && !text.terms.trim().is_empty()
13514        && !has_words(&text.terms)
13515    {
13516        return Err(SourceError::Refused {
13517            message: format!(
13518                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
13519                text.terms
13520            ),
13521        });
13522    }
13523    if let Some(wanted) = query
13524        .metadata
13525        .iter()
13526        .find(|wanted| !has_words(wanted.value()))
13527    {
13528        return Err(SourceError::Refused {
13529            message: format!(
13530                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
13531                wanted.value(),
13532                std::iter::once(wanted.key())
13533                    .chain(wanted.path().iter().map(String::as_str))
13534                    .collect::<Vec<_>>()
13535                    .join("/"),
13536            ),
13537        });
13538    }
13539    Ok(())
13540}
13541
13542/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
13543fn has_words(phrase: &str) -> bool {
13544    phrase.chars().any(char::is_alphanumeric)
13545}
13546
13547/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
13548///
13549/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
13550/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
13551/// which GitHub's word match would read as different words.
13552fn as_stored(value: &str) -> String {
13553    let encoded = Value::String(value.to_owned()).to_string();
13554    encoded[1..encoded.len() - 1].to_owned()
13555}
13556
13557/// The one narrower question a task query carrying a text, metadata or origin predicate is
13558/// sent as.
13559enum Narrowing {
13560    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
13561    Origin(String),
13562    /// The board-scoped issue search narrowed by these qualifiers.
13563    Search(String),
13564}
13565
13566impl Narrowing {
13567    /// What this question is remembered under for the length of one command.
13568    fn key(&self) -> String {
13569        match self {
13570            Self::Origin(origin) => format!("origin {origin}"),
13571            Self::Search(also) => format!("search {also}"),
13572        }
13573    }
13574}
13575
13576/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
13577enum Resumed {
13578    /// It reported another page, which starts after this cursor.
13579    More(String),
13580    /// It has ended. Sending this cursor again — the page's own end when it had one, and
13581    /// otherwise the cursor it was reached from — answers an empty page, so the one document
13582    /// can go on walking the other connection.
13583    Ended(Option<String>),
13584}
13585
13586impl Resumed {
13587    /// Whether the connection has another page.
13588    const fn has_more(&self) -> bool {
13589        matches!(self, Self::More(_))
13590    }
13591
13592    /// The cursor to send this connection next.
13593    fn cursor(self) -> Option<String> {
13594        match self {
13595            Self::More(next) => Some(next),
13596            Self::Ended(last) => last,
13597        }
13598    }
13599}
13600
13601/// Where `connection`, reached from `after`, resumes — refused when it reports another page
13602/// with no cursor to it, or from a cursor that does not advance.
13603fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
13604    let info = connection
13605        .get("pageInfo")
13606        .ok_or_else(|| SourceError::Malformed {
13607            message: "GitHub connection has no pageInfo".into(),
13608        })?;
13609    let end = optional_str(info, "endCursor")?;
13610    if required_bool(info, "hasNextPage")? {
13611        let next = end.ok_or_else(|| SourceError::Malformed {
13612            message: "GitHub connection reports another page and no endCursor".into(),
13613        })?;
13614        validate_cursor_progress(after, next)?;
13615        return Ok(Resumed::More(next.to_owned()));
13616    }
13617    Ok(Resumed::Ended(
13618        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
13619    ))
13620}
13621
13622/// The board, and every item on it this source reports.
13623#[derive(Clone)]
13624struct Board {
13625    id: String,
13626    fields: Value,
13627    items: Vec<Resolved>,
13628}
13629
13630/// What a write needs of the board and nothing more: its node id and its field
13631/// definitions, in the shape a read of the board's own `fields` gives them.
13632///
13633/// Deliberately no items. A write decides which item it writes, which parent it files
13634/// under and which far ends it names by reading each of them by its own id; this is the
13635/// half of the board those reads cannot carry, and holding no item is what keeps it from
13636/// ever being asked whether an item is there.
13637#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13638struct BoardFields {
13639    id: BoardId,
13640    fields: Value,
13641}
13642
13643/// A board's node id: what a field write and `addProjectV2ItemById` address.
13644///
13645/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
13646/// refused where it is read, and one an item names blank is read as not named at all.
13647#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
13648#[serde(try_from = "String")]
13649struct BoardId(String);
13650
13651impl TryFrom<String> for BoardId {
13652    type Error = String;
13653
13654    /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
13655    fn try_from(id: String) -> Result<Self, Self::Error> {
13656        Self::parse(&id).map_err(|error| error.to_string())
13657    }
13658}
13659
13660/// Where one write left its item, for the record the rest of the command reads it out of.
13661///
13662/// A named record rather than a tuple because the update arm and the create arm each fill
13663/// all four, and two `Option`s of different meaning side by side in a tuple are two
13664/// positions a reader has to count.
13665struct Landed {
13666    /// The issue's own node id, which is the [`NativeId`] this source reports.
13667    content_id: NativeId,
13668    /// The board item's id, which is what a field write addresses — `None` for an issue its
13669    /// create filed on the board itself, whose board item GitHub adds after answering and
13670    /// [`TaskSource::finish_writes`] reads.
13671    // 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.
13672    item_id: Option<String>,
13673    /// The web address GitHub gave the issue, when it gave one.
13674    // 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.
13675    url: Option<String>,
13676    /// The issue's number on its repository, when GitHub reported one.
13677    number: Option<u64>,
13678}
13679
13680impl BoardId {
13681    fn parse(id: &str) -> Result<Self, SourceError> {
13682        if id.trim().is_empty() {
13683            return Err(SourceError::Malformed {
13684                message: "GitHub named a board with a blank node id".into(),
13685            });
13686        }
13687        Ok(Self(id.to_owned()))
13688    }
13689
13690    fn as_str(&self) -> &str {
13691        &self.0
13692    }
13693}
13694
13695impl Board {
13696    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
13697        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
13698        let nodes = fields
13699            .get("nodes")
13700            .and_then(Value::as_array)
13701            .ok_or_else(|| SourceError::Malformed {
13702                message: "GitHub project fields.nodes is not an array".into(),
13703            })?;
13704        Ok(nodes
13705            .iter()
13706            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
13707    }
13708}
13709
13710/// One board item, resolved into everything this source reports about it.
13711///
13712/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
13713/// [`TaskSource::prime_write`] takes back — see [`expected`].
13714#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13715struct Resolved {
13716    item_id: String,
13717    id: NativeId,
13718    content_kind: ContentKind,
13719    kind: BoardKind,
13720    title: String,
13721    body: Option<String>,
13722    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
13723    /// that changes the slot alone has to keep byte for byte outside it.
13724    raw_body: Option<String>,
13725    status: Status,
13726    /// The name of the board `Status` option this item sits in, as the board spells it.
13727    option: Option<String>,
13728    /// What its `Priority` field says, read through this instance's mapping.
13729    priority: HeldPriority,
13730    /// Whether this item's issue is closed. A draft has no such state and is never closed.
13731    closed: bool,
13732    /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
13733    /// issue, a draft, and an issue closed before GitHub recorded reasons.
13734    #[serde(default)]
13735    reason: Option<String>,
13736    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
13737    delivers: Vec<TaskRef>,
13738    /// Every task that delivers this one, read out of its slot. Empty for anything not a
13739    /// task.
13740    delivered_by: Vec<TaskRef>,
13741    labels: Vec<Label>,
13742    parent: Option<NativeId>,
13743    /// The issue's GitHub parent on a source reading sub-issues as children (`sub_issues:
13744    /// parent`), where [`Self::parent`] — the project it is filed under — is always `None`.
13745    #[serde(default)]
13746    issue_parent: Option<NativeId>,
13747    // 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.
13748    origin: Option<String>,
13749    /// What each board text field this instance projects metadata onto holds for this item,
13750    /// by field name — only the fields holding a value, an empty text included. What a write compares the
13751    /// item's metadata against, so a field already holding its value is not written again;
13752    /// never read back as metadata, which the slot alone reports.
13753    projected: BTreeMap<String, String>,
13754    /// The issue's own number on its repository, as GitHub reports it.
13755    ///
13756    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
13757    /// declares none, and a draft is not filed in a repository to be numbered by one — and
13758    /// an issue this run created whose creating mutation answered without one, which is a
13759    /// response GitHub's own schema says cannot happen and which a landed write is not
13760    /// worth failing over. An `Issue` read off the board always has one.
13761    number: Option<u64>,
13762    url: Option<String>,
13763    created_at: Option<DateTime<Utc>>,
13764    updated_at: Option<DateTime<Utc>>,
13765    /// When the board item itself last changed — what a board field write moves — when the
13766    /// read that reached it said.
13767    item_updated_at: Option<DateTime<Utc>>,
13768    /// How many comments the issue holds, when the read that reached it said.
13769    comment_count: Option<u64>,
13770    /// How many relationship events the issue's timeline holds — every dependency, as
13771    /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
13772    /// removed — when the read that reached it said.
13773    relationship_events: Option<u64>,
13774    /// The server's clock on the response that read it — its `Date` header — when the
13775    /// response carried one.
13776    served_at: Option<DateTime<Utc>>,
13777    /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
13778    /// its labels — when the read that reached it carried all three whole.
13779    #[serde(default)]
13780    facets: Option<change_token::FacetsDigest>,
13781    own_repository: Option<Repository>,
13782    repositories: Vec<Repository>,
13783    classification: Classification,
13784    slot: BTreeMap<String, Value>,
13785    /// The node id of the board this item sits on, when the read that reached it said.
13786    board_id: Option<String>,
13787    /// The definition of every board field this item holds a value of, in the shape a read
13788    /// of the board's own `fields` gives one.
13789    ///
13790    /// Only the fields this item has a value in: a field it holds nothing of is not here,
13791    /// which says nothing about whether the board has it.
13792    fields: Vec<Value>,
13793    /// Every field the board this item sits on defines, as its own read of the board's
13794    /// `fields` gives them — when the read that reached the item carried them, which a read
13795    /// of it by its own id does. What a write of it needs of the board, then, needs no read
13796    /// of the board.
13797    board_fields: Option<Value>,
13798    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
13799    /// selects one — when the read that reached it carried the connection to its end, which a
13800    /// read of it by its own id does for any issue blocked by no more than a page. What a
13801    /// write reconciles that relationship against, and what a read of its forward edges in
13802    /// the same command answers with.
13803    blocked_by: Option<Vec<Value>>,
13804    /// Every person the item is assigned to, by login in the order GitHub answered them — read
13805    /// only by a source opted in to assignees, and `None` for any other, and for a record no
13806    /// read or write of this process showed them on.
13807    #[serde(default)]
13808    assignees: Option<Vec<String>>,
13809}
13810
13811impl Resolved {
13812    /// The board this item's own read names it on, when that read named one this source can
13813    /// address.
13814    fn named_board(&self) -> Option<BoardId> {
13815        self.board_id
13816            .as_deref()
13817            .and_then(|id| BoardId::parse(id).ok())
13818    }
13819
13820    /// The board's id and every field it defines, when the read that reached this item
13821    /// carried both — which a read of it by its own id does.
13822    fn carried_board(&self) -> Option<BoardFields> {
13823        Some(BoardFields {
13824            id: self.named_board()?,
13825            fields: self.board_fields.clone()?,
13826        })
13827    }
13828
13829    /// Whether this item holds a value of the board field called `name`, and so carries
13830    /// that field's definition. `false` says nothing about whether the board has the field.
13831    fn defines(&self, name: &str) -> bool {
13832        self.fields
13833            .iter()
13834            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
13835    }
13836
13837    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
13838    /// in a field of its own, and none of the five keys that are only an encoding.
13839    ///
13840    /// The two delivery keys are left out for every kind, not only for a task: they are
13841    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
13842    /// document carrying one holds nothing a caller's own metadata could mean by it.
13843    fn metadata(&self) -> BTreeMap<String, Value> {
13844        let mut metadata = self.slot.clone();
13845        metadata.remove(Repository::METADATA_KEY);
13846        metadata.remove(DependencyEdge::RECORDED_KEY);
13847        metadata.remove(ItemKind::METADATA_KEY);
13848        metadata.remove(TaskRef::DELIVERS_KEY);
13849        metadata.remove(TaskRef::DELIVERED_BY_KEY);
13850        metadata.remove(Classification::METADATA_KEY);
13851        // The board field is the origin, and the body's copy of it is only a mirror for the
13852        // issue search to find: an item whose field holds none has none, whatever its body
13853        // says, so no reader ever sees two answers.
13854        metadata.remove(ORIGIN_KEY);
13855        if let Some(origin) = &self.origin {
13856            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
13857        }
13858        metadata
13859    }
13860
13861    /// Where this item is, as a link a reader can open.
13862    ///
13863    /// A board is a hosted place and every issue on it has a web address, so that address
13864    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
13865    /// of place it is, so a reader knows to open it rather than to read a file out. It
13866    /// does not replace or derive from `url`: the field goes on reporting exactly what it
13867    /// reported before, and this says what that address *is*.
13868    ///
13869    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
13870    /// rather than a third variant, which is the contract's "the source did not say". An
13871    /// issue this run created is not one of those: its address comes back from the
13872    /// creating mutation, so it is somewhere a reader can open from the moment it exists
13873    /// rather than from whenever the board read catches up.
13874    fn location(&self) -> Option<Location> {
13875        self.url.clone().map(Location::Url)
13876    }
13877
13878    /// The short handle this board's backend shows people for a task: the issue's number
13879    /// alone, as a decimal string.
13880    ///
13881    /// The number alone rather than `owner/repo#1043`, because that is the contract's
13882    /// value for this backend. A draft has no number and so no handle, which is the
13883    /// contract's *absent* rather than a handle of some other shape — and the native
13884    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
13885    /// derives from.
13886    fn key(&self) -> Option<String> {
13887        self.number.map(|number| number.to_string())
13888    }
13889
13890    /// This item's change token — see [`change_token`] — or `None` when the read that
13891    /// reached it did not carry all six of its parts, which a draft never does, did not say
13892    /// when GitHub served it, or found it changed too recently to have settled.
13893    fn version(&self) -> Option<String> {
13894        let parts = self.token_parts()?;
13895        change_token::settled(parts.issue, parts.item, self.served_at?)
13896            .then(|| change_token::spell(&parts))
13897    }
13898
13899    /// The change token this item, read just after a write of it, holds now — whether or not it
13900    /// has settled: what a written task's `version` answers (see
13901    /// [`GitHubProjectsSource::written_tokens`]), spelled from the parts a later read spells its
13902    /// token from.
13903    fn written_version(&self) -> Option<String> {
13904        self.token_parts().map(|parts| change_token::spell(&parts))
13905    }
13906
13907    /// Every part of this item's change token, or `None` when the read that reached it did not
13908    /// carry them all — a draft never does.
13909    fn token_parts(&self) -> Option<change_token::Parts> {
13910        match self.content_kind {
13911            ContentKind::Issue => Some(change_token::Parts {
13912                issue: self.updated_at?,
13913                item: self.item_updated_at?,
13914                comments: self.comment_count?,
13915                relationships: self.relationship_events?,
13916                facets: self.facets.clone()?,
13917                repository: self.own_repository.clone()?,
13918            }),
13919            ContentKind::DraftIssue => None,
13920        }
13921    }
13922
13923    /// Whether its `Priority` field holds a value at all, mapped or not.
13924    fn holds_priority(&self) -> bool {
13925        self.priority != HeldPriority::Read(Priority::None)
13926    }
13927
13928    /// The task this item is.
13929    ///
13930    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
13931    /// reading that as a level would be a guess, and reading it as `none` would let the next
13932    /// copy clear a priority a person set.
13933    fn task(&self) -> Result<Task, SourceError> {
13934        let priority = match &self.priority {
13935            HeldPriority::Read(priority) => *priority,
13936            HeldPriority::Unmapped(option) => {
13937                return Err(SourceError::Malformed {
13938                    message: format!(
13939                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
13940                         this source's priority_mapping does not name, so its priority cannot be \
13941                         read; next: name {option:?} under priority_mapping, or move the item to \
13942                         a mapped option",
13943                        self.id,
13944                        self.number
13945                            .map(|number| format!(" (#{number})"))
13946                            .unwrap_or_default()
13947                    ),
13948                });
13949            }
13950        };
13951        let metadata = self.metadata();
13952        Ok(Task {
13953            version: self.version(),
13954            id: self.id.clone(),
13955            key: self.key(),
13956            title: self.title.clone(),
13957            // The `## Links` section the engine rendered is stripped, so content round-trips.
13958            content: onetaskgraph_plugin_api::read_content(self.body.clone(), &metadata),
13959            status: self.status.clone(),
13960            priority,
13961            labels: self.labels.clone(),
13962            project: self.parent.clone(),
13963            url: self.url.clone(),
13964            location: self.location(),
13965            created_at: self.created_at,
13966            updated_at: self.updated_at,
13967            metadata,
13968            repositories: self.repositories.clone(),
13969            delivers: self.delivers.clone(),
13970            delivered_by: self.delivered_by.clone(),
13971            classification: self.classification,
13972            assignees: self.assignees.clone(),
13973        })
13974    }
13975
13976    fn project(&self) -> Project {
13977        Project {
13978            version: self.version(),
13979            id: self.id.clone(),
13980            title: self.title.clone(),
13981            content: self.body.clone(),
13982            status: self.status.clone(),
13983            labels: self.labels.clone(),
13984            url: self.url.clone(),
13985            location: self.location(),
13986            created_at: self.created_at,
13987            updated_at: self.updated_at,
13988            metadata: self.metadata(),
13989            repositories: self.repositories.clone(),
13990            classification: self.classification,
13991        }
13992    }
13993
13994    /// The same issue as a document: the project it is filed under, and no status and no
13995    /// dependencies, because a document is not work.
13996    fn document(&self) -> Document {
13997        Document {
13998            version: self.version(),
13999            id: self.id.clone(),
14000            title: self.title.clone(),
14001            content: self.body.clone(),
14002            project: self.parent.clone(),
14003            labels: self.labels.clone(),
14004            url: self.url.clone(),
14005            location: self.location(),
14006            created_at: self.created_at,
14007            updated_at: self.updated_at,
14008            metadata: self.metadata(),
14009            repositories: self.repositories.clone(),
14010            classification: self.classification,
14011        }
14012    }
14013}
14014
14015/// Where one targeted update moves an item's status, and which of its two halves move.
14016struct StatusMove {
14017    /// The board the item's `Status` field is on.
14018    board: BoardId,
14019    /// The `Status` field's id.
14020    field: String,
14021    /// The option's id.
14022    option: String,
14023    /// The option's name, as the board spells it.
14024    name: String,
14025    /// What the status asks of the issue's state.
14026    target: StatusTarget,
14027    /// The status the item reads as once it is there.
14028    landed: Status,
14029    /// Which of the status's two halves differ from what the item holds.
14030    moves: Moves,
14031}
14032
14033/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
14034/// closed state of its issue, or both. A status neither half of which differs is no move at all,
14035/// and is not a value of this type.
14036#[derive(Clone, Copy, PartialEq, Eq)]
14037enum Moves {
14038    /// The option alone.
14039    Option,
14040    /// The issue's state alone: open, closed, or closed with another reason.
14041    State,
14042    /// Both.
14043    Both,
14044}
14045
14046impl Moves {
14047    /// What differs, or `None` when nothing does.
14048    const fn of(option: bool, state: bool) -> Option<Self> {
14049        match (option, state) {
14050            (true, true) => Some(Self::Both),
14051            (true, false) => Some(Self::Option),
14052            (false, true) => Some(Self::State),
14053            (false, false) => None,
14054        }
14055    }
14056
14057    /// Whether the option moves.
14058    const fn option(self) -> bool {
14059        matches!(self, Self::Option | Self::Both)
14060    }
14061
14062    /// Whether the issue's state moves.
14063    const fn state(self) -> bool {
14064        matches!(self, Self::State | Self::Both)
14065    }
14066}
14067
14068/// What one write is, and the status that comes with being it.
14069///
14070/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
14071/// status and a task or a project always has one, so "a document carrying a status" and
14072/// "a task carrying none" are states a write cannot be in rather than states every use
14073/// site below has to defend against.
14074enum Written<'a> {
14075    /// A document, which is not work and so has no status at all.
14076    Document,
14077    /// A task or a project, and the status it is being written with.
14078    Work(ItemKind, &'a Status),
14079}
14080
14081impl Written<'_> {
14082    /// Which of the board's three kinds this write is.
14083    const fn kind(&self) -> BoardKind {
14084        match self {
14085            Self::Document => BoardKind::Document,
14086            Self::Work(kind, _) => BoardKind::Work(*kind),
14087        }
14088    }
14089
14090    /// The status this write carries. A document carries none, so a write of one says
14091    /// nothing about the issue's open or closed state and selects no board `Status`
14092    /// option.
14093    const fn status(&self) -> Option<&Status> {
14094        match self {
14095            Self::Document => None,
14096            Self::Work(_, status) => Some(status),
14097        }
14098    }
14099
14100    /// The status this write carries with the kind whose half of `status_mapping` it is
14101    /// written through.
14102    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
14103        match self {
14104            Self::Document => None,
14105            Self::Work(kind, status) => Some((*kind, status)),
14106        }
14107    }
14108}
14109
14110/// The item being written, in the one shape all three write methods reach.
14111struct Incoming<'a> {
14112    written: Written<'a>,
14113    /// The title a person wrote. A document's goes onto the issue with
14114    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
14115    title: &'a str,
14116    content: Option<&'a str>,
14117    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
14118    labels: &'a [Label],
14119    metadata: &'a BTreeMap<String, Value>,
14120    repositories: &'a [Repository],
14121    /// Recorded in the slot while private, and nowhere while public.
14122    classification: Classification,
14123    parent: Option<&'a NativeId>,
14124    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
14125    /// what keeps either key out of their slot.
14126    delivers: &'a [TaskRef],
14127    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
14128    delivered_by: &'a [TaskRef],
14129    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
14130    /// project, a document, and every write to an instance with no `priority_mapping` —
14131    /// which is what keeps such a write's requests exactly what they were before.
14132    priority: Option<Priority>,
14133    /// [`Task::assignees`] of a task written to a source opted in to them — the people it is
14134    /// to be assigned to, replacing whoever it holds — and `None` for everything else, which
14135    /// leaves an existing item's as they are and creates one unassigned.
14136    assignees: Option<&'a [String]>,
14137}
14138
14139/// What one write does to one projected metadata text field, by the field's id.
14140#[derive(Debug, Clone)]
14141enum ProjectionWrite {
14142    /// Write this text.
14143    Set {
14144        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.
14145        text: String,
14146    },
14147    /// Clear the field's value.
14148    Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
14149}
14150
14151impl ProjectionWrite {
14152    /// Add this write to a field write's list of values and list of clears.
14153    fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
14154        match self {
14155            Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
14156            Self::Clear { field } => clears.push(field),
14157        }
14158    }
14159}
14160
14161/// What one write does to an item's `Priority` field.
14162enum PriorityWrite {
14163    /// Select this option of this field.
14164    Select {
14165        /// The `Priority` field's id.
14166        field: String,
14167        /// The mapped option's id.
14168        option: String,
14169    },
14170    /// Clear the field's value, which is what `none` is.
14171    Clear {
14172        /// The `Priority` field's id.
14173        field: String,
14174    },
14175}
14176
14177impl Incoming<'_> {
14178    /// The title this write puts on the issue.
14179    fn written_title(&self) -> String {
14180        match self.written {
14181            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
14182            Written::Work(..) => self.title.to_owned(),
14183        }
14184    }
14185}
14186
14187#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
14188enum ContentKind {
14189    DraftIssue,
14190    Issue,
14191}
14192
14193/// What one board issue is: a document, or the work an [`ItemKind`] names.
14194///
14195/// A type of this source's own rather than an `ItemKind` with a third variant, because
14196/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
14197/// document — the contract keeps a document out of that enum deliberately. Holding the
14198/// board's three answers in one value is what makes every place that asks "which is this?"
14199/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
14200/// two thirds of the board.
14201#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
14202enum BoardKind {
14203    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
14204    Document,
14205    /// Every other issue, and every draft.
14206    Work(ItemKind),
14207}
14208
14209impl BoardKind {
14210    /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
14211    /// sub-issues and carrying the kind marker `marked` in its slot.
14212    ///
14213    /// The design prefix is read *first*, before either of the two rules that separate a
14214    /// project from a task. A document is not work whatever sub-issues it has and whatever
14215    /// marker it carries, and reading the prefix later would make a design issue with none of
14216    /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
14217    /// overrides it: an issue filed under a project is that project's task even when it has
14218    /// sub-issues of its own.
14219    fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
14220        if title.starts_with(DESIGN_TITLE_PREFIX) {
14221            Self::Document
14222        } else if filed {
14223            Self::Work(ItemKind::Task)
14224        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
14225            Self::Work(ItemKind::Project)
14226        } else {
14227            Self::Work(ItemKind::Task)
14228        }
14229    }
14230
14231    /// What the change token's facets call this kind.
14232    const fn facet(self) -> change_token::FacetKind {
14233        match self {
14234            Self::Document => change_token::FacetKind::Document,
14235            Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
14236            Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
14237        }
14238    }
14239
14240    /// Whose half of `status_mapping` an item of this kind reads its status through. A
14241    /// document has no status of its own, so the task half stands in for whatever the issue
14242    /// holds; nothing reports it.
14243    const fn status_kind(self) -> ItemKind {
14244        match self {
14245            Self::Document => ItemKind::Task,
14246            Self::Work(kind) => kind,
14247        }
14248    }
14249
14250    /// How a refusal names this kind to the person reading it.
14251    const fn describes(self) -> &'static str {
14252        match self {
14253            Self::Document => "document",
14254            Self::Work(kind) => kind.marker(),
14255        }
14256    }
14257}
14258
14259/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
14260///
14261/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
14262/// the shared cross-source journeys assert one answer to one question, so two sources
14263/// that disagree about what "carries the label bug" means fail them.
14264fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
14265    let holds = |name: &String| {
14266        labels
14267            .iter()
14268            .any(|label| label.name.eq_ignore_ascii_case(name))
14269    };
14270    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
14271        && filter.all_of.iter().all(holds)
14272        && !filter.none_of.iter().any(holds)
14273}
14274
14275/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
14276/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
14277fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
14278    statuses.is_empty() || statuses.contains(&category)
14279}
14280
14281/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
14282///
14283/// `content` is the item's own prose — the body with this source's trailing metadata
14284/// comment already taken off — so a search never matches an encoding the author of the
14285/// issue never wrote.
14286fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
14287    let terms = query.terms.to_lowercase();
14288    let in_title = title.to_lowercase().contains(&terms);
14289    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
14290    match query.fields {
14291        TextFields::Title => in_title,
14292        TextFields::Content => in_content,
14293        TextFields::TitleOrContent => in_title || in_content,
14294    }
14295}
14296
14297/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
14298///
14299/// The project predicate is passed separately because a read narrowed to one project has
14300/// already answered it by asking *that project* for its own items — and re-applying it
14301/// there would compare the caller's selector, which may be a project's **name**, against
14302/// the id of the project that name resolved to, and keep nothing. Every other read passes
14303/// `query.project` and applies it here, which is what keeps `projects` a predicate this
14304/// source really does apply.
14305fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
14306    labels_match(&task.labels, &query.labels)
14307        && status_matches(task.status.category, &query.statuses)
14308        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
14309        && match project {
14310            ProjectFilter::Any => true,
14311            ProjectFilter::Orphans => task.project.is_none(),
14312            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
14313        }
14314        && query
14315            .text
14316            .as_ref()
14317            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
14318        // Against the parsed metadata slot, and against the origin field, which is where
14319        // `Resolved::metadata` reads each of them from.
14320        && query.metadata_matches(&task.metadata)
14321        && query.origin_matches(&task.metadata)
14322}
14323
14324fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
14325    labels_match(&project.labels, &query.labels)
14326        && status_matches(project.status.category, &query.statuses)
14327        && query
14328            .text
14329            .as_ref()
14330            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
14331}
14332
14333/// The same three predicates a task query carries, minus the status filter.
14334///
14335/// A document is not work, so it has no status for one to compare against and the query
14336/// type carries none. The project predicate is the same one — a design issue filed under a
14337/// project issue is in that project, and one filed under nothing is in none — so it is
14338/// spelled the same way here rather than answered differently.
14339fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
14340    labels_match(&document.labels, &query.labels)
14341        && match project {
14342            ProjectFilter::Any => true,
14343            ProjectFilter::Orphans => document.project.is_none(),
14344            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
14345        }
14346        && query
14347            .text
14348            .as_ref()
14349            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
14350}
14351
14352/// Why a GitHub item's change token does not vouch for its forward edges.
14353///
14354/// A forward edge names its far end and that far end's kind, and the near item's token moves
14355/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
14356/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
14357/// nothing the near token reads, so an edge kept at the near version could name a far end that
14358/// is no longer what it said.
14359const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
14360     whether it became a document, or its deletion, so its forward edges are always read from \
14361     github";
14362
14363/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
14364/// categories an open issue reads as narrows a board-scoped search with.
14365const OPEN_QUALIFIER: &str = "is:open";
14366
14367/// The board filter `task status reconcile` reads the board through: closed items alone.
14368const CLOSED_FILTER: &str = "is:closed";
14369
14370/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
14371/// one reference, and a slot the batch has none for names its last one again.
14372fn reference_batch(batch: &[IssueRef]) -> Value {
14373    let mut variables = serde_json::Map::new();
14374    for slot in 0..REFERENCE_BATCH {
14375        let issue = batch.get(slot).or(batch.last());
14376        variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
14377        variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
14378        variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
14379    }
14380    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
14381    variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
14382    variables.insert("duplicates".to_owned(), json!(true));
14383    Value::Object(variables)
14384}
14385
14386/// One issue an issue read by reference answered, in the shape every other read of an issue
14387/// hands its resolver: each board membership's `Status` value, read by name, as the one field
14388/// value that membership carries.
14389///
14390/// A membership's `status` is `null` when its card sits at no option — or on a board with no
14391/// field of that name — and otherwise an option value naming its option; anything else is not
14392/// an answer to this read, and is refused rather than read as a card at no option.
14393fn held_issue(issue: &Value) -> Result<Value, SourceError> {
14394    let malformed = |what: &str| SourceError::Malformed {
14395        message: format!("GitHub answered an issue read by reference with {what}"),
14396    };
14397    let mut issue = issue.clone();
14398    let nodes = issue
14399        .pointer_mut("/projectItems/nodes")
14400        .and_then(Value::as_array_mut)
14401        .ok_or_else(|| malformed("no list of board memberships"))?;
14402    for node in nodes {
14403        let node = node
14404            .as_object_mut()
14405            .ok_or_else(|| malformed("a board membership that is not an object"))?;
14406        let status = match node.get("status") {
14407            None => return Err(malformed("a board membership with no status member")),
14408            Some(Value::Null) => None,
14409            Some(status) if status.get("name").is_some_and(Value::is_string) => {
14410                Some(status.clone())
14411            }
14412            // A single-select value of another field type answers `{}`; only an option value
14413            // carries a name.
14414            Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
14415            Some(_) => return Err(malformed("a membership status that names no option")),
14416        };
14417        node.insert(
14418            "fieldValues".to_owned(),
14419            json!({"nodes": status.into_iter().collect::<Vec<_>>(),
14420                   "pageInfo": {"hasNextPage": false}}),
14421        );
14422    }
14423    Ok(issue)
14424}
14425
14426/// The issue one slot of a read by reference answered, `None` when GitHub answered that
14427/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
14428/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
14429/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
14430/// than a reference nothing holds.
14431fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
14432    let alias = format!("r{slot}");
14433    let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
14434        message: format!("GitHub answered a read by reference with no {alias}"),
14435    })?;
14436    if repository.is_null() {
14437        return Ok(None);
14438    }
14439    let issue = repository
14440        .get("issue")
14441        .ok_or_else(|| SourceError::Malformed {
14442            message: format!("GitHub answered {alias} of a read by reference with no issue"),
14443        })?;
14444    if issue.is_null() {
14445        return Ok(None);
14446    }
14447    // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
14448    if optional_str(issue, "__typename")? != Some("Issue") {
14449        return Err(SourceError::Malformed {
14450            message: format!(
14451                "GitHub answered {alias} of a read by reference with an issue whose __typename \
14452                 is not Issue"
14453            ),
14454        });
14455    }
14456    Ok(Some(issue))
14457}
14458
14459/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
14460fn spoken_reason(reason: &str) -> String {
14461    reason.to_ascii_lowercase().replace('_', " ")
14462}
14463
14464impl GitHubProjectsSource {
14465    /// Every task and project of this board whose card contradicts its issue's closure, each
14466    /// judged against its own kind's half of `status_mapping`, read through the board's own
14467    /// `is:closed` filter, page by page — never the board's unfiltered `items`.
14468    ///
14469    /// Each item the filter answers is confirmed from its own node: an open one, one whose
14470    /// closure reads `unknown`, and one whose card is already in line are left out whatever
14471    /// the filter let through. The board's fields ride along and are held for the option
14472    /// writes that follow, so those need no read of the board.
14473    async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
14474        let mut after: Option<String> = None;
14475        let mut found = Vec::new();
14476        loop {
14477            let data = self
14478                .graphql(
14479                    graphql::CLOSED_ITEMS,
14480                    json!({"owner":self.owner,"number":self.project_number,
14481                           "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
14482                           "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
14483                )
14484                .await?;
14485            let board = data
14486                .pointer("/closedItems/projectV2")
14487                .filter(|value| !value.is_null())
14488                .ok_or_else(|| SourceError::Refused {
14489                    message: format!(
14490                        "GitHub project {}/{} was not found or is not visible to the token",
14491                        self.owner, self.project_number
14492                    ),
14493                })?;
14494            *self.fields_cache()? = Some(BoardFields {
14495                id: BoardId::parse(required_str(board, "id")?)?,
14496                fields: board.get("fields").cloned().unwrap_or(Value::Null),
14497            });
14498            let items = board.get("items").ok_or_else(|| SourceError::Malformed {
14499                message: "GitHub project closed items are missing".into(),
14500            })?;
14501            for item in items
14502                .get("nodes")
14503                .and_then(Value::as_array)
14504                .ok_or_else(|| SourceError::Malformed {
14505                    message: "GitHub project items.nodes is not an array".into(),
14506                })?
14507            {
14508                let Some(resolved) = self.resolve(item)? else {
14509                    continue;
14510                };
14511                let BoardKind::Work(kind) = resolved.kind else {
14512                    continue;
14513                };
14514                let Some((closed_as, name)) = self.statuses.contradicted(
14515                    kind,
14516                    resolved.option.as_deref(),
14517                    resolved.closed,
14518                    resolved.reason.as_deref(),
14519                ) else {
14520                    continue;
14521                };
14522                found.push(StatusContradiction {
14523                    closed_as,
14524                    to: name.to_owned(),
14525                    option: resolved.option.clone(),
14526                    item: match kind {
14527                        ItemKind::Task => ContradictedItem::Task(resolved.task()?),
14528                        ItemKind::Project => ContradictedItem::Project(resolved.project()),
14529                    },
14530                });
14531            }
14532            let info = items
14533                .get("pageInfo")
14534                .ok_or_else(|| SourceError::Malformed {
14535                    message: "GitHub project items have no pageInfo".into(),
14536                })?;
14537            if !required_bool(info, "hasNextPage")? {
14538                return Ok(found);
14539            }
14540            let next = required_str(info, "endCursor")?;
14541            validate_cursor_progress(after.as_deref(), next)?;
14542            after = Some(next.to_owned());
14543        }
14544    }
14545
14546    /// What this board holds for each issue in `issues`, once each in the order given, read
14547    /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
14548    ///
14549    /// An issue GitHub cannot find for this source's credential, and one with no item on this
14550    /// board, are both absent. A closure that already says the work will not be done here —
14551    /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
14552    /// completed whose card sits at its kind's `done` option is unchanged, and every other item
14553    /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
14554    /// tasks as sub-issues — is held under exactly these rules, judged against the project
14555    /// kind's half of `status_mapping`.
14556    async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
14557        let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
14558        for issue in issues {
14559            if !distinct.contains(issue) {
14560                distinct.push(issue.clone());
14561            }
14562        }
14563        let done = |kind: ItemKind| {
14564            self.statuses
14565                .target(kind, StatusCategory::Done)
14566                .option()
14567                .unwrap_or("Done")
14568                .to_owned()
14569        };
14570        // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
14571        // its range names no issue any repository can hold: it is answered absent and never sent,
14572        // where binding it would have GitHub refuse the whole batch.
14573        let readable: Vec<IssueRef> = distinct
14574            .iter()
14575            .filter(|issue| i32::try_from(issue.number).is_ok())
14576            .cloned()
14577            .collect();
14578        let mut holdings = Vec::with_capacity(readable.len());
14579        for batch in readable.chunks(REFERENCE_BATCH) {
14580            let data = self
14581                .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
14582                .await?;
14583            for (slot, issue) in batch.iter().enumerate() {
14584                let node = referenced_issue(&data, slot)?;
14585                let resolved = match node {
14586                    Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
14587                    None => None,
14588                };
14589                // A task and a project are both held; a document is not work, so nothing is.
14590                let Some((item, kind)) = resolved.and_then(|item| match item.kind {
14591                    BoardKind::Work(kind) => Some((item, kind)),
14592                    BoardKind::Document => None,
14593                }) else {
14594                    holdings.push(IssueHolding {
14595                        issue: issue.clone(),
14596                        completion: Completion::Absent,
14597                    });
14598                    continue;
14599                };
14600                let task = match kind {
14601                    ItemKind::Task => {
14602                        let held = item.task()?;
14603                        HeldTask {
14604                            id: held.id,
14605                            kind,
14606                            option: item.option.clone(),
14607                            classification: held.classification,
14608                            repositories: held.repositories,
14609                            delivers: held.delivers,
14610                        }
14611                    }
14612                    // A project delivers no task, so what its write keeps in step is nothing.
14613                    ItemKind::Project => {
14614                        let held = item.project();
14615                        HeldTask {
14616                            id: held.id,
14617                            kind,
14618                            option: item.option.clone(),
14619                            classification: held.classification,
14620                            repositories: held.repositories,
14621                            delivers: Vec::new(),
14622                        }
14623                    }
14624                };
14625                let done = done(kind);
14626                let completion = match (item.closed, item.reason.as_deref()) {
14627                    (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
14628                        task,
14629                        reason: format!("closed as {}", spoken_reason(reason)),
14630                    },
14631                    (true, None | Some("COMPLETED"))
14632                        if item
14633                            .option
14634                            .as_deref()
14635                            .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
14636                    {
14637                        Completion::Unchanged { task }
14638                    }
14639                    _ => {
14640                        let kept = item.clone();
14641                        self.held_cache
14642                            .lock()
14643                            .map_err(|_| SourceError::Unavailable {
14644                                message: "items read by issue reference were left \
14645                                          inconsistent; run the command again"
14646                                    .into(),
14647                            })?
14648                            .insert(kept.id.clone(), kept);
14649                        Completion::Due {
14650                            task,
14651                            to: done.clone(),
14652                        }
14653                    }
14654                };
14655                holdings.push(IssueHolding {
14656                    issue: issue.clone(),
14657                    completion,
14658                });
14659            }
14660        }
14661        let mut read = holdings.into_iter();
14662        let mut answered = Vec::with_capacity(distinct.len());
14663        for issue in distinct {
14664            if i32::try_from(issue.number).is_ok() {
14665                answered.push(read.next().ok_or_else(|| SourceError::Malformed {
14666                    message: format!("no holding was read for {issue}"),
14667                })?);
14668            } else {
14669                answered.push(IssueHolding {
14670                    issue,
14671                    completion: Completion::Absent,
14672                });
14673            }
14674        }
14675        Ok(answered)
14676    }
14677}
14678
14679#[async_trait::async_trait]
14680impl TaskSource for GitHubProjectsSource {
14681    fn kind(&self) -> &'static str {
14682        KIND
14683    }
14684    /// GitHub's issue search, which narrows a read of the comments made since an instant, is
14685    /// an index behind the writes it indexes — the credentialed lane has watched it miss a newly
14686    /// commented issue for thirty seconds — so a watermark is set back by
14687    /// [`COMMENT_SEARCH_LAG`], four times that.
14688    fn comment_consistency_window(&self) -> std::time::Duration {
14689        COMMENT_SEARCH_LAG
14690    }
14691    /// The `Date` of the last answer GitHub gave this source.
14692    fn served_at(&self) -> Option<DateTime<Utc>> {
14693        *self
14694            .last_served
14695            .lock()
14696            .unwrap_or_else(std::sync::PoisonError::into_inner)
14697    }
14698    fn attach_body_markup(&self, markup: onetaskgraph_plugin_api::SharedBodyMarkup) {
14699        let _ = self.body_markup.set(markup);
14700    }
14701    fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
14702        change_token::order(a, b)
14703    }
14704    /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
14705    /// member, the change each part of the token moves for. Not its forward edges — see
14706    /// [`FAR_ENDS_UNCOVERED`].
14707    fn version_coverage(&self) -> VersionCoverage {
14708        VersionCoverage {
14709            forward_edges: Coverage::Uncovered {
14710                reason: FAR_ENDS_UNCOVERED.to_owned(),
14711            },
14712            ..VersionCoverage::whole()
14713        }
14714    }
14715    /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
14716    /// and for a far end deleted, so the token sees every change to a task's edges but one — a
14717    /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
14718    /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
14719    /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
14720    // 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.
14721    fn copy_version_coverage(&self) -> VersionCoverage {
14722        VersionCoverage::whole()
14723    }
14724    /// A project's fields, and not its forward edges, for the same reason a task's are not.
14725    fn project_version_coverage(&self) -> ProjectVersionCoverage {
14726        ProjectVersionCoverage {
14727            fields: Coverage::Covered,
14728            forward_edges: Coverage::Uncovered {
14729                reason: FAR_ENDS_UNCOVERED.to_owned(),
14730            },
14731        }
14732    }
14733    /// A document's fields and the manifest of its image assets, which its metadata slot
14734    /// records: both are the issue's body, whose every edit moves the token's issue time.
14735    fn document_version_coverage(&self) -> DocumentVersionCoverage {
14736        DocumentVersionCoverage::whole()
14737    }
14738    fn write_context(&self, id: &NativeId) -> Option<Value> {
14739        self.context_of(id)
14740    }
14741    fn board_context(&self) -> Option<Value> {
14742        self.board_context_held()
14743    }
14744    fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
14745        self.prime(id, context, board)
14746    }
14747    fn unprime(&self, id: &NativeId) {
14748        self.unprime_held(id);
14749    }
14750    fn placement_context(&self) -> Option<Value> {
14751        self.placement_held()
14752    }
14753    fn prime_placement(&self, context: &Value) -> bool {
14754        self.prime_placement_held(context)
14755    }
14756    fn written_context(&self, id: &NativeId) -> Option<Value> {
14757        self.written_held(id)
14758    }
14759    fn prime_written(&self, id: &NativeId, context: &Value, task: &Task) -> bool {
14760        self.prime_written_held(id, context, task)
14761    }
14762    fn begin_journal(&self) {
14763        self.expected.begin();
14764    }
14765    fn end_journal(&self) -> Option<WriteJournal> {
14766        Some(self.expected.end())
14767    }
14768    fn capabilities(&self) -> Capabilities {
14769        Capabilities {
14770            projects: Support::Native,
14771            documents: Support::Native,
14772            comments: Support::Native,
14773            assets: Support::Native,
14774            priority: if self.priorities.is_some() {
14775                Support::Native
14776            } else {
14777                Support::Unsupported
14778            },
14779            filter_by_priority: Support::Native,
14780            filter_by_comment_activity: Support::Native,
14781            filter_by_metadata: Support::Native,
14782            filter_by_origin: Support::Native,
14783            reconcile_status: Support::Native,
14784            complete_by_issue: Support::Native,
14785            orphan_tasks: Support::Native,
14786            filter_by_label: Support::Native,
14787            filter_by_status: Support::Native,
14788            search_title: Support::Native,
14789            search_content: Support::Native,
14790            task_dependencies: DependencySupport::BothDirections,
14791            project_dependencies: DependencySupport::BothDirections,
14792            max_page_size: MAX_PAGE_SIZE,
14793            assignees: if self.assignees {
14794                Support::Native
14795            } else {
14796                Support::Unsupported
14797            },
14798        }
14799    }
14800    async fn visibility(
14801        &self,
14802        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14803    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14804        self.write_visibility(target).await
14805    }
14806    async fn visibility_with_project(
14807        &self,
14808        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14809        project: &onetaskgraph_plugin_api::WriteTarget<'_>,
14810    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14811        self.write_visibility_with_project(target, project).await
14812    }
14813    fn declare_visibility(&self, declared: onetaskgraph_plugin_api::Visibility) {
14814        self.declared_private.store(
14815            declared == onetaskgraph_plugin_api::Visibility::Private,
14816            std::sync::atomic::Ordering::SeqCst,
14817        );
14818    }
14819    fn scope_visibility<'a>(
14820        &'a self,
14821        operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
14822    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
14823        self.scope_visibility_reads(operation)
14824    }
14825
14826    /// Where a create of `target` would file its issue, by [`Self::creation_rule`] — the rule
14827    /// the create applies — reading the parent project's issue only when `target` names one.
14828    async fn creation_repository(
14829        &self,
14830        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14831    ) -> Result<Option<Repository>, SourceError> {
14832        let onetaskgraph_plugin_api::WriteTarget::New {
14833            repositories,
14834            project,
14835        } = target
14836        else {
14837            return Ok(None);
14838        };
14839        let target = self
14840            .creation_rule(repositories, *project, "a new item")
14841            .await?;
14842        Repository::try_from(target.origin())
14843            .map(Some)
14844            .map_err(|message| SourceError::Malformed { message })
14845    }
14846    /// Every issue of the repository a create of `target` files in whose body carries `key` in
14847    /// its metadata slot, read off that repository's issues newest first back to `since` —
14848    /// never through search, whose index lags a fresh issue by seconds.
14849    async fn created_with_key(
14850        &self,
14851        record: onetaskgraph_plugin_api::MetadataRecord,
14852        key: &str,
14853        since: Option<DateTime<Utc>>,
14854        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14855    ) -> Result<Vec<NativeId>, SourceError> {
14856        let _ = record;
14857        let onetaskgraph_plugin_api::WriteTarget::New {
14858            repositories,
14859            project,
14860        } = target
14861        else {
14862            return Ok(Vec::new());
14863        };
14864        let repository = self
14865            .creation_rule(repositories, *project, "a new item")
14866            .await?;
14867        let wanted = Value::String(key.to_owned());
14868        let mut found = Vec::new();
14869        let mut after: Option<String> = None;
14870        let mut followed = std::collections::HashSet::new();
14871        loop {
14872            let data = self
14873                .graphql(
14874                    graphql::ISSUES_BY_CREATION,
14875                    json!({"owner": repository.owner, "name": repository.name,
14876                           "first": MAX_PAGE_SIZE, "after": after}),
14877                )
14878                .await?;
14879            let issues =
14880                data.pointer("/repository/issues")
14881                    .ok_or_else(|| SourceError::Malformed {
14882                        message: format!(
14883                            "GitHub answered no issues for repository {}",
14884                            repository.slug()
14885                        ),
14886                    })?;
14887            let malformed = |what: &str| SourceError::Malformed {
14888                message: format!(
14889                    "GitHub answered a page of repository {}'s issues {what}",
14890                    repository.slug()
14891                ),
14892            };
14893            let nodes = issues
14894                .get("nodes")
14895                .and_then(Value::as_array)
14896                .ok_or_else(|| malformed("with no list of issues"))?;
14897            let mut older = false;
14898            for node in nodes {
14899                let id = node
14900                    .get("id")
14901                    .and_then(Value::as_str)
14902                    .filter(|id| !id.is_empty())
14903                    .ok_or_else(|| malformed("holding an issue with no id"))?;
14904                let created = optional_time(node, "createdAt")?
14905                    .ok_or_else(|| malformed("holding an issue with no creation time"))?;
14906                if since.is_some_and(|since| created < since) {
14907                    older = true;
14908                    break;
14909                }
14910                let body = match node.get("body") {
14911                    Some(Value::String(body)) => Some(body.clone()),
14912                    Some(Value::Null) => None,
14913                    _ => return Err(malformed("holding an issue with no body member")),
14914                };
14915                let (_, metadata) = metadata_body(body)?;
14916                if metadata.get(MetadataKey::CREATE_KEY) == Some(&wanted) {
14917                    found.push(NativeId(id.to_owned()));
14918                }
14919            }
14920            let next = issues
14921                .pointer("/pageInfo/hasNextPage")
14922                .and_then(Value::as_bool)
14923                .ok_or_else(|| malformed("that does not say whether another follows"))?;
14924            if older || !next {
14925                return Ok(found);
14926            }
14927            // A page that says another follows names where it starts, and never a page already
14928            // read: anything else would ask for the same pages again for ever.
14929            let cursor = issues
14930                .pointer("/pageInfo/endCursor")
14931                .and_then(Value::as_str)
14932                .filter(|cursor| !cursor.is_empty())
14933                .map(str::to_owned);
14934            if cursor
14935                .as_ref()
14936                .is_none_or(|cursor| !followed.insert(cursor.clone()))
14937            {
14938                return Err(SourceError::Malformed {
14939                    message: format!(
14940                        "GitHub answered a page of repository {}'s issues that says another \
14941                         follows without a new cursor to it",
14942                        repository.slug()
14943                    ),
14944                });
14945            }
14946            after = cursor;
14947        }
14948    }
14949
14950    /// `Repository.issues` lists an issue GitHub committed only once its listing catches up.
14951    fn create_key_lags(&self) -> bool {
14952        true
14953    }
14954
14955    /// The task as this process's own latest write of it left it, from the item it recorded
14956    /// that write as — never a request. Its `version` is always `None`: a board's change token
14957    /// is given only once an item has gone unchanged for [`change_token::SETTLED`], which an
14958    /// item this command has just written has not, so no write's own answer can vouch for one.
14959    async fn written_task(&self, id: &NativeId) -> Option<Task> {
14960        let held = self
14961            .created()
14962            .ok()?
14963            .iter()
14964            .find(|held| held.id == *id)
14965            .cloned()
14966            .or_else(|| {
14967                self.updated()
14968                    .ok()?
14969                    .iter()
14970                    .find(|held| held.id == *id)
14971                    .cloned()
14972            })?;
14973        let mut task = held.task().ok()?;
14974        task.version = self.written_token(id);
14975        Some(task)
14976    }
14977
14978    /// Why `creation_owners` refuses a create of `target`, by [`Self::creation_rule`] — read
14979    /// only when the list is not empty, so a source restricting nothing reads nothing.
14980    async fn creation_refusal(
14981        &self,
14982        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14983    ) -> Result<Option<String>, SourceError> {
14984        let onetaskgraph_plugin_api::WriteTarget::New {
14985            repositories,
14986            project,
14987        } = target
14988        else {
14989            return Ok(None);
14990        };
14991        if self.creation_owners.is_empty() {
14992            return Ok(None);
14993        }
14994        let target = self
14995            .creation_rule(repositories, *project, "a new item")
14996            .await?;
14997        Ok(self.owner_refusal(&target))
14998    }
14999
15000    async fn health(&self) -> Result<Health, SourceError> {
15001        let board = self.board_page(None, 1).await?;
15002        Ok(Health {
15003            reachable: true,
15004            detail: Some(format!(
15005                "reading GitHub project {}/{} ({})",
15006                self.owner,
15007                self.project_number,
15008                required_str(&board, "title")?
15009            )),
15010        })
15011    }
15012    /// The board's own node id, digested: the one request reading its fields, or none when
15013    /// this command has read them already.
15014    async fn designator(&self) -> Result<Option<Designator>, SourceError> {
15015        let board = self.board_fields().await?;
15016        Designator::of_backend(KIND, board.id.as_str())
15017            .map(Some)
15018            .map_err(|message| SourceError::Malformed { message })
15019    }
15020    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
15021        self.item_by_id(id)
15022            .await?
15023            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15024            .map(|item| item.task())
15025            .transpose()
15026    }
15027    async fn task_assets(
15028        &self,
15029        id: &NativeId,
15030    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
15031        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
15032    }
15033    async fn task_asset(
15034        &self,
15035        id: &NativeId,
15036        name: &onetaskgraph_plugin_api::AssetName,
15037    ) -> Result<Option<Vec<u8>>, SourceError> {
15038        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
15039            .await
15040    }
15041    async fn set_task_rendering_with_assets(
15042        &self,
15043        id: &NativeId,
15044        content: &str,
15045        provenance: &Value,
15046        _answers: &BTreeMap<String, Value>,
15047        assets: &onetaskgraph_plugin_api::AssetWrite,
15048    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
15049        self.replace_rendering(
15050            id,
15051            BoardKind::Work(ItemKind::Task),
15052            content,
15053            provenance,
15054            Some(assets),
15055        )
15056        .await
15057    }
15058    async fn document_assets(
15059        &self,
15060        id: &NativeId,
15061    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
15062        self.held_assets(id, BoardKind::Document).await
15063    }
15064    async fn document_asset(
15065        &self,
15066        id: &NativeId,
15067        name: &onetaskgraph_plugin_api::AssetName,
15068    ) -> Result<Option<Vec<u8>>, SourceError> {
15069        self.held_asset(id, BoardKind::Document, name).await
15070    }
15071    async fn set_document_rendering_with_assets(
15072        &self,
15073        id: &NativeId,
15074        content: &str,
15075        provenance: &Value,
15076        _answers: &BTreeMap<String, Value>,
15077        assets: &onetaskgraph_plugin_api::AssetWrite,
15078    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
15079        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
15080            .await
15081    }
15082    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
15083        Ok(self
15084            .item_by_id(id)
15085            .await?
15086            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
15087            .map(|item| item.project()))
15088    }
15089    /// Every narrowed search and origin lookup asked, a page at a time, as aliased fields of one
15090    /// [`graphql::SEARCH_BATCH`] request a round — a search's issue search in an `s<n>` field,
15091    /// an origin's board filter in an `o<n>` field beside its issue search — and every other
15092    /// query by [`Self::query_tasks`] on its own: one narrowed by a project or by comment
15093    /// activity, one carrying no narrowing, one resumed from a cursor that read handed out, and
15094    /// every query of a process that has written this board, whose own writes complete a
15095    /// narrowed answer. A lone search is its own read too, which costs a batch's point at a
15096    /// page of its own size.
15097    ///
15098    /// Each search's first page asks [`SEARCH_BATCH_FIRST_PAGE`] rows and each later page
15099    /// [`SEARCH_PAGE_SIZE`]. GitHub answers an error about one aliased field at that field's
15100    /// path, beside the others' answers, so a refused page fails that query's answer alone; a
15101    /// request it refuses whole is every query of that request's answer.
15102    async fn query_tasks_batch(
15103        &self,
15104        asks: &[(TaskQuery, PageRequest)],
15105    ) -> Vec<Result<Page<Task>, SourceError>> {
15106        self.search_batched(asks).await
15107    }
15108    async fn query_tasks(
15109        &self,
15110        query: &TaskQuery,
15111        page: &PageRequest,
15112    ) -> Result<Page<Task>, SourceError> {
15113        validate_page(page)?;
15114        refuse_unsearchable(query)?;
15115        // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
15116        // category — `unknown`; every other category reads only off an open issue or a draft. So
15117        // a status filter naming none of those three asks every issue search it sends for open
15118        // issues alone; each candidate's category is still confirmed from its own item below.
15119        let open_only = open_only(query);
15120        if query.origin.is_none()
15121            && !matches!(query.project, ProjectFilter::Is(_))
15122            && let Some(also) = search_qualifiers(query)
15123        {
15124            return self.search_tasks(query, page, &also).await;
15125        }
15126
15127        // A read narrowed to one project asks that project for its own tasks, so nothing
15128        // about it costs what the rest of the board holds. A read carrying a text, metadata
15129        // or origin predicate asks GitHub the narrower question those predicates are, and a
15130        // read narrowed to comment activity alone asks the board's own issue search for the
15131        // issues updated since, which is every issue a comment could have been written or
15132        // edited on since. Every other task read is a question about the whole board and is
15133        // answered by reading it.
15134        let (held, membership) = match (&query.project, query.commented_since) {
15135            (ProjectFilter::Is(project), _) => (
15136                match self.held_task_children(project)? {
15137                    // Taken as `project_children` takes the project's own sub-issue read: with
15138                    // every item this process wrote under it since, and none it moved away.
15139                    Some(held) => self.completed_with_written(
15140                        held.into_iter()
15141                            .filter(|child| child.parent.as_ref() == Some(project))
15142                            .collect(),
15143                        |own| own.parent.as_ref() == Some(project),
15144                    )?,
15145                    None => self.project_children(project).await?,
15146                },
15147                // Answered by where these items came from; see `task_matches`.
15148                &ProjectFilter::Any,
15149            ),
15150            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
15151                match (self.narrowed(query).await?, since) {
15152                    (Some(narrowed), _) => (narrowed, &query.project),
15153                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
15154                    // The board's own items list every draft; only the search half, which
15155                    // completes them with issues they lag behind on, is narrowed — a closed
15156                    // issue that half leaves out is at no category the filter keeps.
15157                    (None, None) if open_only => (
15158                        self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
15159                        &query.project,
15160                    ),
15161                    (None, None) => (self.board().await?.items, &query.project),
15162                }
15163            }
15164        };
15165        // Every candidate this read confirms by its comments is read together.
15166        let candidates = self.comment_candidates(held.iter(), query, membership)?;
15167        self.hold_comment_pages(&candidates).await?;
15168        // Filtered before paged: a page of a filtered result is a page of the survivors,
15169        // never the survivors of a page.
15170        let mut tasks = Vec::new();
15171        for item in held
15172            .iter()
15173            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15174        {
15175            let task = item.task()?;
15176            if task_matches(&task, query, membership)
15177                && self.commented_since(item, query.commented_since).await?
15178            {
15179                tasks.push(task);
15180            }
15181        }
15182        Ok(offset_page(
15183            tasks,
15184            numeric_cursor(page.cursor.as_ref())?,
15185            page.limit.min(MAX_PAGE_SIZE) as usize,
15186        ))
15187    }
15188    /// Read board pages directly, at the board connection's consistency. Unlike
15189    /// `query_tasks`, this does not complete a lagging board with issue search: a complete
15190    /// sweep needs one request per listing page, including the first comments.
15191    async fn query_task_details(
15192        &self,
15193        query: &TaskQuery,
15194        page: &PageRequest,
15195        comments: Option<&PageRequest>,
15196    ) -> Result<Page<TaskDetailRead>, SourceError> {
15197        let Some(comments) = comments else {
15198            let tasks = self.query_tasks(query, page).await?;
15199            return Ok(Page {
15200                items: tasks
15201                    .items
15202                    .into_iter()
15203                    .map(|task| TaskDetailRead {
15204                        task,
15205                        comments: None,
15206                    })
15207                    .collect(),
15208                next: tasks.next,
15209            });
15210        };
15211        validate_page(page)?;
15212        validate_page(comments)?;
15213        refuse_unsearchable(query)?;
15214        if comments.cursor.is_some() || query.commented_since.is_some() {
15215            return Err(SourceError::Refused {
15216                message: "a board listing with comments requires a first comment page and cannot filter by comment activity".into(),
15217            });
15218        }
15219        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
15220        let data = self
15221            .graphql(
15222                graphql::BOARD_WITH_COMMENTS,
15223                json!({
15224                    "owner":self.owner,"number":self.project_number,
15225                    "first":page.limit.min(MAX_PAGE_SIZE),"after":after,
15226                    "commentFirst":comments.limit.min(MAX_PAGE_SIZE),
15227                    "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true,
15228                }),
15229            )
15230            .await?;
15231        let connection = data
15232            .pointer("/owner/projectV2/items")
15233            .filter(|value| !value.is_null())
15234            .ok_or_else(|| SourceError::Refused {
15235                message: "GitHub project was not found or is not visible to the token".into(),
15236            })?;
15237        let mut items = Vec::new();
15238        for node in optional_nodes(Some(connection), "board items")?
15239            .into_iter()
15240            .flatten()
15241        {
15242            let Some(item) = self.resolve(node)? else {
15243                continue;
15244            };
15245            if item.kind != BoardKind::Work(ItemKind::Task) {
15246                continue;
15247            }
15248            let task = item.task()?;
15249            if !task_matches(&task, query, &query.project) {
15250                continue;
15251            }
15252            let read = if item.content_kind == ContentKind::DraftIssue {
15253                Err(self.draft_has_no_comments(&task.id))
15254            } else {
15255                self.expected.learn_comments(&task.id, &node["content"]);
15256                comment_page(&node["content"], &task.id.0, None).map(Some)
15257            };
15258            items.push(TaskDetailRead {
15259                task,
15260                comments: Some(read),
15261            });
15262        }
15263        let next = next_cursor(connection)?;
15264        if let Some(next) = &next {
15265            validate_cursor_progress(after, &next.0)?;
15266        }
15267        Ok(Page { items, next })
15268    }
15269
15270    async fn query_projects(
15271        &self,
15272        query: &ProjectQuery,
15273        page: &PageRequest,
15274    ) -> Result<Page<Project>, SourceError> {
15275        validate_page(page)?;
15276        refuse_unsearchable_text(query.text.as_ref())?;
15277        // The projects a board holds are found by an issue search scoped to that board,
15278        // never by walking the board's own item connection: what tells a project from a
15279        // task is the `parent` each issue carries, which costs nothing to read. A query
15280        // carrying a text asks that search for the text too, so it reads the issues that
15281        // hold it rather than every issue of the board.
15282        let held = match self.text_searched(query.text.as_ref()).await? {
15283            Some(searched) => searched,
15284            None => self.board_issues().await?,
15285        };
15286        let projects = held
15287            .iter()
15288            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
15289            .map(Resolved::project)
15290            .filter(|project| project_matches(project, query))
15291            .collect();
15292        Ok(offset_page(
15293            projects,
15294            numeric_cursor(page.cursor.as_ref())?,
15295            page.limit.min(MAX_PAGE_SIZE) as usize,
15296        ))
15297    }
15298    async fn query_projects_by_origin(
15299        &self,
15300        origin: &str,
15301        page: &PageRequest,
15302    ) -> Result<Page<Project>, SourceError> {
15303        validate_page(page)?;
15304        let projects = self
15305            .copied_from(origin, BoardKind::Work(ItemKind::Project))
15306            .await?
15307            .iter()
15308            .map(Resolved::project)
15309            .collect();
15310        Ok(offset_page(
15311            projects,
15312            numeric_cursor(page.cursor.as_ref())?,
15313            page.limit.min(MAX_PAGE_SIZE) as usize,
15314        ))
15315    }
15316    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
15317        let Some(item) = self
15318            .document_by_id(id)
15319            .await?
15320            .filter(|item| item.kind == BoardKind::Document)
15321        else {
15322            return Ok(None);
15323        };
15324        self.expected.read_document(&item);
15325        Ok(Some(item.document()))
15326    }
15327    async fn query_documents_by_origin(
15328        &self,
15329        origin: &str,
15330        page: &PageRequest,
15331    ) -> Result<Page<Document>, SourceError> {
15332        validate_page(page)?;
15333        let documents = self
15334            .copied_from(origin, BoardKind::Document)
15335            .await?
15336            .iter()
15337            .map(Resolved::document)
15338            .collect();
15339        Ok(offset_page(
15340            documents,
15341            numeric_cursor(page.cursor.as_ref())?,
15342            page.limit.min(MAX_PAGE_SIZE) as usize,
15343        ))
15344    }
15345    async fn query_documents(
15346        &self,
15347        query: &DocumentQuery,
15348        page: &PageRequest,
15349    ) -> Result<Page<Document>, SourceError> {
15350        validate_page(page)?;
15351        // Narrowed to one project, this is the same sub-issue read a task list scoped to
15352        // that project makes — a document filed under a project is a sub-issue of it too,
15353        // and which of them come back is the kind this caller asked for. Unscoped, a query
15354        // carrying a text asks the board-scoped issue search for it, as a task query does,
15355        // and only one carrying none reads the board.
15356        let (held, membership) = match &query.project {
15357            ProjectFilter::Is(project) => (
15358                self.project_children(project).await?,
15359                // Answered by where these items came from; see `task_matches`.
15360                &ProjectFilter::Any,
15361            ),
15362            ProjectFilter::Any | ProjectFilter::Orphans => {
15363                refuse_unsearchable_text(query.text.as_ref())?;
15364                match self.text_searched(query.text.as_ref()).await? {
15365                    Some(searched) => (searched, &query.project),
15366                    None => (self.board().await?.items, &query.project),
15367                }
15368            }
15369        };
15370        // Filtered before paged, exactly as a task read is: a page of a filtered result is
15371        // a page of the survivors, never the survivors of a page.
15372        let documents = held
15373            .iter()
15374            .filter(|item| item.kind == BoardKind::Document)
15375            .map(Resolved::document)
15376            .filter(|document| document_matches(document, query, membership))
15377            .collect();
15378        Ok(offset_page(
15379            documents,
15380            numeric_cursor(page.cursor.as_ref())?,
15381            page.limit.min(MAX_PAGE_SIZE) as usize,
15382        ))
15383    }
15384    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
15385        validate_page(page)?;
15386        let offset = numeric_cursor(page.cursor.as_ref())?;
15387        let mut labels = self
15388            .board()
15389            .await?
15390            .items
15391            .into_iter()
15392            .flat_map(|item| item.labels)
15393            .fold(Vec::new(), |mut all, label| {
15394                if !all.iter().any(|x: &Label| x.id == label.id) {
15395                    all.push(label);
15396                }
15397                all
15398            });
15399        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
15400        Ok(offset_page(
15401            labels,
15402            offset,
15403            page.limit.min(MAX_PAGE_SIZE) as usize,
15404        ))
15405    }
15406    async fn task_dependencies(
15407        &self,
15408        id: &NativeId,
15409        direction: Direction,
15410        page: &PageRequest,
15411    ) -> Result<Page<DependencyEdge>, SourceError> {
15412        self.dependencies(id, ItemKind::Task, direction, page).await
15413    }
15414    async fn project_dependencies(
15415        &self,
15416        id: &NativeId,
15417        direction: Direction,
15418        page: &PageRequest,
15419    ) -> Result<Page<DependencyEdge>, SourceError> {
15420        self.dependencies(id, ItemKind::Project, direction, page)
15421            .await
15422    }
15423
15424    fn writes(&self) -> WriteSupport {
15425        WriteSupport::Supported
15426    }
15427
15428    /// Create or update one task.
15429    ///
15430    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
15431    /// neither may name the task itself or name one task twice — and land in the body's
15432    /// metadata slot under their reserved keys, in place of any caller metadata of those
15433    /// names.
15434    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
15435        if write.item.project.is_some() {
15436            self.projectless()?;
15437        }
15438        self.write_task_assets(write, None)
15439            .await
15440            .map(|written| written.id)
15441    }
15442
15443    async fn read_ahead_origins_for_copy(&self, origins: &[String]) -> Result<(), SourceError> {
15444        self.origins_ahead(origins).await
15445    }
15446
15447    async fn member_classifications(
15448        &self,
15449        project: &NativeId,
15450    ) -> Result<Option<Vec<(Classification, Vec<Repository>)>>, SourceError> {
15451        Ok(self
15452            .member_classes
15453            .lock()
15454            .map_err(|_| SourceError::Unavailable {
15455                message: "a project's members held for its classification were left \
15456                          inconsistent; run the command again"
15457                    .into(),
15458            })?
15459            .remove(project))
15460    }
15461
15462    /// The issue's own web address, as the `createIssue` that made it in this command answered
15463    /// it: a task, a project and a document are each an issue here.
15464    fn written_location(
15465        &self,
15466        _record: onetaskgraph_plugin_api::MetadataRecord,
15467        id: &NativeId,
15468    ) -> Option<Location> {
15469        self.written
15470            .lock()
15471            .unwrap_or_else(std::sync::PoisonError::into_inner)
15472            .get(id)
15473            .cloned()
15474            .map(Location::Url)
15475    }
15476
15477    /// [`MUTATION_BATCH`], while writes are held back: [`graphql::CREATE_ISSUES`] creates that many
15478    /// in one request, and a copy's tasks are created together only then, since their fields
15479    /// and edges are what is held back.
15480    fn create_batch_limit(&self) -> usize {
15481        if self.deferring().unwrap_or(false) {
15482            MUTATION_BATCH
15483        } else {
15484            1
15485        }
15486    }
15487
15488    // 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.
15489    async fn write_tasks(&self, writes: &[ItemWrite<Task>]) -> Result<Vec<NativeId>, SourceError> {
15490        if writes.iter().any(|write| write.item.project.is_some()) {
15491            self.projectless()?;
15492        }
15493        if let Some(write) = writes.iter().find(|write| write.target.is_some()) {
15494            return Err(SourceError::Refused {
15495                message: format!(
15496                    "write_tasks creates tasks, and the write of {} names the existing task {}; \
15497                     next: update it with write_task",
15498                    write.item.id,
15499                    write.target.as_ref().map_or("", |target| target.0.as_str())
15500                ),
15501            });
15502        }
15503        if !self.deferring()? {
15504            let mut created = Vec::with_capacity(writes.len());
15505            for write in writes {
15506                match self.write_task(write).await {
15507                    Ok(id) => created.push(id),
15508                    Err(error) => {
15509                        let mut left: Vec<&str> = Vec::new();
15510                        for id in &created {
15511                            if self.delete_issue(id).await.is_err() {
15512                                left.push(id.0.as_str());
15513                            }
15514                        }
15515                        return Err(left_behind(error, &left));
15516                    }
15517                }
15518            }
15519            return Ok(created);
15520        }
15521        self.create_together(writes).await
15522    }
15523
15524    fn begin_deferred_writes(&self) {
15525        if let Ok(mut deferred) = self.deferred.lock() {
15526            deferred.get_or_insert_with(Deferred::default);
15527        }
15528    }
15529
15530    async fn finish_writes(&self) -> Result<(), SourceError> {
15531        self.send_held_updates().await?;
15532        self.send_held_back().await
15533    }
15534
15535    async fn write_task_with_assets(
15536        &self,
15537        write: &ItemWrite<Task>,
15538        _answers: Option<&BTreeMap<String, Value>>,
15539        assets: &onetaskgraph_plugin_api::AssetWrite,
15540    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15541        self.write_task_assets(write, Some(assets)).await
15542    }
15543
15544    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
15545        self.projectless()?;
15546        self.write_item(
15547            &Incoming {
15548                written: Written::Work(ItemKind::Project, &write.item.status),
15549                title: &write.item.title,
15550                content: write.item.content.as_deref(),
15551                assets: None,
15552                labels: &write.item.labels,
15553                metadata: &write.item.metadata,
15554                repositories: &write.item.repositories,
15555                classification: write.item.classification,
15556                parent: None,
15557                delivers: &[],
15558                delivered_by: &[],
15559                priority: None,
15560                assignees: None,
15561            },
15562            write.target.as_ref(),
15563            &write.depends_on,
15564        )
15565        .await
15566        .map(|written| written.id)
15567    }
15568
15569    fn task_body_size(
15570        &self,
15571        write: &ItemWrite<Task>,
15572        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15573    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15574        Some(self.body_size(&self.task_incoming(write, assets).ok()?))
15575    }
15576
15577    fn project_body_size(
15578        &self,
15579        write: &ItemWrite<Project>,
15580    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15581        Some(self.body_size(&Incoming {
15582            written: Written::Work(ItemKind::Project, &write.item.status),
15583            title: &write.item.title,
15584            content: write.item.content.as_deref(),
15585            assets: None,
15586            labels: &write.item.labels,
15587            metadata: &write.item.metadata,
15588            repositories: &write.item.repositories,
15589            classification: write.item.classification,
15590            parent: None,
15591            delivers: &[],
15592            delivered_by: &[],
15593            priority: None,
15594            assignees: None,
15595        }))
15596    }
15597
15598    fn document_body_size(
15599        &self,
15600        write: &ItemWrite<Document>,
15601        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15602    ) -> Option<onetaskgraph_plugin_api::BodySize> {
15603        Some(self.body_size(&Incoming {
15604            written: Written::Document,
15605            title: &write.item.title,
15606            content: write.item.content.as_deref(),
15607            assets,
15608            labels: &write.item.labels,
15609            metadata: &write.item.metadata,
15610            repositories: &write.item.repositories,
15611            classification: write.item.classification,
15612            parent: write.item.project.as_ref(),
15613            delivers: &[],
15614            delivered_by: &[],
15615            priority: None,
15616            assignees: None,
15617        }))
15618    }
15619
15620    /// Create or update one document, which is one issue titled the way this board spells
15621    /// a document.
15622    ///
15623    /// Everything else is exactly a task write: caller metadata goes to the same canonical
15624    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
15625    /// or a field this board cannot carry is refused by name rather than dropped, a target
15626    /// naming an issue this board does not hold is refused rather than created, and an
15627    /// issue this call created is taken back when the rest of the write fails.
15628    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
15629        self.write_document_assets(write, None)
15630            .await
15631            .map(|written| written.id)
15632    }
15633
15634    async fn write_document_with_assets(
15635        &self,
15636        write: &ItemWrite<Document>,
15637        _answers: Option<&BTreeMap<String, Value>>,
15638        assets: &onetaskgraph_plugin_api::AssetWrite,
15639    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15640        self.write_document_assets(write, Some(assets)).await
15641    }
15642
15643    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
15644    /// which reads nothing; then the board's `Status` option. Over an existing item that is
15645    /// read off the item, as the write reads it, and the item is held among this command's
15646    /// resolved records so the write that follows reuses that read rather than repeating it;
15647    /// an item that does not carry the field takes the board's fields, which are held once
15648    /// read. A create is checked against the board's fields only when this command already
15649    /// holds them, because a create reads them together with its repository, in one request,
15650    /// and refuses a missing option before it writes anything.
15651    async fn check_status_write(
15652        &self,
15653        kind: ItemKind,
15654        category: StatusCategory,
15655        target: Option<&NativeId>,
15656    ) -> Result<(), SourceError> {
15657        let status = self.resolved_target(kind, category)?;
15658        if status.option().is_none() {
15659            return Ok(());
15660        }
15661        let fields = match target {
15662            Some(target) => {
15663                // A target this board does not hold is the write's own refusal to make.
15664                let Some(item) = self.bound_item(target).await? else {
15665                    return Ok(());
15666                };
15667                self.resolved_cache()?.insert(target.clone(), item.clone());
15668                // Only the `Status` field decides whether the write can land.
15669                self.fields_for(Some(&item), false, true, false)
15670                    .await?
15671                    .fields
15672            }
15673            None => {
15674                let held = self
15675                    .board_cache()?
15676                    .as_ref()
15677                    .map(|board| board.fields.clone());
15678                match held.or_else(|| {
15679                    self.fields_cache()
15680                        .ok()
15681                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
15682                }) {
15683                    Some(fields) => fields,
15684                    None => return Ok(()),
15685                }
15686            }
15687        };
15688        self.column_for(&fields, kind, category, &status)
15689            .map(|_| ())
15690    }
15691
15692    /// What a write of `status` reads back as: the `Status` option its category maps to for
15693    /// `kind`, and for a closing category the closure that option and its fixed reason read
15694    /// as — exactly what [`BoardStatuses::status`] reports of the item once written. A category
15695    /// this instance has no option for is answered as given, since writing it is refused.
15696    fn status_as_written(&self, kind: ItemKind, status: &Status) -> Status {
15697        match self.statuses.target(kind, status.category) {
15698            StatusTarget::Column(name) => {
15699                self.statuses.status(kind, Some(name.as_str()), false, None)
15700            }
15701            StatusTarget::Terminal(name, reason) => {
15702                self.statuses
15703                    .status(kind, Some(name.as_str()), true, Some(reason.reason()))
15704            }
15705            StatusTarget::Disabled(_) => status.clone(),
15706        }
15707    }
15708
15709    /// Set one task's status alone.
15710    ///
15711    /// An open target reopens a closed issue with an `updateIssue` carrying only its
15712    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
15713    /// terminal target selects its mapped option, then closes with its fixed reason. No
15714    /// request carries a title, a body or a label. The status
15715    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
15716    /// what a re-read reports.
15717    async fn set_task_status(
15718        &self,
15719        id: &NativeId,
15720        category: StatusCategory,
15721    ) -> Result<Option<Status>, SourceError> {
15722        self.set_status(id, ItemKind::Task, category).await
15723    }
15724
15725    /// Set one task's status and answer the task with it, reading the item once: the read the
15726    /// task is answered from is handed to the status write, which would otherwise read the
15727    /// same issue again — two requests on a fresh process, the read and the field write,
15728    /// where the default's `get_task` before `set_task_status` sent three.
15729    async fn set_task_status_reading(
15730        &self,
15731        id: &NativeId,
15732        category: StatusCategory,
15733    ) -> Result<Option<Task>, SourceError> {
15734        // Refused before anything is read, in the words the write itself refuses it.
15735        self.resolved_target(ItemKind::Task, category)?;
15736        let Some(item) = self
15737            .bound_item(id)
15738            .await?
15739            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15740        else {
15741            return Ok(None);
15742        };
15743        let mut task = item.task()?;
15744        self.held_cache
15745            .lock()
15746            .map_err(|_| SourceError::Unavailable {
15747                message: "items read by issue reference were left inconsistent; run the \
15748                          command again"
15749                    .into(),
15750            })?
15751            .insert(id.clone(), item);
15752        let Some(status) = self.set_status(id, ItemKind::Task, category).await? else {
15753            return Ok(None);
15754        };
15755        task.status = status;
15756        Ok(Some(task))
15757    }
15758
15759    /// Set one project's status alone, by exactly the paired write a task's status takes, for
15760    /// the project kind: a terminal category selects the project kind's mapped option and then
15761    /// closes the issue with the category's fixed reason — an issue already closed with a
15762    /// reason that reads that category is left as it is — and an open category reopens a closed
15763    /// issue and then selects its option. A board draft is refused a terminal category as a
15764    /// task's is.
15765    async fn set_project_status(
15766        &self,
15767        id: &NativeId,
15768        category: StatusCategory,
15769    ) -> Result<Option<Status>, SourceError> {
15770        self.projectless()?;
15771        self.set_status(id, ItemKind::Project, category).await
15772    }
15773
15774    /// Every task and project whose card contradicts its issue's closure, read through the
15775    /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
15776    async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
15777        self.contradictions().await
15778    }
15779
15780    /// What this board holds for each issue reference, one request per fifty of them; see
15781    /// [`TaskSource::issue_holdings`].
15782    async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
15783        self.holdings(issues).await
15784    }
15785
15786    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
15787    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
15788    /// for `none`. Refused by an instance with no `priority_mapping`.
15789    async fn set_task_priority(
15790        &self,
15791        id: &NativeId,
15792        priority: Priority,
15793    ) -> Result<Option<Priority>, SourceError> {
15794        self.set_priority(id, priority).await
15795    }
15796
15797    /// Replace one task's content with a single body update that keeps the metadata slot
15798    /// byte for byte.
15799    async fn set_task_content(
15800        &self,
15801        id: &NativeId,
15802        content: &str,
15803    ) -> Result<Option<()>, SourceError> {
15804        self.replace_content(id, content).await
15805    }
15806
15807    /// Replace one task issue's content and its provenance slot entry with a single body
15808    /// update. The answers are not kept: see `replace_rendering`.
15809    async fn set_task_rendering(
15810        &self,
15811        id: &NativeId,
15812        content: &str,
15813        provenance: &Value,
15814        _answers: &BTreeMap<String, Value>,
15815    ) -> Result<Option<()>, SourceError> {
15816        self.replace_rendering(
15817            id,
15818            BoardKind::Work(ItemKind::Task),
15819            content,
15820            provenance,
15821            None,
15822        )
15823        .await
15824        .map(|written| written.map(|_| ()))
15825    }
15826
15827    /// Replace one design-document issue's content and its provenance slot entry, on exactly
15828    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15829    async fn set_document_rendering(
15830        &self,
15831        id: &NativeId,
15832        content: &str,
15833        provenance: &Value,
15834        _answers: &BTreeMap<String, Value>,
15835    ) -> Result<Option<()>, SourceError> {
15836        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
15837            .await
15838            .map(|written| written.map(|_| ()))
15839    }
15840
15841    /// Replace one project issue's content and its provenance slot entry, on exactly the
15842    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15843    async fn set_project_rendering(
15844        &self,
15845        id: &NativeId,
15846        content: &str,
15847        provenance: &Value,
15848        _answers: &BTreeMap<String, Value>,
15849    ) -> Result<Option<()>, SourceError> {
15850        self.projectless()?;
15851        self.replace_rendering(
15852            id,
15853            BoardKind::Work(ItemKind::Project),
15854            content,
15855            provenance,
15856            None,
15857        )
15858        .await
15859        .map(|written| written.map(|_| ()))
15860    }
15861
15862    /// Apply a targeted update with one read of the item and a write only for what differs:
15863    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
15864    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
15865    async fn update_task(
15866        &self,
15867        id: &NativeId,
15868        update: &TaskUpdate,
15869    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
15870        self.targeted_update(id, update).await
15871    }
15872
15873    /// Each issue's sub-issue parent and children and its links, read in batches.
15874    async fn read_relations(
15875        &self,
15876        ids: &[NativeId],
15877    ) -> Vec<Result<Option<onetaskgraph_plugin_api::Relations>, SourceError>> {
15878        self.relations_of(ids).await
15879    }
15880
15881    async fn set_parents(
15882        &self,
15883        writes: &[onetaskgraph_plugin_api::ParentWrite],
15884    ) -> Vec<Result<onetaskgraph_plugin_api::ParentAnswer, SourceError>> {
15885        self.parents_set(writes).await
15886    }
15887
15888    fn link_types(&self) -> onetaskgraph_plugin_api::LinkTypes {
15889        self.link_types.clone()
15890    }
15891
15892    async fn add_native_links(
15893        &self,
15894        links: &[onetaskgraph_plugin_api::NativeLink],
15895    ) -> Vec<Result<(), SourceError>> {
15896        self.native_links(links, relations::NativeWrite::Add).await
15897    }
15898
15899    async fn remove_native_links(
15900        &self,
15901        links: &[onetaskgraph_plugin_api::NativeLink],
15902    ) -> Vec<Result<(), SourceError>> {
15903        self.native_links(links, relations::NativeWrite::Remove)
15904            .await
15905    }
15906
15907    /// Every task read together — [`DETAIL_BATCH`] to a [`graphql::ISSUE_DETAILS`] request —
15908    /// and each update's hold and guard decided on that read; then each update the read lets
15909    /// through composed exactly as [`Self::update_task`] composes one, its board field writes
15910    /// and its issue update held back and sent in [`graphql::WRITE_TASKS`] requests: every
15911    /// field write first, then the issue update of every task whose field writes all landed.
15912    ///
15913    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15914    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15915    /// that batch is read by itself, the missing one answered missing and the rest as
15916    /// themselves. A batch of one, an update of a board draft, one naming dependencies and one
15917    /// of an item whose body this process has not read are each made on their own, as
15918    /// [`Self::update_task`] makes them, because each sends writes of its own in an order the
15919    /// batch does not hold. A field write GitHub refuses fails that task's update alone and
15920    /// sends nothing of its body.
15921    async fn update_tasks(
15922        &self,
15923        updates: &[onetaskgraph_plugin_api::BatchUpdate],
15924    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<TaskUpdateOutcome>, SourceError>> {
15925        self.updates_batched(updates).await
15926    }
15927
15928    /// Replace one task's `delivered_by` with a single body update that changes the
15929    /// metadata slot and nothing outside it.
15930    async fn set_delivered_by(
15931        &self,
15932        id: &NativeId,
15933        delivered_by: &[TaskRef],
15934    ) -> Result<Option<()>, SourceError> {
15935        self.replace_delivered_by(id, delivered_by).await
15936    }
15937
15938    /// Set one key of one task issue's metadata with a single body update that changes the
15939    /// metadata slot and nothing outside it — no title, label, state or board field request —
15940    /// and sends nothing when the task already holds that value under the key.
15941    async fn set_task_metadata(
15942        &self,
15943        id: &NativeId,
15944        key: &MetadataKey,
15945        value: &Value,
15946    ) -> Result<Option<Task>, SourceError> {
15947        Ok(self
15948            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
15949            .await?
15950            .map(|item| item.task())
15951            .transpose()?)
15952    }
15953
15954    /// Set one key of one project issue's metadata, on exactly the terms of
15955    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15956    async fn set_project_metadata(
15957        &self,
15958        id: &NativeId,
15959        key: &MetadataKey,
15960        value: &Value,
15961    ) -> Result<Option<Project>, SourceError> {
15962        self.projectless()?;
15963        Ok(self
15964            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
15965            .await?
15966            .map(|item| item.project()))
15967    }
15968
15969    /// Set one key of one design-document issue's metadata, on exactly the terms of
15970    /// [`set_task_metadata`](TaskSource::set_task_metadata).
15971    async fn set_document_metadata(
15972        &self,
15973        id: &NativeId,
15974        key: &MetadataKey,
15975        value: &Value,
15976    ) -> Result<Option<Document>, SourceError> {
15977        Ok(self
15978            .set_slot_key(id, BoardKind::Document, key, value)
15979            .await?
15980            .map(|item| item.document()))
15981    }
15982
15983    /// Take one key out of one issue's metadata slot with a single body update that changes the
15984    /// slot and nothing outside it, sending nothing when the slot does not hold the key.
15985    async fn remove_record_metadata(
15986        &self,
15987        record: onetaskgraph_plugin_api::MetadataRecord,
15988        id: &NativeId,
15989        key: &MetadataKey,
15990    ) -> Result<Option<Option<String>>, SourceError> {
15991        let kind = match record {
15992            onetaskgraph_plugin_api::MetadataRecord::Task => BoardKind::Work(ItemKind::Task),
15993            onetaskgraph_plugin_api::MetadataRecord::Project => BoardKind::Work(ItemKind::Project),
15994            onetaskgraph_plugin_api::MetadataRecord::Document => BoardKind::Document,
15995        };
15996        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
15997            return Ok(None);
15998        };
15999        if item.slot.contains_key(key.as_str()) {
16000            let mut slot = item.slot.clone();
16001            slot.remove(key.as_str());
16002            self.write_slot(&mut item, &slot).await?;
16003            self.remember_written(item.clone(), false)?;
16004        }
16005        Ok(Some(match record {
16006            onetaskgraph_plugin_api::MetadataRecord::Task => item.task()?.version,
16007            onetaskgraph_plugin_api::MetadataRecord::Project => item.project().version,
16008            onetaskgraph_plugin_api::MetadataRecord::Document => item.document().version,
16009        }))
16010    }
16011
16012    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
16013        self.delete_item(id).await
16014    }
16015
16016    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
16017        self.projectless()?;
16018        self.delete_item(id).await
16019    }
16020
16021    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
16022        self.delete_item(id).await
16023    }
16024
16025    /// One page of the task issue's own comments, walked by GitHub's own cursor.
16026    ///
16027    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
16028    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
16029    ///
16030    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
16031    /// board is the read of its comments. A draft this process already resolved is refused
16032    /// without one.
16033    async fn task_comments(
16034        &self,
16035        task: &NativeId,
16036        page: &PageRequest,
16037    ) -> Result<Option<Page<Comment>>, SourceError> {
16038        validate_page(page)?;
16039        let cached = self.resolved_cache()?.get(task).cloned();
16040        if let Some(item) = cached {
16041            if item.kind != BoardKind::Work(ItemKind::Task) {
16042                return Ok(None);
16043            }
16044            if item.content_kind == ContentKind::DraftIssue {
16045                return Err(self.draft_has_no_comments(task));
16046            }
16047        }
16048        // The first page a comment-activity read just read of this task, at the size asked, is
16049        // that read's answer: taken, so it answers once.
16050        if page.cursor.is_none()
16051            && page.limit >= MAX_PAGE_SIZE
16052            && let Some(held) = self.held_comments()?.remove(task)
16053        {
16054            return Ok(Some(held));
16055        }
16056        match self.issue_detail(task, page).await? {
16057            Some(TaskDetailRead {
16058                comments: Some(comments),
16059                ..
16060            }) => comments,
16061            _ => Ok(None),
16062        }
16063    }
16064
16065    /// Every id's task, with the first page of its comments when `comments` names it:
16066    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
16067    /// comments in one [`graphql::ISSUE_DETAIL`] request.
16068    async fn get_task_details(
16069        &self,
16070        ids: &[NativeId],
16071        comments: Option<&PageRequest>,
16072    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
16073        if let Some(page) = comments
16074            && let Err(error) = validate_page(page)
16075        {
16076            return ids.iter().map(|_| Err(error.clone())).collect();
16077        }
16078        match (ids, comments) {
16079            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
16080            ([id], None) => vec![self.task_read(id).await],
16081            _ => self.issue_details(ids, comments).await,
16082        }
16083    }
16084
16085    /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
16086    /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
16087    /// request, so the dependency read a copy makes of each next is answered from this one.
16088    async fn get_task_details_for_copy(
16089        &self,
16090        ids: &[NativeId],
16091        comments: &PageRequest,
16092    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
16093        // A page past the first is a page of one task's comments, which the detail read of it
16094        // alone answers.
16095        if comments.cursor.is_some() {
16096            return self.get_task_details(ids, Some(comments)).await;
16097        }
16098        if let Err(error) = validate_page(comments) {
16099            return ids.iter().map(|_| Err(error.clone())).collect();
16100        }
16101        let mut read = Vec::with_capacity(ids.len());
16102        for batch in ids.chunks(COPY_DETAIL_BATCH) {
16103            match self
16104                .graphql(
16105                    graphql::ISSUE_COPY_DETAILS,
16106                    copy_detail_batch(batch, comments, false),
16107                )
16108                .await
16109            {
16110                Ok(data) => {
16111                    for (slot, id) in batch.iter().enumerate() {
16112                        let read_one = match data.get(format!("i{slot}")) {
16113                            Some(node) => self.detail_of(id, node, true, None).await,
16114                            None => Err(SourceError::Malformed {
16115                                message: format!(
16116                                    "GitHub answered a batch read with no item for {}",
16117                                    id.0
16118                                ),
16119                            }),
16120                        };
16121                        read.push(read_one);
16122                    }
16123                }
16124                Err(error) if unresolvable_node(&error) => {
16125                    for id in batch {
16126                        read.push(self.issue_detail(id, comments).await);
16127                    }
16128                }
16129                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
16130            }
16131        }
16132        read
16133    }
16134
16135    /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
16136    /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
16137    /// and held for the next read of each by id; see `read_ahead`.
16138    ///
16139    /// What this run wrote is never read: its own record answers, as it does for a read by id.
16140    /// Nor is an item a listing of its project carried whole earlier in this command — see
16141    /// `carried` — which is held in place of reading it.
16142    /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
16143    /// so each of its ids is read on its own and answered as that read answers; a draft, and an
16144    /// answer naming another issue than the one asked for, are left to their own reads too.
16145    async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
16146        // The origins asked just before go out now, beside the members of the one project this
16147        // names when it names one; a project those origins answered whole is held for its next
16148        // read by id rather than read again here.
16149        let hinted = match ids {
16150            [one] => Some(one),
16151            _ => None,
16152        };
16153        self.send_pending_origins(hinted).await?;
16154        if let Some(project) = hinted {
16155            let answered = self.resolved_cache()?.get(project).cloned();
16156            if let Some(answered) = answered
16157                && answered.kind == BoardKind::Work(ItemKind::Project)
16158            {
16159                self.read_ahead()?.insert(project.clone(), Some(answered));
16160                return Ok(());
16161            }
16162        }
16163        let mut unread: Vec<NativeId> = Vec::new();
16164        // Read whole by a listing of its project in this command, which a mutation naming it
16165        // would have dropped: held as a batch would have held it, once every batch is answered.
16166        let mut listed: Vec<NativeId> = Vec::new();
16167        {
16168            let created = self.created()?;
16169            let held = self.read_ahead()?;
16170            let carried = self.carried()?;
16171            for id in ids {
16172                if unread.contains(id)
16173                    || listed.contains(id)
16174                    || held.contains_key(id)
16175                    || created.iter().any(|own| own.id == *id)
16176                {
16177                    continue;
16178                }
16179                // An issue a listing carried without the whole of what blocks it is read in a
16180                // batch too, which carries that, rather than having its dependency read sent
16181                // on its own once the copy reconciles its edges.
16182                let whole = carried.get(id).is_some_and(|item| {
16183                    item.content_kind != ContentKind::Issue || item.blocked_by.is_some()
16184                });
16185                if whole {
16186                    listed.push(id.clone());
16187                } else {
16188                    unread.push(id.clone());
16189                }
16190            }
16191        }
16192        // Nothing of an item's comments is wanted here: the smallest page keeps the read to
16193        // what the item itself costs.
16194        let comments = PageRequest {
16195            cursor: None,
16196            limit: 1,
16197        };
16198        // Held only once every batch has been answered, so a read-ahead that fails part way
16199        // holds nothing — what a listing carried included, which stays carried — and every
16200        // item is read on its own, as the method promises.
16201        let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
16202        // The issues the first id names as sub-issues, as `ISSUE_COPY_DETAILS`'s `children`
16203        // answered them: what a project copy's orphan check asks, when that first id is its
16204        // project.
16205        let mut children: Option<(NativeId, Vec<(String, String)>)> = None;
16206        for (index, batch) in unread.chunks(COPY_DETAIL_BATCH).enumerate() {
16207            let data = match self
16208                .graphql(
16209                    graphql::ISSUE_COPY_DETAILS,
16210                    copy_detail_batch(batch, &comments, index == 0),
16211                )
16212                .await
16213            {
16214                Ok(data) => data,
16215                Err(error) if unresolvable_node(&error) => continue,
16216                Err(error) => return Err(error),
16217            };
16218            for (slot, id) in batch.iter().enumerate() {
16219                let Some(node) = data.get(format!("i{slot}")) else {
16220                    continue;
16221                };
16222                let held = if node.is_null() {
16223                    None
16224                } else {
16225                    match optional_str(node, "__typename")? {
16226                        Some("DraftIssue") => continue,
16227                        Some("Issue") if required_str(node, "id")? != id.0 => continue,
16228                        _ => {}
16229                    }
16230                    // Remembered below with the rest, so a later batch refused leaves no
16231                    // dependency read answered from this one either.
16232                    self.resolve_issue_as(node, false).await?
16233                };
16234                read.push((id.clone(), held));
16235            }
16236            if index == 0
16237                && let Some(node) = data.get("children").filter(|node| !node.is_null())
16238                && node.get("id").and_then(Value::as_str) == batch.first().map(|id| id.0.as_str())
16239                && node.pointer("/subIssues/pageInfo/hasNextPage") == Some(&Value::Bool(false))
16240            {
16241                // A list GitHub did not answer is not an empty one: it would answer the project's
16242                // task listing with nothing, and the orphan check with no orphans.
16243                let listed = node
16244                    .pointer("/subIssues/nodes")
16245                    .and_then(Value::as_array)
16246                    .ok_or_else(|| SourceError::Malformed {
16247                        message: format!(
16248                            "GitHub answered the sub-issues of project {} with no list of them",
16249                            batch[0].0
16250                        ),
16251                    })?
16252                    .iter()
16253                    .map(|child| {
16254                        Ok((
16255                            required_str(child, "id")?.to_owned(),
16256                            required_str(child, "title")?.to_owned(),
16257                        ))
16258                    })
16259                    .collect::<Result<Vec<_>, SourceError>>()?;
16260                children = Some((batch[0].clone(), listed));
16261            }
16262        }
16263        // The project's tasks are known from this read alone when every child it named is held
16264        // from it, but for a document, which its title says is one and which a task listing
16265        // never answers: then a listing of the project's tasks is answered from them.
16266        if let Some((project, listed)) = children
16267            && read.iter().any(|(id, held)| {
16268                *id == project
16269                    && held
16270                        .as_ref()
16271                        .is_some_and(|held| held.kind == BoardKind::Work(ItemKind::Project))
16272            })
16273        {
16274            let mut tasks = Vec::new();
16275            let mut whole = true;
16276            for (child, title) in &listed {
16277                match read.iter().find(|(id, _)| id.0 == *child) {
16278                    Some((_, Some(held))) if held.parent.as_ref() == Some(&project) => {
16279                        if held.kind == BoardKind::Work(ItemKind::Task) {
16280                            tasks.push(held.clone());
16281                        }
16282                    }
16283                    _ if title.starts_with(DESIGN_TITLE_PREFIX) => {}
16284                    _ => whole = false,
16285                }
16286            }
16287            if whole {
16288                self.task_children()?.insert(project, tasks);
16289            }
16290        }
16291        let mut resolved = self.resolved_cache()?;
16292        let mut ahead = self.read_ahead()?;
16293        let mut carried = self.carried()?;
16294        for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
16295            resolved.insert(item.id.clone(), item.clone());
16296        }
16297        // Taken only if still carried: a mutation naming it while the batches ran dropped it,
16298        // and its next read is then its own.
16299        for id in &listed {
16300            let Some(item) = carried.remove(id) else {
16301                continue;
16302            };
16303            // The whole of what it is blocked by came with it, so the dependency read a copy
16304            // makes next is answered from this one, as a copy's detail read is.
16305            if item.blocked_by.is_some() {
16306                resolved.insert(item.id.clone(), item.clone());
16307            }
16308            read.push((id.clone(), Some(item)));
16309        }
16310        for (id, held) in read {
16311            ahead.insert(id, held);
16312        }
16313        Ok(())
16314    }
16315
16316    /// Add one comment to the task's issue, as the account the token belongs to.
16317    ///
16318    /// The author is refused before anything is sent — not even the task is read — because
16319    /// no answer GitHub could give would make posting under another name than the one asked
16320    /// for the right outcome.
16321    async fn add_comment(
16322        &self,
16323        task: &NativeId,
16324        comment: &NewComment,
16325    ) -> Result<Option<Comment>, SourceError> {
16326        if let Some(author) = &comment.author {
16327            return Err(SourceError::Refused {
16328                message: format!(
16329                    "source {} cannot post a comment as {author:?}: GitHub records the account \
16330                     the token signs in as the author of every comment; next: leave --author \
16331                     out, and the comment is posted as that account",
16332                    self.name
16333                ),
16334            });
16335        }
16336        let Some(issue) = self.commented_issue(task).await? else {
16337            return Ok(None);
16338        };
16339        let data = self
16340            .graphql(
16341                graphql::ADD_COMMENT,
16342                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
16343            )
16344            .await?;
16345        let subject = data
16346            .pointer("/addComment/subject")
16347            .filter(|value| !value.is_null())
16348            .ok_or_else(|| SourceError::Malformed {
16349                message: "GitHub comment addition returned no subject".into(),
16350            })?;
16351        if required_str(subject, "id")? != issue.0 {
16352            return Err(SourceError::Malformed {
16353                message: "GitHub comment addition answered about another issue".into(),
16354            });
16355        }
16356        let added = data
16357            .pointer("/addComment/commentEdge/node")
16358            .filter(|value| !value.is_null())
16359            .ok_or_else(|| SourceError::Malformed {
16360                message: "GitHub comment addition returned no comment".into(),
16361            })?;
16362        let added = comment_from(added)?;
16363        self.remember_commented(&issue)?;
16364        Ok(Some(added))
16365    }
16366
16367    /// Every task commented on read together — [`DETAIL_BATCH`] to a
16368    /// [`graphql::ISSUE_DETAILS`] request — and each comment's hold decided on that read; then
16369    /// every comment it lets through added by aliased `addComment`s of one
16370    /// [`graphql::ADD_COMMENTS`] request per [`COMMENT_BATCH`]. GitHub answers a refusal of one
16371    /// alias at that alias's path, so it fails that comment alone.
16372    ///
16373    /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
16374    /// batched node read naming an id that resolves to nothing is refused whole, so each id of
16375    /// that batch is read by itself, the missing one answered missing. A batch of one is the
16376    /// comment alone, as [`Self::add_comment`] adds it.
16377    async fn add_comments(
16378        &self,
16379        comments: &[onetaskgraph_plugin_api::BatchComment],
16380    ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<Comment>, SourceError>> {
16381        self.comments_batched(comments).await
16382    }
16383
16384    async fn edit_comment(
16385        &self,
16386        task: &NativeId,
16387        comment: &NativeId,
16388        body: &CommentBody,
16389    ) -> Result<Option<Comment>, SourceError> {
16390        let Some(issue) = self.commented_issue(task).await? else {
16391            return Ok(None);
16392        };
16393        let Some(held) = self.comment_on(&issue, comment).await? else {
16394            return Ok(None);
16395        };
16396        // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
16397        let body = CommentOrigin::kept(&held, body)?;
16398        let data = self
16399            .graphql(
16400                graphql::UPDATE_COMMENT,
16401                json!({"input":{"id":comment.0,"body":body.as_str()}}),
16402            )
16403            .await?;
16404        let edited = data
16405            .pointer("/updateIssueComment/issueComment")
16406            .filter(|value| !value.is_null())
16407            .ok_or_else(|| SourceError::Malformed {
16408                message: "GitHub comment update returned no comment".into(),
16409            })?;
16410        let edited = comment_from(edited)?;
16411        if edited.id != *comment {
16412            return Err(SourceError::Malformed {
16413                message: "GitHub comment update returned the wrong comment".into(),
16414            });
16415        }
16416        self.remember_commented(&issue)?;
16417        Ok(Some(edited))
16418    }
16419
16420    async fn delete_comment(
16421        &self,
16422        task: &NativeId,
16423        comment: &NativeId,
16424    ) -> Result<Option<NativeId>, SourceError> {
16425        let Some(issue) = self.commented_issue(task).await? else {
16426            return Ok(None);
16427        };
16428        if !self.comment_is_on(&issue, comment).await? {
16429            return Ok(None);
16430        }
16431        self.held_comments()?.remove(&issue);
16432        let data = self
16433            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
16434            .await?;
16435        // The payload says nothing about the comment it removed, so what is checked is that
16436        // GitHub answered the mutation at all rather than leaving it unanswered.
16437        data.get("deleteIssueComment")
16438            .filter(|value| !value.is_null())
16439            .ok_or_else(|| SourceError::Malformed {
16440                message: "GitHub comment deletion returned no payload".into(),
16441            })?;
16442        Ok(Some(comment.clone()))
16443    }
16444
16445    /// Every request this source has recorded, and what each of GitHub's two budgets was
16446    /// attributed — read off the same accounting the session report is rendered from, so
16447    /// the two cannot count one request two ways.
16448    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
16449        Ok(Some(self.ledger.snapshot().metering()))
16450    }
16451
16452    /// Drop every item, search answer and board read this source holds, so the next command
16453    /// reads the board as a person has since left it.
16454    ///
16455    /// Every one of those is held on the assumption that nothing but this source writes the
16456    /// board while a command runs, which stops being true the moment the command is over: a
16457    /// body a person edited would be overwritten from the record held here, and a card they
16458    /// moved would be read as still where this source left it. The board's own field
16459    /// definitions go too, because a person can add or delete a `Status` option and a write
16460    /// resolved against the held list would not re-read on a miss. What stays is what stays
16461    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
16462    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
16463    /// accounting [`metering`](TaskSource::metering) answers from.
16464    ///
16465    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
16466    /// refused, because clearing it is what puts it right.
16467    async fn end_command(&self) -> Result<(), SourceError> {
16468        fn clear<T: Default>(held: &Mutex<T>) {
16469            *held
16470                .lock()
16471                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
16472            held.clear_poison();
16473        }
16474        clear(&self.created);
16475        clear(&self.updated);
16476        clear(&self.commented);
16477        clear(&self.board_cache);
16478        clear(&self.search_cache);
16479        clear(&self.narrowed_cache);
16480        clear(&self.search_next);
16481        clear(&self.resolved_cache);
16482        clear(&self.read_ahead);
16483        clear(&self.pending_origins);
16484        clear(&self.member_classes);
16485        clear(&self.carried);
16486        clear(&self.held_cache);
16487        clear(&self.children_cache);
16488        clear(&self.task_children);
16489        clear(&self.written);
16490        clear(&self.fields_cache);
16491        clear(&self.held_comments);
16492        clear(&self.task_writes);
16493        clear(&self.written_tokens);
16494        self.forget_at_end_of_call();
16495        self.expected.end_command();
16496        Ok(())
16497    }
16498}
16499
16500/// One member of a project as a classification-only read answers it: its classification and
16501/// the repositories its work changes.
16502type MemberClass = (Classification, Vec<Repository>);
16503
16504/// Each project's sub-issues as one command read them, keyed by the selector they were asked
16505/// for under, beside the project that selector named; see `children_cache`.
16506type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
16507
16508/// One issue comment as the contract carries it.
16509///
16510/// `author` is absent both when GitHub answers `null` for an account that no longer exists
16511/// and when it answers an actor with no login, because either way the source did not say who
16512/// wrote it — which is what an absent author means, rather than an author called nothing.
16513///
16514/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
16515/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
16516/// and is read back with the marker taken out and reported as its origin.
16517fn comment_from(value: &Value) -> Result<Comment, SourceError> {
16518    let actor = value.get("author").unwrap_or(&Value::Null);
16519    // A bot's login is spelled as GitHub's own pages spell it, `<login>[bot]`, so a reader —
16520    // and every copy of the comment — can tell it from a person's.
16521    let bot = actor.get("__typename").and_then(Value::as_str) == Some("Bot");
16522    Ok(Comment {
16523        origin: None,
16524        id: NativeId(required_str(value, "id")?.to_owned()),
16525        author: optional_str(actor, "login")?.map(|login| {
16526            if bot && !login.ends_with("[bot]") {
16527                format!("{login}[bot]")
16528            } else {
16529                login.to_owned()
16530            }
16531        }),
16532        created_at: optional_time(value, "createdAt")?,
16533        updated_at: optional_time(value, "updatedAt")?,
16534        body: required_str(value, "body")?.to_owned(),
16535        url: optional_str(value, "url")?.map(str::to_owned),
16536    }
16537    .unmarked())
16538}
16539
16540/// The page of comments one issue node carries, resumed from `after`.
16541fn comment_page(
16542    node: &Value,
16543    issue: &str,
16544    after: Option<&str>,
16545) -> Result<Page<Comment>, SourceError> {
16546    let connection = node
16547        .get("comments")
16548        .filter(|value| !value.is_null())
16549        .ok_or_else(|| SourceError::Malformed {
16550            message: format!("GitHub issue {issue} answered with no comments connection"),
16551        })?;
16552    let items = optional_nodes(Some(connection), "issue comments")?
16553        .into_iter()
16554        .flatten()
16555        .map(comment_from)
16556        .collect::<Result<Vec<_>, _>>()?;
16557    let next = next_cursor(connection)?;
16558    if let Some(next) = &next {
16559        validate_cursor_progress(after, &next.0)?;
16560    }
16561    Ok(Page { items, next })
16562}
16563
16564/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
16565/// end — `None` when it carried none, or a page with more past it.
16566fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
16567    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
16568        return Ok(None);
16569    };
16570    if next_cursor(connection)?.is_some() {
16571        return Ok(None);
16572    }
16573    Ok(Some(
16574        optional_nodes(Some(connection), "blocked-by issues")?
16575            .into_iter()
16576            .flatten()
16577            .cloned()
16578            .collect(),
16579    ))
16580}
16581
16582/// Where the recorded tail of a dependency walk resumes; see
16583/// [`GitHubProjectsSource::recorded_edges`].
16584const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
16585
16586/// The board text field this source keeps a copy's origin in.
16587///
16588/// Named after the key it holds, and held to that name by the guard below rather than by
16589/// a reader noticing.
16590const ORIGIN_FIELD: &str = "onetaskgraph.origin";
16591
16592/// The metadata key that field holds.
16593///
16594/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
16595/// constructs or interprets the qualified id it carries. This source names it only to
16596/// route it — a short, typed value belongs in a typed field rather than in the body slot
16597/// a caller's own prose shares.
16598///
16599/// Restated rather than imported, because no plugin crate may depend on the engine. What
16600/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
16601/// target in `check`: it reads the engine's own literal and fails naming the file and the
16602/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
16603/// that creates a second item every run instead of finding the one it wrote — and that is
16604/// too late to learn it.
16605const ORIGIN_KEY: &str = "onetaskgraph.origin";
16606
16607/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
16608///
16609/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
16610/// is derived from the far end, never written down on the near item — so only a forward
16611/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
16612/// it did not come from, and it is told so rather than answered with an empty page that
16613/// reads as a walk which ended.
16614fn recorded_offset(
16615    cursor: Option<&str>,
16616    direction: Direction,
16617) -> Result<Option<usize>, SourceError> {
16618    cursor
16619        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
16620        .map(|offset| {
16621            if direction != Direction::DependsOn {
16622                return Err(SourceError::Config {
16623                    message: format!(
16624                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
16625                         reverse dependency read never issues; resume it in the direction \
16626                         that reported it"
16627                    ),
16628                });
16629            }
16630            offset.parse().map_err(|_| SourceError::Config {
16631                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
16632            })
16633        })
16634        .transpose()
16635}
16636
16637/// What a dependency read's own answer says of a board draft: `None` when the item is not a
16638/// draft, or the answer did not carry its board item; `Some(None)` for a draft whose one board
16639/// item is not on this board, which this source does not hold; and `Some(Some(slot))`, the
16640/// draft's metadata, for one it does — its board item naming this board by its number and by
16641/// the id the same answer read of the board, as [`GitHubProjectsSource::draft_by_id`] decides.
16642fn draft_slot(
16643    data: &Value,
16644    id: &NativeId,
16645    project_number: u32,
16646) -> Result<Option<Option<BTreeMap<String, Value>>>, SourceError> {
16647    let Some(draft) = data
16648        .get("node")
16649        .filter(|node| node.get("__typename").and_then(Value::as_str) == Some("DraftIssue"))
16650    else {
16651        return Ok(None);
16652    };
16653    let Some(items) = data.pointer("/draft/projectV2Items") else {
16654        return Ok(None);
16655    };
16656    if required_str(draft, "id")? != id.0 {
16657        return Err(SourceError::Malformed {
16658            message: format!("GitHub answered a different draft for {}", id.0),
16659        });
16660    }
16661    let nodes = items
16662        .get("nodes")
16663        .and_then(Value::as_array)
16664        .ok_or_else(|| SourceError::Malformed {
16665            message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
16666        })?;
16667    let info = items
16668        .get("pageInfo")
16669        .ok_or_else(|| SourceError::Malformed {
16670            message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
16671        })?;
16672    if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
16673        return Err(SourceError::Malformed {
16674            message: format!(
16675                "GitHub draft {} reports more board items than the one GitHub links a draft to",
16676                id.0
16677            ),
16678        });
16679    }
16680    let board = data
16681        .pointer("/board/projectV2/id")
16682        .and_then(Value::as_str)
16683        .ok_or_else(|| SourceError::Malformed {
16684            message: format!(
16685                "GitHub answered draft {} without this board's own id beside it",
16686                id.0
16687            ),
16688        })?;
16689    let mut held = false;
16690    for node in nodes {
16691        let project = node.get("project").ok_or_else(|| SourceError::Malformed {
16692            message: format!("GitHub draft {} board item has no project", id.0),
16693        })?;
16694        let number = project
16695            .get("number")
16696            .and_then(Value::as_u64)
16697            .ok_or_else(|| SourceError::Malformed {
16698                message: format!(
16699                    "GitHub draft {} board item has no numeric project number",
16700                    id.0
16701                ),
16702            })?;
16703        let project_id = required_str(project, "id")?;
16704        held |= number == u64::from(project_number) && project_id == board;
16705    }
16706    if !held {
16707        return Ok(Some(None));
16708    }
16709    Ok(Some(Some(
16710        metadata_body(optional_str(draft, "body")?.map(str::to_owned))?.1,
16711    )))
16712}
16713
16714fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
16715    let mut page = offset_page(edges, offset, limit.max(1));
16716    page.next = page
16717        .next
16718        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
16719    page
16720}
16721
16722/// The kind of one issue reached through a dependency connection.
16723///
16724/// The same questions the board scan asks, over the fields the dependency document
16725/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
16726/// then anything with sub-issues or the marker is a project.
16727///
16728/// # Errors
16729///
16730/// A far end this board holds as a document is refused rather than reported. The two
16731/// answers that are not refusals would both be wrong: reporting it as a task names an id
16732/// no task read of this source can find, and reporting it as a project names one no
16733/// project read can. There is no third value to return — `ItemKind` has no document
16734/// variant, because nothing may point at a document — so the relationship itself is what
16735/// the person is told about.
16736fn related_kind(value: &Value, meaning: SubIssues) -> Result<ItemKind, SourceError> {
16737    let id = required_str(value, "id")?;
16738    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
16739        return Err(SourceError::Refused {
16740            message: format!(
16741                "GitHub issue {id} is a document of this board — its title begins \
16742                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
16743                 on by one; next: remove that issue's blocking relationship on this board"
16744            ),
16745        });
16746    }
16747    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
16748    if parent.is_some() || meaning == SubIssues::Parent {
16749        return Ok(ItemKind::Task);
16750    }
16751    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
16752    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
16753        message: format!("GitHub issue {id}: {message}"),
16754    })?;
16755    let sub_issues = sub_issue_total(value)?;
16756    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
16757        ItemKind::Project
16758    } else {
16759        ItemKind::Task
16760    })
16761}
16762
16763/// The `stateInput` that closes an issue for `reason`.
16764fn closed_input(reason: ClosedState) -> Value {
16765    json!({"value":"CLOSED","stateReason":reason.reason()})
16766}
16767
16768/// The `IssueStateUpdateInput` one status target asks for.
16769///
16770/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
16771/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
16772/// a currently-closed issue: without that the item would read back `Unknown` and a copy
16773/// would report a change forever. A document has no status at all, and asks for neither.
16774fn state_input(target: Option<&StatusTarget>) -> Value {
16775    match target {
16776        Some(StatusTarget::Terminal(_, reason)) => closed_input(*reason),
16777        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
16778        // A document has no status, so a write of one says nothing about the issue's open
16779        // or closed state rather than forcing it open: `stateInput` is what carries that
16780        // instruction, and an explicit null asks for no change to it.
16781        None => Value::Null,
16782    }
16783}
16784
16785/// The metadata one write stores in the item's body slot.
16786///
16787/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
16788/// rather than carried: the kind marker so an empty project stays readable, the
16789/// repository list only when it is not exactly the issue's own repository, and the far
16790/// ends no relationship here can name.
16791///
16792/// The copy origin is the one typed field that is also mirrored here, and only as a
16793/// mirror: it lands in the board's origin field as well, which stays the one every reader
16794/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
16795/// and catches up with a write in seconds rather than minutes — can find the item by it.
16796/// A reader of the release before this one drops the slot's copy and reads the field, so an
16797/// item written here still reads with exactly one origin there.
16798fn slot_metadata(
16799    incoming: &Incoming<'_>,
16800    own_repository: Option<&Repository>,
16801    fallback: &[DependencyEdge],
16802) -> BTreeMap<String, Value> {
16803    let mut metadata = incoming.metadata.clone();
16804    match metadata.remove(ORIGIN_KEY) {
16805        Some(Value::String(origin)) if !origin.is_empty() => {
16806            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
16807        }
16808        _ => {}
16809    }
16810    match incoming.written.kind() {
16811        BoardKind::Work(kind) => metadata.insert(
16812            ItemKind::METADATA_KEY.to_owned(),
16813            Value::String(kind.marker().to_owned()),
16814        ),
16815        // A document is told by its title, so it carries no kind marker: that key names
16816        // what a dependency endpoint points at, and nothing may point at a document.
16817        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
16818    };
16819    incoming.classification.record(&mut metadata);
16820    let derivable = own_repository
16821        .map(|own| incoming.repositories == [own.clone()])
16822        .unwrap_or(incoming.repositories.is_empty());
16823    if derivable {
16824        metadata.remove(Repository::METADATA_KEY);
16825    } else {
16826        metadata.insert(
16827            Repository::METADATA_KEY.to_owned(),
16828            Value::Array(
16829                incoming
16830                    .repositories
16831                    .iter()
16832                    .map(|repository| Value::String(repository.as_str().to_owned()))
16833                    .collect(),
16834            ),
16835        );
16836    }
16837    // The typed lists are what land, whatever the caller's own metadata held under their
16838    // keys: a key of either name travelling beside the field would otherwise be a second
16839    // answer to the same question, and the field is the one the contract names.
16840    for (key, entries) in [
16841        (TaskRef::DELIVERS_KEY, incoming.delivers),
16842        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
16843    ] {
16844        set_task_list(&mut metadata, key, entries);
16845    }
16846    record_edges(&mut metadata, fallback);
16847    metadata
16848}
16849
16850/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
16851/// one slot's metadata, or no such key when there are none.
16852fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
16853    if fallback.is_empty() {
16854        metadata.remove(DependencyEdge::RECORDED_KEY);
16855    } else {
16856        metadata.insert(
16857            DependencyEdge::RECORDED_KEY.to_owned(),
16858            Value::Array(
16859                fallback
16860                    .iter()
16861                    .map(|edge| {
16862                        serde_json::to_value(&edge.to).expect("an endpoint renders as JSON")
16863                    })
16864                    .collect(),
16865            ),
16866        );
16867    }
16868}
16869
16870/// Every label one item carries, from its content's own connection and nowhere else.
16871///
16872/// There is no second place to read one from: no document this source sends selects the
16873/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
16874/// cannot carry one at all. The module documentation records the three schema facts that
16875/// settle it.
16876fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
16877    optional_nodes(content.get("labels"), "content labels")?
16878        .into_iter()
16879        .flatten()
16880        .map(|v| {
16881            Ok(Label {
16882                id: NativeId(required_str(v, "id")?.to_owned()),
16883                name: required_str(v, "name")?.to_owned(),
16884                color: optional_str(v, "color")?.map(str::to_owned),
16885            })
16886        })
16887        .collect()
16888}
16889
16890/// Each board field value one item holds, as the change token's facets name it: the field's
16891/// name, with the single-select option's name or the text. A value of a type no read here
16892/// selects, and one naming no field, says nothing a person can rename and is left out.
16893fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
16894    field_values
16895        .iter()
16896        .filter_map(|value| {
16897            let field = value.pointer("/field/name")?.as_str()?;
16898            let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
16899            Some((field.to_owned(), held.to_owned()))
16900        })
16901        .collect()
16902}
16903
16904/// The digest of the facets of the issue `content`, whose board item's values are
16905/// `field_values` — or `None` when the response did not carry, whole, every member they are read
16906/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
16907/// label, and every field value.
16908///
16909/// What a write's own response spells its token from, by exactly the rules a read takes them by.
16910fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
16911    let ended = |connection: &Value| {
16912        connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
16913    };
16914    let labels = content.get("labels").filter(|labels| ended(labels))?;
16915    if !ended(field_values) {
16916        return None;
16917    }
16918    let parent = content.get("parent")?;
16919    let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
16920    let kind = BoardKind::of(
16921        content.get("title")?.as_str()?,
16922        !parent.is_null(),
16923        sub_issue_total(content).ok()?,
16924        ItemKind::from_metadata(&slot).ok()?,
16925    );
16926    let labels = self::labels(&json!({"labels": labels})).ok()?;
16927    let nodes = field_values.get("nodes")?.as_array()?;
16928    Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
16929}
16930
16931/// The definition of each board field one item's values are values of, in the shape a read
16932/// of the board's own `fields` gives one.
16933///
16934/// A value names its field through a fragment on that field's own type, so the type is
16935/// known from which kind of value it is: a single-select value's field is a
16936/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
16937/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
16938fn field_definitions(field_values: &[Value]) -> Vec<Value> {
16939    field_values
16940        .iter()
16941        .filter_map(|value| {
16942            let field = value.get("field")?.as_object()?;
16943            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
16944            let typename = if value.get("text").is_some() {
16945                "ProjectV2Field"
16946            } else if value.get("name").is_some() {
16947                "ProjectV2SingleSelectField"
16948            } else {
16949                return None;
16950            };
16951            let mut defined = field.clone();
16952            defined.insert("__typename".to_owned(), json!(typename));
16953            // Only a text field holds a text value, so the value says the field's type too.
16954            if typename == "ProjectV2Field" {
16955                defined.insert("dataType".to_owned(), json!("TEXT"));
16956            }
16957            Some(Value::Object(defined))
16958        })
16959        .collect()
16960}
16961
16962fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
16963    let Some(node) = field_values
16964        .iter()
16965        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
16966    else {
16967        return Ok(None);
16968    };
16969    Ok(optional_str(node, "text")?.map(str::to_owned))
16970}
16971
16972fn valid_github_owner(owner: &str) -> bool {
16973    !owner.is_empty()
16974        && owner.len() <= 39
16975        && !owner.starts_with('-')
16976        && !owner.ends_with('-')
16977        && !owner.contains("--")
16978        && owner
16979            .bytes()
16980            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
16981}
16982
16983/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
16984/// neither of the two names a path segment already means.
16985fn valid_github_repository_name(name: &str) -> bool {
16986    !name.is_empty()
16987        && name.len() <= 100
16988        && name != "."
16989        && name != ".."
16990        && name
16991            .bytes()
16992            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
16993}
16994
16995fn valid_environment_name(name: &str) -> bool {
16996    let mut bytes = name.bytes();
16997    bytes
16998        .next()
16999        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
17000        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
17001}
17002
17003/// How many sub-issues one issue has.
17004///
17005/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
17006/// absent or non-integer one is a response this source cannot read — and reading it as
17007/// zero would classify a project as a task, which is exactly the mistake the marker
17008/// exists to keep from happening quietly.
17009fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
17010    let summary = issue
17011        .get("subIssuesSummary")
17012        .ok_or_else(|| SourceError::Malformed {
17013            message: "GitHub issue is missing subIssuesSummary".into(),
17014        })?;
17015    summary
17016        .get("total")
17017        .and_then(Value::as_u64)
17018        .ok_or_else(|| SourceError::Malformed {
17019            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
17020        })
17021}
17022
17023/// One issue's own `number`.
17024///
17025/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
17026/// an issue in this module asks for it. So a read of one that comes back without it, or
17027/// with something that is not an unsigned integer, is a response this source cannot read —
17028/// absence here is **not** "this issue has no number". A draft is the content that has
17029/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
17030/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
17031fn issue_number(issue: &Value) -> Result<u64, SourceError> {
17032    issue
17033        .get("number")
17034        .and_then(Value::as_u64)
17035        .ok_or_else(|| SourceError::Malformed {
17036            message: "GitHub issue number is missing or is not an unsigned integer".into(),
17037        })
17038}
17039
17040/// The `number` a creating mutation answered with, and `None` when it answered without one;
17041/// why a missing one is tolerated is at the call in `create_and_file_issue`.
17042fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
17043    match created.get("number") {
17044        None | Some(Value::Null) => Ok(None),
17045        Some(value) => value
17046            .as_u64()
17047            .map(Some)
17048            .ok_or_else(|| SourceError::Malformed {
17049                message: "GitHub created issue number is not an unsigned integer".into(),
17050            }),
17051    }
17052}
17053
17054fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
17055    value
17056        .get(field)
17057        .and_then(Value::as_str)
17058        .ok_or_else(|| SourceError::Malformed {
17059            message: format!("GitHub response is missing string field {field}"),
17060        })
17061}
17062
17063fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
17064    let found = required_str(value, field)?;
17065    if found.trim().is_empty() {
17066        return Err(SourceError::Malformed {
17067            message: format!("GitHub response has blank string field {field}"),
17068        });
17069    }
17070    Ok(found)
17071}
17072
17073/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
17074/// needs one — Linear spells them too, in its own description field.
17075///
17076/// Restated rather than shared, because a plugin crate depends on the contract crate and
17077/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
17078/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
17079/// source round-trips its own writes perfectly well under its own spelling.
17080const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
17081const METADATA_CLOSE: &str = "\n-->";
17082
17083/// What the composer puts between a non-empty visible body and the slot, and the one thing
17084/// the parser takes off the visible body when it takes the slot off — exactly once, so every
17085/// other trailing byte of the body comes back as it was written.
17086// 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.
17087const METADATA_SEPARATOR: &str = "\n\n";
17088
17089/// The visible body and the metadata slot at the end of it.
17090///
17091/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
17092/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
17093/// own content and is left alone. The visible body is everything before the slot less the
17094/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
17095fn metadata_body(
17096    body: Option<String>,
17097) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
17098    let Some(body) = body else {
17099        return Ok((None, BTreeMap::new()));
17100    };
17101    let Some(slot) = slot_span(&body)? else {
17102        return Ok((Some(body), BTreeMap::new()));
17103    };
17104    let metadata =
17105        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
17106            SourceError::Malformed {
17107                message: format!(
17108                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
17109                ),
17110            }
17111        })?;
17112    let before = &body[..slot.start];
17113    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
17114    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
17115}
17116
17117/// Where the metadata slot sits in one body, as byte offsets into it.
17118struct SlotSpan {
17119    /// Where [`METADATA_OPEN`] begins.
17120    start: usize,
17121    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
17122    encoded_start: usize,
17123    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
17124    encoded_end: usize,
17125    /// Just past [`METADATA_CLOSE`].
17126    end: usize,
17127}
17128
17129/// The slot at the very end of `body`, or `None` when it has none.
17130///
17131/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
17132/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
17133/// slot.
17134fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
17135    let Some(start) = body.rfind(METADATA_OPEN) else {
17136        return Ok(None);
17137    };
17138    let encoded_start = start + METADATA_OPEN.len();
17139    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
17140        return Err(SourceError::Malformed {
17141            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
17142        });
17143    };
17144    let encoded_end = encoded_start + relative_end;
17145    let end = encoded_end + METADATA_CLOSE.len();
17146    if !body[end..].trim().is_empty() {
17147        return Ok(None);
17148    }
17149    Ok(Some(SlotSpan {
17150        start,
17151        encoded_start,
17152        encoded_end,
17153        end,
17154    }))
17155}
17156
17157/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
17158/// slot as it was.
17159///
17160/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
17161/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
17162/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
17163/// or alone in an empty body — and a body with no slot that is given no metadata is
17164/// returned as it is.
17165fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
17166    let encoded = if metadata.is_empty() {
17167        None
17168    } else {
17169        Some(
17170            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
17171                message: error.to_string(),
17172            })?,
17173        )
17174    };
17175    Ok(match (slot_span(body)?, encoded) {
17176        (Some(slot), Some(encoded)) => format!(
17177            "{}{encoded}{}",
17178            &body[..slot.encoded_start],
17179            &body[slot.encoded_end..]
17180        ),
17181        (Some(slot), None) => {
17182            let before = &body[..slot.start];
17183            format!(
17184                "{}{}",
17185                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
17186                &body[slot.end..]
17187            )
17188        }
17189        (None, None) => body.to_owned(),
17190        (None, Some(encoded)) if body.is_empty() => {
17191            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
17192        }
17193        (None, Some(encoded)) => {
17194            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
17195        }
17196    })
17197}
17198
17199/// `body` with everything before its metadata slot replaced by `content`, and the slot
17200/// itself kept byte for byte.
17201///
17202/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
17203/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
17204/// `content` is empty — so a read of the result reports `content` as the visible body and
17205/// the slot's metadata exactly as it was.
17206fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
17207    let Some(slot) = slot_span(body)? else {
17208        return Ok(content.to_owned());
17209    };
17210    let kept = &body[slot.start..];
17211    Ok(if content.is_empty() {
17212        kept.to_owned()
17213    } else {
17214        format!("{content}{METADATA_SEPARATOR}{kept}")
17215    })
17216}
17217
17218/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
17219fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
17220    if entries.is_empty() {
17221        metadata.remove(key);
17222    } else {
17223        metadata.insert(
17224            key.to_owned(),
17225            Value::Array(
17226                entries
17227                    .iter()
17228                    .map(|entry| Value::String(entry.as_str().to_owned()))
17229                    .collect(),
17230            ),
17231        );
17232    }
17233}
17234
17235fn compose_body(
17236    content: Option<&str>,
17237    metadata: &BTreeMap<String, Value>,
17238) -> Result<Option<String>, SourceError> {
17239    let visible = content.unwrap_or_default();
17240    if metadata.is_empty() {
17241        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
17242    }
17243    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
17244        message: error.to_string(),
17245    })?;
17246    Ok(Some(if visible.is_empty() {
17247        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
17248    } else {
17249        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
17250    }))
17251}
17252
17253fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
17254    value
17255        .get(field)
17256        .and_then(Value::as_bool)
17257        .ok_or_else(|| SourceError::Malformed {
17258            message: format!("GitHub response is missing boolean field {field}"),
17259        })
17260}
17261fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
17262    match value.get(field) {
17263        None | Some(Value::Null) => Ok(None),
17264        Some(value) => value
17265            .as_str()
17266            .map(Some)
17267            .ok_or_else(|| SourceError::Malformed {
17268                message: format!("GitHub response field {field} is not a string or null"),
17269            }),
17270    }
17271}
17272fn optional_nodes<'a>(
17273    connection: Option<&'a Value>,
17274    name: &str,
17275) -> Result<Option<&'a Vec<Value>>, SourceError> {
17276    match connection {
17277        None | Some(Value::Null) => Ok(None),
17278        Some(value) => value
17279            .get("nodes")
17280            .and_then(Value::as_array)
17281            .map(Some)
17282            .ok_or_else(|| SourceError::Malformed {
17283                message: format!("GitHub {name}.nodes is not an array"),
17284            }),
17285    }
17286}
17287fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
17288    let page_info = connection
17289        .get("pageInfo")
17290        .ok_or_else(|| SourceError::Malformed {
17291            message: format!("GitHub {name} has no pageInfo"),
17292        })?;
17293    if required_bool(page_info, "hasNextPage")? {
17294        return Err(SourceError::Malformed {
17295            message: format!(
17296                "GitHub {name} exceeds the supported nested connection size of {size}"
17297            ),
17298        });
17299    }
17300    Ok(())
17301}
17302fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
17303    optional_str(value, field)?
17304        .map(|timestamp| {
17305            timestamp.parse().map_err(|error| SourceError::Malformed {
17306                message: format!("GitHub response field {field} is not a timestamp: {error}"),
17307            })
17308        })
17309        .transpose()
17310}
17311fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
17312    if page.limit == 0 {
17313        Err(SourceError::Config {
17314            message: "page limit must be at least 1".into(),
17315        })
17316    } else {
17317        Ok(())
17318    }
17319}
17320fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
17321    let page = connection
17322        .get("pageInfo")
17323        .filter(|value| value.is_object())
17324        .ok_or_else(|| SourceError::Malformed {
17325            message: "GitHub connection is missing pageInfo".into(),
17326        })?;
17327    if required_bool(page, "hasNextPage")? {
17328        let cursor = required_str(page, "endCursor")?;
17329        validate_cursor_progress(None, cursor)?;
17330        Ok(Some(Cursor(cursor.into())))
17331    } else {
17332        Ok(None)
17333    }
17334}
17335fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
17336    if next.is_empty() || previous == Some(next) {
17337        Err(SourceError::Malformed {
17338            message: "GitHub pagination cursor is empty or did not advance".into(),
17339        })
17340    } else {
17341        Ok(())
17342    }
17343}
17344/// The version of this plugin's opaque narrowing-search cursor.
17345pub const SEARCH_CURSOR_VERSION: u32 = 4;
17346
17347#[derive(Serialize, Deserialize)]
17348#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
17349enum SearchConnection {
17350    Initial {},
17351    Continuing { after: Cursor },
17352    Exhausted {},
17353}
17354impl SearchConnection {
17355    fn after(&self) -> Option<&str> {
17356        match self {
17357            Self::Continuing { after } => Some(&after.0),
17358            _ => None,
17359        }
17360    }
17361    fn exhausted(&self) -> bool {
17362        matches!(self, Self::Exhausted { .. })
17363    }
17364    /// Whether a cursor naming this position, `offset` rows into its page, is one this
17365    /// plugin could have handed out: a page is resumed only part of the way through it — an
17366    /// offset of a whole page or more would skip rows nobody was given — an initial page
17367    /// only once some of it was handed out, and an exhausted connection has no page to be
17368    /// part of the way through.
17369    fn valid_resume(&self, offset: usize) -> bool {
17370        let within = offset < SEARCH_PAGE_SIZE as usize;
17371        match self {
17372            Self::Initial { .. } => offset > 0 && within,
17373            Self::Continuing { after } => !after.0.is_empty() && within,
17374            Self::Exhausted { .. } => offset == 0,
17375        }
17376    }
17377}
17378
17379/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
17380#[derive(Serialize, Deserialize)]
17381#[serde(deny_unknown_fields)]
17382struct SearchPosition {
17383    version: u32,
17384    connection: SearchConnection,
17385    /// How many rows of the page `connection` starts were already handed out.
17386    #[serde(default, skip_serializing_if = "is_zero")]
17387    offset: usize,
17388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17389    seen: Vec<NativeId>,
17390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17391    own: Vec<NativeId>,
17392}
17393impl Default for SearchPosition {
17394    fn default() -> Self {
17395        Self {
17396            version: SEARCH_CURSOR_VERSION,
17397            connection: SearchConnection::Initial {},
17398            offset: 0,
17399            seen: Vec::new(),
17400            own: Vec::new(),
17401        }
17402    }
17403}
17404
17405fn is_zero(offset: &usize) -> bool {
17406    *offset == 0
17407}
17408
17409fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
17410    cursor.map_or(Ok(0), |c| {
17411        c.0.parse().map_err(|_| SourceError::Config {
17412            message: "page cursor is invalid".into(),
17413        })
17414    })
17415}
17416fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
17417    if offset > items.len() {
17418        return Page::last(vec![]);
17419    }
17420    let tail = items.split_off(offset);
17421    let mut selected = tail;
17422    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
17423    selected.truncate(limit);
17424    Page {
17425        items: selected,
17426        next,
17427    }
17428}
17429
17430impl GitHubProjectsSource {
17431    /// The body [`Self::write_item`] would compose for `incoming`, sized in the characters
17432    /// [`BODY_LIMIT`] counts, without a request: its slot as `slot_metadata` writes it, every
17433    /// asset reference rewritten to its recorded URL or — for one not uploaded yet — to an
17434    /// address as long as GitHub's, and the heading markup this board's bodies carry.
17435    ///
17436    /// What the write reads rather than knows is taken as it most often lands: the issue in
17437    /// the one repository the item names, else the configured one, and every edge on the
17438    /// board's own relationships rather than in the slot.
17439    fn body_size(&self, incoming: &Incoming<'_>) -> onetaskgraph_plugin_api::BodySize {
17440        let own = match incoming.repositories {
17441            [named] => Some(named.clone()),
17442            _ => self
17443                .repository
17444                .as_ref()
17445                .and_then(|target| Repository::try_from(target.origin()).ok()),
17446        };
17447        let mut slot = slot_metadata(incoming, own.as_ref(), &[]);
17448        let content = match incoming.assets {
17449            Some(assets) => {
17450                let mut uploads = onetaskgraph_plugin_api::AssetUploads::default();
17451                for asset in &assets.assets {
17452                    let url = assets
17453                        .recorded_assets
17454                        .as_ref()
17455                        .and_then(|recorded| recorded.reusable(&asset.name, &asset.sha256))
17456                        .unwrap_or(PREDICTED_ATTACHMENT_URL)
17457                        .to_owned();
17458                    uploads.0.insert(
17459                        asset.name.clone(),
17460                        onetaskgraph_plugin_api::AssetUpload {
17461                            sha256: asset.sha256.clone(),
17462                            url,
17463                        },
17464                    );
17465                }
17466                incoming.content.map(|content| {
17467                    onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads)
17468                })
17469            }
17470            None => incoming.content.map(str::to_owned),
17471        };
17472        let marked = content.as_deref().map(|content| self.marked(content));
17473        let size = compose_body(marked.as_deref(), &slot)
17474            .ok()
17475            .flatten()
17476            .map_or(0, |body| body.chars().count());
17477        onetaskgraph_plugin_api::BodySize {
17478            size: u64::try_from(size).unwrap_or(u64::MAX),
17479            limit: Some(BODY_LIMIT),
17480        }
17481    }
17482
17483    async fn write_task_assets(
17484        &self,
17485        write: &ItemWrite<Task>,
17486        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17487    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17488        self.write_item(
17489            &self.task_incoming(write, assets)?,
17490            write.target.as_ref(),
17491            &write.depends_on,
17492        )
17493        .await
17494    }
17495
17496    /// One task write as [`Self::write_item`] takes it, once what it delivers and its priority
17497    /// are known to be ones this source can write.
17498    fn task_incoming<'a>(
17499        &self,
17500        write: &'a ItemWrite<Task>,
17501        assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
17502    ) -> Result<Incoming<'a>, SourceError> {
17503        let near = write.target.as_ref().unwrap_or(&write.item.id);
17504        for (key, entries) in [
17505            (TaskRef::DELIVERS_KEY, &write.item.delivers),
17506            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
17507        ] {
17508            TaskRef::listed(key, near, Some(&self.name), entries.clone())
17509                .map_err(|message| SourceError::Refused { message })?;
17510        }
17511        if self.priorities.is_none() && write.item.priority != Priority::None {
17512            return Err(self.holds_no_priority());
17513        }
17514        if write.item.assignees.is_some() && !self.assignees {
17515            return Err(onetaskgraph_plugin_api::unwritable_field(KIND, "assignees"));
17516        }
17517        Ok(Incoming {
17518            written: Written::Work(ItemKind::Task, &write.item.status),
17519            title: &write.item.title,
17520            content: write.item.content.as_deref(),
17521            assets,
17522            labels: &write.item.labels,
17523            metadata: &write.item.metadata,
17524            repositories: &write.item.repositories,
17525            classification: write.item.classification,
17526            parent: write.item.project.as_ref(),
17527            delivers: &write.item.delivers,
17528            delivered_by: &write.item.delivered_by,
17529            priority: self.priorities.as_ref().map(|_| write.item.priority),
17530            assignees: write.item.assignees.as_deref(),
17531        })
17532    }
17533    async fn write_document_assets(
17534        &self,
17535        write: &ItemWrite<Document>,
17536        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17537    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17538        // A document takes part in no dependency graph, so there is no far end to write
17539        // natively and none to record: a caller naming one is told so rather than having it
17540        // stored under the reserved key, where a later read would report an edge the
17541        // contract says cannot exist.
17542        if !write.depends_on.is_empty() {
17543            return Err(SourceError::Refused {
17544                message: format!(
17545                    "this write names {} dependencies for a document, and a document takes \
17546                     part in no dependency graph; next: put the dependency on the task or \
17547                     project the document is about",
17548                    write.depends_on.len()
17549                ),
17550            });
17551        }
17552        self.write_item(
17553            &Incoming {
17554                written: Written::Document,
17555                title: &write.item.title,
17556                content: write.item.content.as_deref(),
17557                assets,
17558                labels: &write.item.labels,
17559                metadata: &write.item.metadata,
17560                repositories: &write.item.repositories,
17561                classification: write.item.classification,
17562                parent: write.item.project.as_ref(),
17563                delivers: &[],
17564                delivered_by: &[],
17565                priority: None,
17566                assignees: None,
17567            },
17568            write.target.as_ref(),
17569            &[],
17570        )
17571        .await
17572    }
17573}
17574
17575/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
17576/// itself, for the two things no journey can observe.
17577///
17578/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
17579/// with and without the call, that a settlement, a board listing and a metadata search each
17580/// read afresh after it — the resolved records, the written-item overlay, the board and its
17581/// search, and the narrowed searches. What they cannot reach is the held field definitions,
17582/// because a status write naming an option a person deleted is refused the same whether or
17583/// not the list is held, and a poisoned lock, because nothing outside the source can panic
17584/// while one of its locks is held. So these assert those directly, and every other holder
17585/// beside them so a holder added later without a clear in the call fails here.
17586#[cfg(test)]
17587mod end_command_tests {
17588    use super::*;
17589
17590    struct Token;
17591
17592    impl SecretResolver for Token {
17593        fn get(&self, var: &str) -> Option<SecretString> {
17594            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
17595        }
17596    }
17597
17598    fn source() -> GitHubProjectsSource {
17599        let config = serde_json::from_value(json!({
17600            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
17601            // Nothing here is sent: the source is only built and its state inspected.
17602            "endpoint": "http://127.0.0.1:9/graphql",
17603        }))
17604        .expect("a usable configuration");
17605        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
17606            .expect("the source builds")
17607    }
17608
17609    /// One issue as a board read answers it.
17610    fn resolved(source: &GitHubProjectsSource) -> Resolved {
17611        source
17612            .resolve(&json!({
17613                "id": "ITEM-1",
17614                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
17615                            "body": "what a person may since have edited", "state": "OPEN",
17616                            "stateReason": null, "url": null, "number": 1,
17617                            "subIssuesSummary": {"total": 0},
17618                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
17619                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
17620            }))
17621            .expect("the item reads")
17622            .expect("an issue")
17623    }
17624
17625    /// Hold something in every holder the call clears, and the repository id it keeps.
17626    fn fill(source: &GitHubProjectsSource) {
17627        let item = resolved(source);
17628        source.created.lock().unwrap().push(item.clone());
17629        source.updated.lock().unwrap().push(item.clone());
17630        *source.board_cache.lock().unwrap() = Some(Board {
17631            id: "PVT-board".into(),
17632            fields: json!({"nodes": []}),
17633            items: vec![item.clone()],
17634        });
17635        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
17636        source
17637            .narrowed_cache
17638            .lock()
17639            .unwrap()
17640            .insert("status:todo".into(), vec![item.clone()]);
17641        source.children_cache.lock().unwrap().insert(
17642            NativeId("P-1".into()),
17643            (NativeId("P-1".into()), vec![item.clone()]),
17644        );
17645        source
17646            .search_next
17647            .lock()
17648            .unwrap()
17649            .insert("status:todo".into(), Some("cursor".into()));
17650        source
17651            .resolved_cache
17652            .lock()
17653            .unwrap()
17654            .insert(item.id.clone(), item);
17655        *source.fields_cache.lock().unwrap() = Some(BoardFields {
17656            id: BoardId::parse("PVT-board").unwrap(),
17657            fields: json!({"nodes": []}),
17658        });
17659        source
17660            .repository_cache
17661            .lock()
17662            .unwrap()
17663            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
17664    }
17665
17666    fn assert_dropped(source: &GitHubProjectsSource) {
17667        assert!(source.created().unwrap().is_empty(), "created");
17668        assert!(source.updated().unwrap().is_empty(), "updated");
17669        assert!(source.board_cache().unwrap().is_none(), "board");
17670        assert!(source.search_cache.lock().unwrap().is_none(), "search");
17671        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
17672        assert!(
17673            source.children_cache.lock().unwrap().is_empty(),
17674            "project children"
17675        );
17676        assert!(
17677            source.search_next.lock().unwrap().is_empty(),
17678            "search paging"
17679        );
17680        assert!(
17681            source.resolved_cache().unwrap().is_empty(),
17682            "resolved records"
17683        );
17684        assert!(source.fields_cache().unwrap().is_none(), "board fields");
17685        assert_eq!(
17686            source.repository_cache().unwrap().len(),
17687            1,
17688            "a repository's node id stays valid and is kept"
17689        );
17690    }
17691
17692    fn end(source: &GitHubProjectsSource) {
17693        tokio::runtime::Builder::new_current_thread()
17694            .build()
17695            .unwrap()
17696            .block_on(source.end_command())
17697            .expect("the command ends");
17698    }
17699
17700    #[test]
17701    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
17702        let source = source();
17703        fill(&source);
17704        end(&source);
17705        assert_dropped(&source);
17706    }
17707
17708    #[test]
17709    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
17710        fn poison<T: Send>(held: &Mutex<T>) {
17711            std::thread::scope(|scope| {
17712                let _ = scope
17713                    .spawn(|| {
17714                        let _guard = held.lock().unwrap();
17715                        panic!("a failure while the lock is held");
17716                    })
17717                    .join();
17718            });
17719            assert!(held.is_poisoned());
17720        }
17721        let source = source();
17722        fill(&source);
17723        poison(&source.created);
17724        poison(&source.updated);
17725        poison(&source.board_cache);
17726        poison(&source.search_cache);
17727        poison(&source.narrowed_cache);
17728        poison(&source.search_next);
17729        poison(&source.resolved_cache);
17730        poison(&source.fields_cache);
17731        assert!(
17732            source.resolved_cache().is_err(),
17733            "a poisoned lock is refused before the call"
17734        );
17735        end(&source);
17736        assert_dropped(&source);
17737    }
17738}
17739
17740#[cfg(test)]
17741mod unprojectable_fields_tests {
17742    use super::*;
17743
17744    /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
17745    fn documented() -> std::collections::BTreeSet<String> {
17746        let document = std::fs::read_to_string(
17747            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
17748        )
17749        .expect("docs/metadata.md is readable");
17750        let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
17751        let lead = "a `field` equal, ignoring case, to ";
17752        let start = document.find(lead).expect("the refused-field sentence") + lead.len();
17753        let sentence = &document[start..];
17754        let sentence = &sentence[..sentence
17755            .find("; and two entries naming one field")
17756            .expect("the refused-field sentence ends at the duplicate rule")];
17757        let (own, github) = sentence
17758            .split_once(" or a field GitHub owns on every board (")
17759            .expect("the sentence names GitHub's own fields");
17760        own.split('`')
17761            .skip(1)
17762            .step_by(2)
17763            .chain(
17764                github
17765                    .strip_suffix(')')
17766                    .expect("GitHub's own fields close the sentence")
17767                    .split(", "),
17768            )
17769            .map(str::to_owned)
17770            .collect()
17771    }
17772
17773    /// `docs/metadata.md` states the text field's limit a caller is held to; it is this source's
17774    /// own constant, so the two cannot part.
17775    #[test]
17776    fn the_documented_text_field_limit_is_the_one_the_source_shortens_at() {
17777        let document = std::fs::read_to_string(
17778            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
17779        )
17780        .expect("docs/metadata.md is readable");
17781        let lead = "**A text field holds at most ";
17782        let start = document.find(lead).expect("the limit sentence") + lead.len();
17783        let stated = &document[start..start + document[start..].find(',').unwrap() + 4];
17784        assert_eq!(
17785            stated.replace(',', "").parse::<usize>().ok(),
17786            Some(TEXT_FIELD_LIMIT),
17787            "docs/metadata.md states {stated:?}"
17788        );
17789        assert!(document[start..].starts_with(&format!("{stated}, counted here in UTF-8 bytes.")));
17790    }
17791
17792    #[test]
17793    fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
17794        let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
17795            .iter()
17796            .map(|&name| name.to_owned())
17797            .collect();
17798        assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
17799        let instance = SourceName::new("work").unwrap();
17800        for name in UNPROJECTABLE_FIELDS {
17801            for spelled in [name.to_owned(), name.to_uppercase()] {
17802                let entry = MetadataFieldConfig {
17803                    field: spelled.clone(),
17804                    key: "team.machine".to_owned(),
17805                    path: Vec::new(),
17806                };
17807                let refused = MetadataField::resolve(vec![entry], &instance)
17808                    .expect_err("an unprojectable field is refused");
17809                assert!(
17810                    refused.to_string().contains(&format!("{name:?}")),
17811                    "{spelled}: {refused}"
17812                );
17813            }
17814        }
17815    }
17816}