Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83//! **A caller's value can also be projected onto a board text field**, so the board can filter
84//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
85//! metadata key and a path inside its value. The slot stays the value's one home — every read
86//! reports metadata from it alone — and the field follows it on every write of an item, a
87//! create, an update and a copy alike: a string is written, and not sent again while the field
88//! holds it — an empty string included, which is written as itself; nothing there or `null`
89//! clears a field holding a value; any other
90//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
91//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
92//! own moves its field first and the body last, as every write of an existing item does. A
93//! board without the field, or with a non-text field of that name, is refused before any
94//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
95//! a text field and never changes a field that is there.
96//!
97// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
98//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
99//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
100//! option of the board's one `Status` field for a task and for a project: a bare name is
101//! the option for both kinds, `null` disables the category for both, and `{task, project}`
102//! names it per kind. A category the mapping does not mention keeps its shipped default for
103//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
104//! gets the shipped default for the kind it leaves out. Two categories one kind would read
105//! back from one option are refused as the configuration is read, ignoring case, while one
106//! option may stand for different categories of the two kinds. Writes go by the kind of the
107//! item written: a status that kind has no option for, or whose option the board lacks, is
108//! refused before any mutation, naming the source, the kind, the category and the key
109//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
110//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
111//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
112//! before selecting its option. Reads give a closed issue's reason precedence over its
113//! option, while an open issue's option decides its category through its own kind's
114//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
115//! The guarded [`GitHubProjectsSource::status_options`] and
116//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
117//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
118//! existing option id and verify the field and item assignments immediately afterwards.
119//! They ask for both kinds' options, counting a terminal category's mapped option as
120//! configured because a terminal write refuses without it. No ordinary source read or
121//! write calls that mutation, whose
122//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
123//! and a mistake destroys every item's status. A status this board cannot represent is a
124//! refusal naming the status and the instance instead.
125//!
126//! `unknown` has no shipped option because this source cannot preserve an open-ended
127//! status word: it writes an existing board option and never
128//! creates an option. An operator may map `unknown` to one existing option, in which case
129//! every unknown word lands on that option and reads back as `unknown` under the option's
130//! name. This differs from `local-md`, which writes and reads the original word itself.
131//!
132//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
133//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
134//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
135//! finished tasks were only moved to a "Done" column would read 0% complete forever.
136// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
137//!
138//! # What this source declares, field by field
139//!
140//! One verdict per field of [`Capabilities`], and what `Native` means when this source
141//! says it. *Proven* means a shared journey drives it against the real
142//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
143//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
144//! [`capabilities`](TaskSource::capabilities) from parting.
145//!
146//! | Field | Verdict |
147//! | --- | --- |
148//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
149//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
150//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
151//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
152//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
153//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
154//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
155//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
156//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
157//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
158//! | `filter_by_metadata` | **Supported, and asked of GitHub** — through the body and never through a projected `metadata_fields` text field, which is the board's to filter on and not this source's to read. A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
159//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
160//! | `reconcile_status` | **Supported,** and proven by `onetaskgraph-github-projects-e2e`'s `status_reconcile.rs`: the board's own `ProjectV2.items(query:)` filter, `is:closed`, read page by page and never the unfiltered items, each candidate confirmed from its own item node — a closed task whose card is not at the option the mapping gives its closure's category. |
161//! | `complete_by_issue` | **Supported,** and proven by `status_complete.rs` there and in the engine's suite: each issue a reference names read in one aliased request per fifty — its repository, its number, its first page of board memberships with each card's `Status` read by name — and nothing else of the board. |
162//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
163//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
164//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
165//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
166//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
167//!
168//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
169//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
170//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
171//! and the raw bytes as the request body. The numeric repository id is read at most once
172//! per repository per source instance. A loopback API endpoint also selects the loopback
173//! uploads host; production needs no assets repository, commits or extra configuration.
174//! Every returned JSON `url` is read with the same token before a body is written: only
175//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
176//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
177//! asset and HTTP status and says the token type may not be accepted by the upload
178//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
179//! fine-grained PATs or OAuth tokens.
180//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
181//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
182//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
183//! and verified afresh, in the repository the issue already lives in on an update.
184//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
185//! Uploads take the same mutation spacing as content writes. Both uploads and verification
186//! reads wait out classified rate limits within the configured per-call retry budget,
187//! on the supplied clock; verifying reads take no mutation slot.
188//!
189//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
190//! source has documents and that its tasks have comments, both of which hold — and the three
191//! facts behind the uniform `Native` on the
192//! predicates beside it are recorded below rather than re-derived, because a reader who
193//! takes `Native` to mean *the remote service filters* will read that uniformity as a
194//! lie.
195//!
196//! First, the plugin contract defines `Support::Native` as *the source applies this
197//! predicate itself*, and says nothing about where it applies it. What the declaration
198//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
199//! — so that the engine may push it down and apply nothing of its own.
200//!
201//! Second, this source can keep that promise for every predicate at no additional API
202//! cost, because whichever of the reads below answers a query has already read every
203//! candidate that query will return before it filters anything. Filtering those items is
204//! in-process work over data already in hand.
205//!
206//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
207//! applied in process over what that question returned. A project filter has a relationship — a
208//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
209//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
210//! a search for metadata values, is the board-scoped issue search carrying the text and each
211//! value as quoted phrases; an origin is the board's own field filter over its origin field
212//! beside the same search for the id. **The text search narrows, and that is this source's
213//! declared semantics:** GitHub matches whole words where the substring rule this source and
214//! the local Markdown source confirm with would match inside one, so an item holding the text
215//! only inside a longer word is never a candidate. Every item returned does contain the text.
216//! A project query's text, and a document query's scoped to no project, is that same search
217//! and narrows on the same terms, its candidates confirmed by their kind as well.
218//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
219//! so those three are applied in process over the candidates, and a query carrying none of
220//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
221//! engine compensate for work this source has already done, and declaring `projects` native
222//! while ignoring the filter (which this source once did) silently returns another project's
223//! tasks, because the engine trusts the declaration and applies nothing locally.
224//!
225//! # The three ways this source reaches an item, and what each costs
226//!
227//! A board read is charged for what its *nested* connections could return rather than for
228//! what was asked, so one whole-board read costs the same whether the question was about
229//! one project or about all of them. That is why a question about one project is never
230//! answered by reading the board:
231//!
232//! | The question | What is sent | What it costs |
233//! | --- | --- | --- |
234//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)`, carrying the field definitions of the boards it sits on and the far ends of its `blockedBy`, which is what a write of it needs — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
235//! | one task with its first page of comments, for `task show` and a comment listing | [`graphql::ISSUE_DETAIL`] — the same `node(id:)` read with the issue's `comments` | the item and a page of its comments |
236//! | 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 |
237//! | 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 |
238//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
239//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
240//! | 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 |
241//! | 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 |
242//! | 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 |
243//! | 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 |
244//! | 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 |
245//! | 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 |
246//!
247//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
248//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
249//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
250//! equal to declared points equal to the row. They include the origin lookup and the
251//! field/repository discovery a create needs. A bound re-copy changes status, priority,
252//! content and metadata; comment recount means a subsequent detail read. Each request here
253//! costs one declared point. A membership beyond the embedded page can additionally require
254//! the one-point membership recovery described above. A bound re-copy of a task filed under a
255//! project adds one read, the engine confirming that project's link by its own id once per
256//! command; and the same-source far ends a write newly names — those that do not already block
257//! the item, whose own read answered for them — are read together by their own ids,
258//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
259//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
260//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
261//!
262//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
263//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
264//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
265//! holds both halves.
266//!
267//! **An existing item is written body last.** A bound re-copy and a `task update` send its
268//! board fields first — the `Status` option and the `Priority` together, in one request — then
269//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
270//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
271//! undoing an earlier field when a later one fails, so that order is what makes a write
272//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
273//! the one piece of metadata written before the body, an origin a copy re-points, is put back
274//! when a later write is refused — and when putting it back is refused too, the write's own
275//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
276//! of those writes in turn, whole and as one aliased field failing after the one before it.
277//!
278//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
279//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
280//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
281//!
282//! - **A board is accepted at creation but its item is not answered, so a create still files
283//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
284//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
285//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
286//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
287//!   against a real board on 2026-10-01 with a create sending the board there and reading the
288//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
289//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
290//!   refused "Content already exists in this project" — GitHub had filed the issue after
291//!   answering, and refuses a second filing rather than answering with the item it holds. A
292//!   create therefore sends no `projectV2Ids` and files the issue with
293//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
294//!   real is the read before it: the board's fields and the repository's id together, in
295//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
296//! - **A comment still reads its target first, so a comment is 2 requests.**
297//!   `AddCommentInput.subjectId: ID!` is declared there with
298//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
299//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
300//!   project's issue, a document's issue, an issue on no board of this source and a pull
301//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
302//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
303//!   refuses those by name.
304//!
305//! | Verb | Requests / points | Documents |
306//! | --- | --- | --- |
307//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
308//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
309//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS, then UPDATE_ISSUE last |
310//! | 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 |
311//! | 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 |
312//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
313//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
314//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
315//! | 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 |
316//! | recount | 1 | ISSUE_DETAIL |
317//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
318//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
319//! | content | 2 | ISSUE, UPDATE_ISSUE |
320//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
321//! | 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 |
322//! | record only | 1 | ISSUE |
323//!
324//! <!-- github-search-paging:start -->
325//! Board-scoped text, metadata, project-name and comment-activity searches send every
326//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
327//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
328//! needs rows. A page is never resized to the rows still needed: GitHub orders one
329//! search differently at different page sizes, so one fixed size makes a paged walk
330//! send exactly the requests one whole read sends, and the answer's order is the order
331//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
332//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
333//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
334//! returned and fetched pages: a limit is sliced from the pages it needs, and local
335//! confirmation can require more candidates than matching rows. Walking all pages
336//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
337//! cursor and how far into that page the last answer stopped, and resumes in the same
338//! process or a new one, without duplicates or gaps. It carries no rows: one process
339//! sends each page's search once, and a new process re-reads only the page it resumes
340//! in, then sends a further page once, never as a re-read, only when its limit still
341//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
342//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
343//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
344//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
345//! not required to include the original process's writes still omitted by the index.
346//! <!-- github-search-paging:end -->
347//!
348//! The board half of an issue — its board item's id, its `Status` option and this
349//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
350//! an item reached any of those ways resolves through the same
351//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
352//! same status, the same labels and the same qualified id. That connection comes back a
353//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
354//! on the page in hand and — only if that page reports more of the connection — in the
355//! last row's read of that one issue's memberships, resumed from the page's own cursor and
356//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
357//! report, which is what keeps an id naming another repository's issue from being answered
358//! as an item of this board; and because the page is where the search starts rather than
359//! where it ends, that answer is one about a connection read to exhaustion and never about
360//! an unread page. Nothing costs the extra read but an issue on more boards than a page
361//! holds: an issue this board really does not hold reports no next page, so its
362//! memberships are already exhausted where they arrived.
363//!
364//! **No document here selects the board's own `Labels` field, and nothing is lost by
365//! that.** An item's labels are read from its content alone, wherever that content is
366//! reached: the three documents above select `Issue.labels` on the fragment, and
367//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
368//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
369//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
370//! create one, and `ProjectV2FieldValue` — the whole of what
371//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
372//! it from the content, for every content type it exists on, and there is nothing it can
373//! hold that the content does not already say: for an `Issue` it *is* that issue's own
374//! labels, so selecting it beside them unions a set with itself.
375//!
376//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
377//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
378//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
379//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
380//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
381//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
382//! label set, and that set is the fixture issue's own, by
383//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
384//! item whose content is a draft reports an empty set, by
385//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
386//! selection is held over [`graphql::DOCUMENTS`] by
387//! `no_document_selects_the_boards_own_labels_field`.
388//!
389//! The whole-board row is still the board's own item connection, and deliberately: a
390//! **draft** board item is not an issue, so no search can list one, and the reads that have
391//! to answer for the whole board are the ones whose cost is the board's size anyway.
392//!
393//! **A question about one item this source already names by id never lists the board.**
394//! Whether that item is on this board, and what its board fields are, is answered by reading
395//! that item — its own `Issue.projectItems`, walked to exhaustion by
396//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
397//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
398//! holds. That covers a write's destination, the project a new item is filed under, a
399//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
400//! and the delete that takes back an item a copy made. What such a write needs of the board
401//! and the item does not carry — the board's id, the `Status` and origin field definitions —
402//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
403//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
404//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
405//! minutes. Scanning this host's 842-item board has refused a document copy and an update
406//! even though the items' own reads named that board. A scan there gives the wrong answer
407//! as well as paying for every page. So a `board.items` lookup does not belong on any of
408//! those paths.
409//!
410//! **What a read may return is capped too, and that cap is on the document rather than on
411//! the board.** GitHub limits the number of nodes **one query may return** to
412//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
413//! an error naming the connection the count crossed at, not a slow or a partial result.
414//! Every board this source reads is refused the same way, so no board is too big for these
415//! documents and none is small enough to save one that is over.
416//!
417//! The count is arithmetic over the document's own text: each connection contributes the
418//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
419//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
420//! restate them — `github-graphql-node-count` implements them, and
421//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
422//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
423//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
424//! same text on every run and fails naming any that reaches the limit — so a connection
425//! added to a shared fragment is caught there rather than by GitHub.
426//!
427//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
428//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
429//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
430//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
431//! effectively squared there, which is why it is the one the limit is most sensitive to.
432//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
433//! page of memberships misses is recovered by one further read rather than refused, so it
434//! buys a bound every read pays for at the price of a request only a multi-board issue
435//! pays.
436//!
437//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
438//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
439//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
440//! `cost` is rate-limit points, metered per hour across everything one credential does; it
441//! is what the two limiters [`Limiter`] tells apart meter, and a document under
442//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
443//! that second number, and `tests/point_cost.rs` pins every document in
444//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
445//! one under, the pin itself is the check. The credentialed lane reconciles both figures
446//! against GitHub's own, off a probe it already sends.
447//!
448//! **What is pinned that way is a per-document price and never a session's.** The record in
449//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
450//! **requests** and **worst-case nodes** — and neither is points. What one whole session
451//! consumes of the hourly point allowance is observable only from a credentialed run's own
452//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
453//! and what `tests/live.rs` prints at the end of every run.
454//!
455//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
456//!
457//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
458//! enumerations of a board can supply one alone.** Resolving a node id is strongly
459//! consistent, so a read by id and a project's own sub-issues are already current. The
460//! other two are not, and they are behind by different amounts and in different directions:
461//!
462//! - GitHub's **issue search** is an index and answers a write made moments ago with the
463//!   value from before it — usually for a second or two.
464//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
465//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
466//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
467//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
468//!
469//! That second one is a measurement rather than a caution. This repository's own
470//! credentialed journey writes a project and waits for the board to report it, then writes a
471//! task and waits for the same thing seconds later on the same board: the project wait is
472//! answered through the search and converged in two or three attempts in each of three runs,
473//! and the task wait is answered through `ProjectV2.items` and converged in none of them
474//! inside thirty. Separately, an item added to a second and larger board was read back by
475//! `Issue.projectItems` on that board's own id while every one of that connection's nine
476//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
477//! through the lagging one alone is what had a board read deny an issue that had certainly
478//! landed on it.
479//!
480//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
481//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
482//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
483//! search reports what the projection is behind on. What closes the last
484//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
485//! source answers is completed with what this process itself wrote, so an item created
486//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
487//! nothing is written down, and the record dies with the process. **A wait that has to
488//! observe GitHub's own data cannot be answered from that record** — which is why the
489//! credentialed journey asks through a source built afresh, and why the union above rather
490//! than a longer wait is what makes such a wait converge.
491//!
492//! **A narrowed read is the same bargain, stated for each of the three predicates it
493//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
494//! than walking the board, and every such answer is completed with what this process wrote —
495//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
496//! each filtered by the same predicates as the rest — so an item this command wrote a moment
497//! ago is returned by a query that matches it whether or not the index has caught up. An item
498//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
499//! consistent. What is left is stated rather than papered over:
500//!
501//! | Read | Finds | Behind by |
502//! | --- | --- | --- |
503//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
504//! | 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 |
505//! | 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 |
506//! | origin, third read | this process's own writes | nothing |
507//!
508//! So an origin carrier another process added within the last second or two, before either
509//! index has it, can be missing from an origin query, and one written by the release before
510//! this one — its origin in the field alone — can be missing for as long as the board's own
511//! item connection is behind on it. A copy that must not duplicate its own earlier write
512//! relies on the link it records, not on either index. **A board draft is not an issue**, so
513//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
514//! lists one, the origin lookup drops any the board's own field filter names, and one this
515//! process wrote is not added back either.
516//!
517//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
518//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
519//! body's metadata slot, so the issue search can find it in seconds. The field is
520//! authoritative: this source reads an item's origin from the field alone, so a slot that
521//! disagrees with it, or holds one where the field holds none, is never read as a second
522//! origin — and the release before this one reads the slot, drops that key's copy for the
523//! field's, and sees the same one origin.
524//!
525//! Filtering happens before paging, so a page of a filtered result is a page of the
526//! survivors rather than the survivors of a page. Label matching and the substring rule a
527//! text candidate is confirmed by answer the same question the same way the local Markdown
528//! source's do; which candidates a text search has to confirm is GitHub's word match, which
529//! is the one place the two sources can answer the same text differently.
530//!
531//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
532//! has one source, `capabilities`, and the note above is the reasoning behind it rather
533//! than a second copy of it: without the three facts recorded here a reader takes the
534//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
535//! crate's own capabilities test, which pins every field of it against a fully spelled-out
536//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
537//! fails to compile there rather than going unasserted. -->
538//! The fixture-server tests above run wherever this crate is selected; the credentialed
539//! lane runs in the same required check, beside them, and can fail it — it verifies the
540//! 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,
541//! one filed under neither, a label on one of the three and a closed status on another —
542//! because that shape is what tells an honoured predicate from an ignored one: a board
543//! holding a single project answers a project filter the same way whether or not this
544//! source applies it, which is exactly how the defect above went unseen.
545//!
546//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
547//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
548//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
549//! session or request, independently of the live demand. Fix that variable in the machine's
550//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
551//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
552//! policy crate; absent credentials or nominations otherwise skip.
553//!
554//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
555//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
556//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
557//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
558//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
559//! after its run reads back as `completed`, immediately before the listed-artifact batch.
560//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
561//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
562//! and the board's `onetaskgraph.origin` field untouched.
563//!
564//! # What a session of requests costs, and where the report is
565//!
566//! This source records **every** request it sends into [`accounting::Accounting`], at
567//! `send_once` — the one place a request leaves this crate, which is why a read path added
568//! later is counted without anybody remembering to count it. That is the whole of what this
569//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
570//! session's spend is arrived at, and what it deliberately does not know are set out.
571//!
572//! What one whole session of the live journey costs, counted that way against this crate's
573//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
574//! reduction it came out of, and with what it does and does not say about rate-limit points.
575//!
576//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
577//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
578//! code path — no environment variable, no feature, no build configuration — because an
579//! instrument nobody switches on measures nothing, and
580//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
581//! source's counts the whole session rather than this source's share. The credentialed lane
582//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
583//! passed or failed.
584//!
585//! **A live session refuses to start unless the account can afford it.** Before it does any
586//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
587//! GitHub documents as not counting against the REST rate limit and which answers both of
588//! its budgets at once — and starts only if, for each of them, what remains minus this
589//! session's estimated cost is still at least
590//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
591//! allowance. A session that cannot **declines**: it did not run, so it is
592//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
593//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
594//! waiting for the budget to come back. The estimate is derived offline from
595//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
596//! which is also where the published rule that model rests on is cited; the accounting
597//! above records the gate's own read like any other request, and
598//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
599//!
600//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
601//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
602//! text, which is what lets it run on every platform and on a pull request from a fork with
603//! no credential — and that is what actually stops a regression merging. But an offline
604//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
605//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
606//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
607//! number of nodes this query may return"* and whose `cost` is what that document would
608//! spend, both for a document **without executing it**, and the lane asks it for every query
609//! document this source sends, under the largest bindings this source sends, and fails when
610//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
611//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
612//! one; the offline pins still cover it. It records what those calls reported about the
613//! account's own allowance, because whether asking is free is a thing to observe rather than
614//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
615//! and `cost` is metered against an hourly allowance the accounting above reads off a
616//! credentialed run's own response headers.
617//!
618//! **GitHub has two rate limiters and this source is refused by both, so nothing here
619//! treats them as one thing.** The primary budget is the hourly allowance `gh api
620//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
621//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
622//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
623//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
624//! one part of.
625#![deny(missing_docs)]
626
627use std::collections::BTreeMap;
628use std::sync::{Arc, Mutex};
629use std::time::Duration;
630
631use chrono::{DateTime, Utc};
632use onetaskgraph_plugin_api::{
633    Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
634    ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
635    DependencySupport, Direction, Document, DocumentQuery, DocumentVersionCoverage, Health,
636    HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
637    MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
638    ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
639    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
640    StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
641    TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
642    WriteJournal, WriteSupport, system_clock,
643};
644use reqwest::{Client, StatusCode, Url};
645use schemars::{Schema, schema_for};
646use secrecy::{ExposeSecret, SecretString};
647use serde::{Deserialize, Serialize};
648use serde_json::{Value, json};
649
650pub mod accounting;
651mod assets;
652pub mod change_token;
653mod expected;
654pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
655mod visibility;
656
657use accounting::Accounting;
658
659/// The registry name for this plugin.
660pub const KIND: &str = "github-projects";
661/// GitHub's maximum connection page size.
662pub const MAX_PAGE_SIZE: u32 = 100;
663/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
664/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
665/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
666pub const SEARCH_PAGE_SIZE: u32 = 20;
667/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
668/// its comments: the largest batch the node-count model prices at one point.
669///
670/// Each aliased item is resolved once, and what GitHub charges for it is the connections
671/// under it — its labels, its page of board memberships, the field values of each of those
672/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
673/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
674/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
675/// one point and fails if one item more would still be priced at one.
676pub const DETAIL_BATCH: usize = 24;
677/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
678///
679/// Each reference asks GitHub for one connection — a page of its board memberships, each with
680/// its `Status` value read by name rather than through a connection of field values — so fifty
681/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
682/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
683/// issues per source. `tests/point_cost.rs` prices the document at one point.
684pub const REFERENCE_BATCH: usize = 50;
685/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
686/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
687/// point.
688///
689/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
690/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
691/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
692/// if one item more would still be priced at one.
693pub const COPY_DETAIL_BATCH: usize = 21;
694
695/// The most nodes any one document this source sends may be asked to return.
696///
697/// GitHub's own published per-query ceiling, taken from
698/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
699/// workspace cannot hold a stale copy of somebody else's number. A query above it is
700/// **refused before it is executed**, whoever is asking and whatever board they are
701/// asking about — so this is a bound on the documents rather than a budget that runs out.
702///
703/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
704/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
705/// everything the credential does — two numbers against two limits, and this constant
706/// bounds only the first. The second is computed offline too, per document:
707/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
708/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
709/// lane. There is no constant like this one to hold a price under, because points are an
710/// hourly allowance rather than a per-call bound.
711///
712/// Neither is a session's price. What `session-cost.md` records of a whole session is its
713/// **requests** and its **worst-case nodes**; what a whole session spends in points is
714/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
715/// [`accounting`]. The module section on the three ways this source reaches an item says how
716/// the count is arrived at, and which of the page sizes below decide it.
717pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
718
719/// Nested connection size for the connections that hang off one item.
720///
721/// It multiplies through every document that reaches an item under a page — the count
722/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
723/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
724/// every document under these constants and fails naming any that reaches the limit, so
725/// raising this is caught there rather than by GitHub.
726const NESTED_PAGE_SIZE: u32 = 50;
727/// How many of one issue's board memberships are read when an issue is reached directly.
728///
729/// An issue reached through a search or through its own node id carries its board half in
730/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
731/// under a page of issues, so every point of it multiplies through the whole document and
732/// is paid for whether or not any issue is on a second board — which is why it is
733/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
734///
735/// **Three, because what a page misses is now recovered rather than refused**, and the
736/// recovery is what the value is chosen against. An issue whose entry for this board sits
737/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
738/// that page's own cursor — so the value trades a bound every read pays for a request only
739/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
740/// boards would pay that request *per issue*, which is order N against the one page per
741/// hundred issues a read costs today. At three it is only reached by an issue on four or
742/// more boards at once, which keeps the recovery path exceptional rather than routine for
743/// a plausible deployment.
744const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
745/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
746/// its two connections for.
747///
748/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
749/// second is a duplicate a copy already takes the first of. Both connections are walked to
750/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
751/// never what the answer is. It is small because every point of it is paid on every lookup,
752/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
753/// worst-case nodes than the one whole-board read they replaced.
754const ORIGIN_PAGE_SIZE: u32 = 3;
755
756pub use github_graphql_node_count::{NodeCountError, Variables};
757
758/// The largest value this source can bind to each page-size variable its documents name.
759///
760/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
761/// constant above it wherever a caller's own limit could reach it — `$first` at
762/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
763/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
764/// not one configuration of it, which is what makes a bound computed under it a bound on
765/// every read.
766pub fn largest_page_sizes() -> Variables {
767    Variables::from([
768        ("first".to_owned(), MAX_PAGE_SIZE),
769        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
770        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
771        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
772    ])
773}
774
775/// The most nodes `document` could be asked to return, by GitHub's published rules.
776///
777/// Computed offline from the document's own text under [`largest_page_sizes`] — no
778/// network, no credential and no schema — by
779/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
780/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
781/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
782///
783/// # Errors
784///
785/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
786/// no single operation, or binds a page size this source does not name — each of which is
787/// a defect in the document rather than a number.
788pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
789    node_count(document, &largest_page_sizes())
790}
791
792/// The most rate-limit points one call of `document` could spend, by GitHub's published
793/// rules.
794///
795/// Computed offline from the document's own text under [`largest_page_sizes`] — no
796/// network, no credential and no schema — by
797/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
798/// This is `cost`, metered **per hour** against the allowance one credential shares across
799/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
800/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
801/// under, so what `tests/point_cost.rs` does with it is pin every document in
802/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
803/// figures against GitHub's own reported `cost`.
804///
805/// # Errors
806///
807/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
808/// no single operation, or binds a page size this source does not name — each of which is
809/// a defect in the document rather than a number.
810pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
811    github_graphql_node_count::point_cost(document, &largest_page_sizes())
812}
813
814/// The most nodes `document` could be asked to return under `variables`.
815///
816/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
817/// [`accounting`] is this under the bindings one request really sent — one spelling of the
818/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
819/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
820///
821/// # Errors
822///
823/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
824/// no single operation, or binds a page size `variables` does not name.
825pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
826    github_graphql_node_count::node_count(document, variables)
827}
828
829/// The issue-title prefix that makes a board issue a document.
830///
831/// A GitHub Projects board has no document type — it holds issues — so the discriminator
832/// is the title, and this is the whole of it: an issue whose title begins with these bytes
833/// is a document and every other issue is the task or project the sub-issue rule makes it.
834///
835/// It is spelled **once**, here, and read rather than restated everywhere else — including
836/// by the shared journeys, which take it from this constant so a board fixture cannot
837/// drift from what this source reads. `docs/metadata.md` records the two consequences that
838/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
839/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
840/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
841pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
842
843/// Exact GraphQL query documents issued by this plugin.
844///
845/// Keeping the production documents here lets the pinned-schema test validate the same
846/// bytes that are sent to GitHub, rather than a test-only copy which could drift
847/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
848/// field, and its guarded caller always supplies the complete existing option set with ids.
849pub mod graphql {
850    /// The board half of one item: the field values every document here reads it from.
851    ///
852    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
853    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
854    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
855    /// *the same value*, because
856    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
857    /// one path. Three spellings of it is what would drift, so there is one.
858    ///
859    /// The `Status` option and this source's own origin text field are the whole of it. It
860    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
861    /// content, so it holds nothing the content's own `labels` do not already say, and it
862    /// would sit a label connection two page sizes deep.
863    macro_rules! board_item_values {
864        () => {
865            r#"fieldValues(first:$nestedFirst){nodes{
866          ... on ProjectV2ItemFieldSingleSelectValue{name field{
867            ... on ProjectV2SingleSelectField{id name options{id name}}
868          }}
869          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
870        }pageInfo{hasNextPage}}"#
871        };
872    }
873
874    /// The variable every document reading an issue declares for the relationship events its
875    /// change token counts, with its value as the default, so no caller binds it.
876    ///
877    /// A variable rather than a literal list because every argument a production document
878    /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
879    /// its default to the pinned enum. Its default is the six event types GitHub records a
880    /// relationship added or removed with: a dependency on either end, as `blockedBy` and
881    /// `blocking`, so one a person makes from the far end moves this end's count too; and a
882    /// project membership on the task, as its parent, which GitHub records there whichever
883    /// issue the move was made from. A sub-issue's events on its parent are not counted: a
884    /// project carries no task token, and a copy writes nothing of a task its own sub-issues
885    /// decide. [`super::change_token`] says why the count is in the token.
886    macro_rules! relationship_events_variable {
887        () => {
888            ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
889        };
890    }
891
892    /// Everything this source reads about one issue, wherever it reaches that issue.
893    ///
894    /// A macro rather than a constant so the three documents below can `concat!` it: one
895    /// spelling of these fields is what makes an issue read through the board-scoped
896    /// search, through its own node id, and through its project's sub-issue relationship
897    /// resolve to *the same* item, which is the whole of what
898    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
899    ///
900    /// `projectItems` is what carries the board half of an issue: the board item's own id
901    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
902    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
903    /// issue rather than on the board, which is what makes the cost of a read proportional
904    /// to what was asked for instead of to the board's size.
905    ///
906    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
907    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
908    /// not on that page: a page here is where the search for the entry starts rather than
909    /// where it ends.
910    ///
911    /// It does **not** select the board's `Labels` field value, and that is the whole of
912    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
913    /// a label connection there sits under `fieldValues` under `projectItems` under a page
914    /// of issues, spending `$nestedFirst` twice down one path, and took
915    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
916    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
917    /// above, and that connection is where every label this source reports comes from. No
918    /// document in this module selects the board field any longer, [`BOARD`] included; the
919    /// module documentation records why nothing it could have held is lost.
920    macro_rules! board_issue {
921        () => {
922            concat!(
923                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
924      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
925      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
926        "#,
927                board_item_values!(),
928                r#"}pageInfo{hasNextPage endCursor}}}"#
929            )
930        };
931    }
932
933    /// Every issue of one board, found by a search scoped to that board.
934    ///
935    /// This is how the projects a board holds are listed, and it selects no `items`
936    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
937    /// container walked page by page, so nothing nested inside a board item is paid for.
938    /// Which of the issues it returns is a project is then read off `parent` — GitHub
939    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
940    /// discriminator has to be applied to the field, which is a scalar on the issue and
941    /// costs nothing.
942    pub const SEARCH_ISSUES: &str = concat!(
943        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
944        relationship_events_variable!(),
945        r#"){
946      search(query:$search,type:$type,first:$first,after:$after){
947        pageInfo{hasNextPage endCursor}
948        nodes{__typename ...BoardIssue}
949      }
950    }"#,
951        board_issue!()
952    );
953
954    /// What a dependency read selects of each far end: enough to say which kind of item it
955    /// is, its body included for the kind marker.
956    macro_rules! related_issue {
957        () => {
958            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
959        };
960    }
961
962    /// One issue by its own node id, which is what a qualified id names here — with what a
963    /// write of it needs and the issue does not carry in `board_issue!`: the field
964    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
965    ///
966    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
967    /// answers a write made moments ago with the value from before it, and resolving a node
968    /// id does not.
969    ///
970    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
971    /// reads it by its own id, and with them that one read answers everything the write
972    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
973    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
974    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
975    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
976    /// they sit under one item, and this read is still one point.
977    pub const ISSUE: &str = concat!(
978        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
979        relationship_events_variable!(),
980        r#"){
981      node(id:$id){__typename ...BoardIssue ... on Issue{
982        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
983          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
984          ... on ProjectV2Field{__typename id name dataType}
985        }pageInfo{hasNextPage}}}}}
986        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
987      }}
988    }"#,
989        board_issue!(),
990        related_issue!()
991    );
992
993    /// One project's tasks: the sub-issues of the issue that project is, each with a page of
994    /// what blocks it.
995    ///
996    /// The work this costs is the project's own size. Nothing about it grows as the board
997    /// gains projects, or as those projects gain tasks.
998    ///
999    /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1000    /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1001    /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1002    /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1003    /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1004    /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1005    /// It adds a connection under each issue of the page — one rate-limit point per page,
1006    /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1007    /// answer questions that never read an edge.
1008    ///
1009    /// **The project issue itself rides here too**, with the page of what blocks it, because
1010    /// the reader of a project's tasks reads the project next: a copy writing the project reads
1011    /// it and its edges before it writes them. Carried on the request that already names the
1012    /// project, the first page answers that read and the dependency read after it, at the
1013    /// state GitHub answered in this command, rather than spending one more read on an item
1014    /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1015    /// copy's read of a task this listing carried is answered by it rather than sent again.
1016    /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1017    pub const SUB_ISSUES: &str = concat!(
1018        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1019        relationship_events_variable!(),
1020        r#"){
1021      node(id:$id){__typename ...BoardIssue
1022        ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1023          subIssues(first:$first,after:$after){
1024          pageInfo{hasNextPage endCursor}
1025          nodes{__typename ...BoardIssue ... on Issue{
1026            blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1027          }}
1028        }}}
1029    }"#,
1030        board_issue!(),
1031        related_issue!()
1032    );
1033
1034    /// What a read of the board's own `items` selects of each item's content.
1035    ///
1036    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1037    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1038    /// content by one spelling.
1039    macro_rules! board_item_content {
1040        () => {
1041            r#" content{
1042        ... 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}}}
1043        ... on PullRequest{__typename id}
1044        ... on DraftIssue{__typename id title body createdAt updatedAt}
1045      }"#
1046        };
1047    }
1048
1049    /// Reads the board's fields and one page of its items.
1050    pub const BOARD: &str = concat!(
1051        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1052        relationship_events_variable!(),
1053        r#"){
1054      owner:repositoryOwner(login:$owner){
1055        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1056      }
1057    } fragment Board on ProjectV2 { id title
1058      fields(first:$nestedFirst){nodes{
1059        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1060        ... on ProjectV2Field{__typename id name dataType}
1061      }pageInfo{hasNextPage}}
1062      items(first:$first,after:$after){nodes{id updatedAt "#,
1063        board_item_values!(),
1064        board_item_content!(),
1065        r#"} pageInfo{hasNextPage endCursor}}
1066    }"#
1067    );
1068
1069    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1070    /// the board.
1071    ///
1072    /// **`originItems`** is the board's own items narrowed by its own field filter —
1073    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1074    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1075    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1076    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1077    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1078    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1079    /// fresh `addProjectV2ItemById` the way that connection does.
1080    ///
1081    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1082    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1083    /// indexes that comment, and the index catches up with a write in a second or two rather
1084    /// than in minutes, so it finds a carrier another process wrote that the first read is
1085    /// still behind on.
1086    ///
1087    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1088    /// — and resumes from its own cursor; a connection already walked to its end is resumed
1089    /// from its last cursor, which answers an empty page. Every candidate either read returns
1090    /// is confirmed against its own origin field before it is reported, so a token match of
1091    /// the search or anything else the filter admits never is.
1092    ///
1093    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1094    /// own whole reads counts this one among them.
1095    pub const ORIGIN_LOOKUP: &str = concat!(
1096        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1097        relationship_events_variable!(),
1098        r#"){
1099      originItems:repositoryOwner(login:$owner){
1100        ... on ProjectV2Owner{projectV2(number:$number){
1101          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1102        board_item_values!(),
1103        board_item_content!(),
1104        r#"} pageInfo{hasNextPage endCursor}}
1105        }}
1106      }
1107      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1108        pageInfo{hasNextPage endCursor}
1109        nodes{__typename ...BoardIssue}
1110      }
1111    }"#,
1112        board_issue!()
1113    );
1114
1115    /// Every closed item of the board, by the board's own field filter — what
1116    /// `task status reconcile` reads, and nothing else of the board.
1117    ///
1118    /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1119    /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1120    /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1121    /// closed, with a reason and a card that contradict each other, from the item node it
1122    /// already carries, so a filter that also let open items through would cost reads and
1123    /// write nothing wrong.
1124    ///
1125    /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1126    /// option writes that follow need no read of the board. The root is aliased `closedItems`
1127    /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1128    /// among them.
1129    pub const CLOSED_ITEMS: &str = concat!(
1130        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1131        relationship_events_variable!(),
1132        r#"){
1133      closedItems:repositoryOwner(login:$owner){
1134        ... on ProjectV2Owner{projectV2(number:$number){id
1135          fields(first:$nestedFirst){nodes{
1136            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1137            ... on ProjectV2Field{__typename id name dataType}
1138          }pageInfo{hasNextPage}}
1139          items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1140        board_item_values!(),
1141        board_item_content!(),
1142        r#"} pageInfo{hasNextPage endCursor}}
1143        }}
1144      }
1145    }"#
1146    );
1147
1148    /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1149    macro_rules! reference_alias {
1150        ($n:literal) => {
1151            concat!(
1152                "\n      r",
1153                stringify!($n),
1154                ":repository(owner:$owner",
1155                stringify!($n),
1156                ",name:$name",
1157                stringify!($n),
1158                "){issue(number:$number",
1159                stringify!($n),
1160                "){...HeldIssue}}"
1161            )
1162        };
1163    }
1164
1165    /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1166    /// number — the one read `task status complete` makes of a source, and nothing else of
1167    /// the board: no `items`, no search and no project read.
1168    ///
1169    /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1170    /// references one point.** It selects no labels, and of each board membership only the
1171    /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1172    /// connection, its page of memberships, where the whole fragment asks for five. What a
1173    /// completion writes is a card and, at most, the issue's state, so the labels and the other
1174    /// field values it leaves out are never what it needs; an item read this way is held for
1175    /// that write alone and never answers another read. An issue whose entry for this board is
1176    /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1177    /// every other read of an issue recovers one.
1178    ///
1179    /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1180    /// fewer references binds the slots it has none for to its last one.
1181    pub const ISSUES_BY_REFERENCE: &str = concat!(
1182        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!"#,
1183        relationship_events_variable!(),
1184        r#"){"#,
1185        reference_alias!(0),
1186        reference_alias!(1),
1187        reference_alias!(2),
1188        reference_alias!(3),
1189        reference_alias!(4),
1190        reference_alias!(5),
1191        reference_alias!(6),
1192        reference_alias!(7),
1193        reference_alias!(8),
1194        reference_alias!(9),
1195        reference_alias!(10),
1196        reference_alias!(11),
1197        reference_alias!(12),
1198        reference_alias!(13),
1199        reference_alias!(14),
1200        reference_alias!(15),
1201        reference_alias!(16),
1202        reference_alias!(17),
1203        reference_alias!(18),
1204        reference_alias!(19),
1205        reference_alias!(20),
1206        reference_alias!(21),
1207        reference_alias!(22),
1208        reference_alias!(23),
1209        reference_alias!(24),
1210        reference_alias!(25),
1211        reference_alias!(26),
1212        reference_alias!(27),
1213        reference_alias!(28),
1214        reference_alias!(29),
1215        reference_alias!(30),
1216        reference_alias!(31),
1217        reference_alias!(32),
1218        reference_alias!(33),
1219        reference_alias!(34),
1220        reference_alias!(35),
1221        reference_alias!(36),
1222        reference_alias!(37),
1223        reference_alias!(38),
1224        reference_alias!(39),
1225        reference_alias!(40),
1226        reference_alias!(41),
1227        reference_alias!(42),
1228        reference_alias!(43),
1229        reference_alias!(44),
1230        reference_alias!(45),
1231        reference_alias!(46),
1232        reference_alias!(47),
1233        reference_alias!(48),
1234        reference_alias!(49),
1235        "\n    }",
1236        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}
1237      projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1238        status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1239          ... on ProjectV2SingleSelectField{id name options{id name}}
1240        }}}
1241      }pageInfo{hasNextPage endCursor}}}"#
1242    );
1243
1244    /// The board's own id and field definitions, and not one of its items.
1245    ///
1246    /// What a write needs of the board when the item it writes does not say: the id a field
1247    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1248    /// origin fields. It selects no `items`, so what it costs is the board's field list
1249    /// however many items the board holds — and it decides nothing about which items those
1250    /// are, which is the question a read of one item by its own id answers instead.
1251    ///
1252    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1253    /// board's item reads by their root counts this one among them.
1254    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1255      boardFields:repositoryOwner(login:$owner){
1256        ... on ProjectV2Owner{projectV2(number:$number){id
1257          fields(first:$nestedFirst){nodes{
1258            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1259            ... on ProjectV2Field{__typename id name dataType}
1260          }pageInfo{hasNextPage}}
1261        }}
1262      }
1263    }"#;
1264
1265    /// One board draft by its own node id, with the board item it sits in.
1266    ///
1267    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1268    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1269    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1270    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1271    /// board listing hands it to, and nothing has to list the board to find one.
1272    pub const DRAFT: &str = concat!(
1273        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1274      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1275        projectV2Items(first:$boardItems){nodes{id project{id number}
1276        "#,
1277        board_item_values!(),
1278        r#"}pageInfo{hasNextPage endCursor}}}}
1279    }"#
1280    );
1281
1282    /// One issue's board memberships alone, walked past the page a read of it carried.
1283    ///
1284    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1285    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1286    /// boards than that page holds may have this board's entry past its end. This asks that
1287    /// one issue for its memberships and nothing else — the caller already holds the issue —
1288    /// so an answer of "this board does not hold it" is only ever given about a connection
1289    /// read to exhaustion.
1290    ///
1291    /// It selects the board item's id, its project number and the same
1292    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1293    /// very same resolver: an issue recovered this way reports the same title, the same
1294    /// status, the same labels and the same qualified id as one whose entry was on the
1295    /// page.
1296    ///
1297    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1298    /// multiplies through it and the membership connection can be walked at
1299    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1300    /// further request for any issue a person really keeps.
1301    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1302        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1303      node(id:$id){
1304        ... on Issue{projectItems(first:$first,after:$after){
1305          nodes{id updatedAt project{id number}
1306        "#,
1307        board_item_values!(),
1308        r#"}
1309          pageInfo{hasNextPage endCursor}}}
1310      }
1311    }"#
1312    );
1313    /// Whether the board's Project is public — half of what decides whether a write here can
1314    /// be read by anybody. Needs the `read:project` scope.
1315    pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1316    /// Resolves the configured repository's node id, which creating an issue requires.
1317    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1318    /// What creating an issue needs and has not read yet: the board's own id and field
1319    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1320    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1321    ///
1322    /// Sent at the point a create knows which repository it is for, when neither half is
1323    /// already known to this process; a create needing only one of them sends that one's own
1324    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1325    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1326    /// status.
1327    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1328      boardFields:repositoryOwner(login:$owner){
1329        ... on ProjectV2Owner{projectV2(number:$number){id
1330          fields(first:$nestedFirst){nodes{
1331            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1332            ... on ProjectV2Field{__typename id name dataType}
1333          }pageInfo{hasNextPage}}
1334        }}
1335      }
1336      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1337    }"#;
1338    /// Reads both dependency directions for one issue, with each far end's own kind — and
1339    /// the issue's own body, which is where an edge to another source is recorded, so that
1340    /// half of a dependency read needs no second read of the issue or of the board.
1341    pub const ISSUE_DEPENDENCIES: &str = concat!(
1342        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1343      ... on Issue{body
1344        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1345        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1346      }}}"#,
1347        related_issue!()
1348    );
1349    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1350    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1351    /// answered when it was.
1352    pub const CREATE_ISSUE: &str =
1353        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1354    /// Puts an existing issue on the configured board.
1355    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1356    /// Updates an issue's visible fields and its open or closed state in one call.
1357    pub const UPDATE_ISSUE: &str =
1358        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1359    /// Updates an existing draft's user-visible fields.
1360    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1361    /// Updates a text or single-select value on one project item.
1362    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}}}}}}}}"#;
1363    /// Writes up to six board fields and clears up to three in one ordered mutation: every
1364    /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1365    /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1366    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}}}"#;
1367    /// Clears one project item's value of one field, which is what a `none` priority is.
1368    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}}}}}}}}"#;
1369    /// Creates one field: a single-select one with its options, or a text one for a projected
1370    /// metadata value. Only the guarded field setup may use this document, and only for a
1371    /// field the board lacks.
1372    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1373    /// Replaces a single-select field's options. Only the guarded field setup — the
1374    /// `status-options` and `fields` operations — may use this document, because GitHub
1375    /// treats the input as the complete option list.
1376    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1377    /// A fresh snapshot of the Status field and every board item's assignment.
1378    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}}}}}}"#;
1379    /// Files one issue under another as a sub-issue, which is what project membership is.
1380    pub const ADD_SUB_ISSUE: &str =
1381        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1382    /// Takes one issue back out of its parent.
1383    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1384    /// Adds GitHub's native issue blocked-by relationship.
1385    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1386    /// Removes one native issue blocked-by relationship.
1387    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1388    /// Deletes one issue, which takes its board item with it.
1389    ///
1390    /// The engine sends this in one situation only: undoing a copy that could not finish,
1391    /// over the items that same copy created. Deleting the issue removes the board item
1392    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1393    pub const DELETE_ISSUE: &str =
1394        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1395
1396    /// Everything this source reads about one issue comment, wherever it reaches one.
1397    ///
1398    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1399    /// and a comment just edited are handed to one mapper, so they are selected by one
1400    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1401    /// longer exists, and `login` is the one member every kind of actor carries.
1402    macro_rules! issue_comment {
1403        () => {
1404            "id author{login} createdAt updatedAt body url"
1405        };
1406    }
1407
1408    /// One task's comments: a page of its issue's own `comments` connection.
1409    ///
1410    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1411    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1412    /// list every time somebody edited it; left unordered the connection answers in the order
1413    /// the comments were written, which is the order GitHub documents for the same collection
1414    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1415    /// node count and the caller's own page size is pushed straight down.
1416    pub const ISSUE_COMMENTS: &str = concat!(
1417        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1418        issue_comment!(),
1419        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1420    );
1421    /// One issue by its own node id, with a page of its comments: what `task show` and a
1422    /// comment listing read, in one request.
1423    ///
1424    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1425    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1426    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1427    /// connection there would multiply through both of those documents' price, and neither
1428    /// needs one.
1429    pub const ISSUE_DETAIL: &str = concat!(
1430        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1431        relationship_events_variable!(),
1432        r#"){
1433      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1434        issue_comment!(),
1435        r#"}pageInfo{hasNextPage endCursor}}}}
1436    }"#,
1437        board_issue!()
1438    );
1439
1440    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1441    /// page of its comments when `$comments` asks for them.
1442    macro_rules! issue_details_alias {
1443        ($n:literal) => {
1444            concat!(
1445                "\n      i",
1446                stringify!($n),
1447                ":node(id:$id",
1448                stringify!($n),
1449                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1450                issue_comment!(),
1451                "}pageInfo{hasNextPage endCursor}}}}"
1452            )
1453        };
1454    }
1455
1456    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1457    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1458    ///
1459    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1460    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1461    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1462    /// neither — so every connection under it would be priced at nothing and the pin in
1463    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1464    /// one-item read the model already prices, so the batch costs what its aliases cost.
1465    ///
1466    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1467    /// slots it has no item for to the last item it does, and reads that item again; the
1468    /// price is the document's, whatever its variables, so a short batch costs what a full
1469    /// one does and nothing more.
1470    pub const ISSUE_DETAILS: &str = concat!(
1471        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!"#,
1472        relationship_events_variable!(),
1473        r#"){"#,
1474        issue_details_alias!(0),
1475        issue_details_alias!(1),
1476        issue_details_alias!(2),
1477        issue_details_alias!(3),
1478        issue_details_alias!(4),
1479        issue_details_alias!(5),
1480        issue_details_alias!(6),
1481        issue_details_alias!(7),
1482        issue_details_alias!(8),
1483        issue_details_alias!(9),
1484        issue_details_alias!(10),
1485        issue_details_alias!(11),
1486        issue_details_alias!(12),
1487        issue_details_alias!(13),
1488        issue_details_alias!(14),
1489        issue_details_alias!(15),
1490        issue_details_alias!(16),
1491        issue_details_alias!(17),
1492        issue_details_alias!(18),
1493        issue_details_alias!(19),
1494        issue_details_alias!(20),
1495        issue_details_alias!(21),
1496        issue_details_alias!(22),
1497        issue_details_alias!(23),
1498        "\n    }",
1499        board_issue!()
1500    );
1501
1502    /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
1503    /// page of its comments and the issues it is blocked by.
1504    macro_rules! issue_copy_details_alias {
1505        ($n:literal) => {
1506            concat!(
1507                "\n      i",
1508                stringify!($n),
1509                ":node(id:$id",
1510                stringify!($n),
1511                "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
1512                issue_comment!(),
1513                "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
1514            )
1515        };
1516    }
1517
1518    /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
1519    /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
1520    /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
1521    /// at a time, so a copy of a changed task costs this read and nothing else.
1522    ///
1523    /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
1524    /// what `task show-many` is priced by and which reads no edge.
1525    pub const ISSUE_COPY_DETAILS: &str = concat!(
1526        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!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1527        relationship_events_variable!(),
1528        r#"){"#,
1529        issue_copy_details_alias!(0),
1530        issue_copy_details_alias!(1),
1531        issue_copy_details_alias!(2),
1532        issue_copy_details_alias!(3),
1533        issue_copy_details_alias!(4),
1534        issue_copy_details_alias!(5),
1535        issue_copy_details_alias!(6),
1536        issue_copy_details_alias!(7),
1537        issue_copy_details_alias!(8),
1538        issue_copy_details_alias!(9),
1539        issue_copy_details_alias!(10),
1540        issue_copy_details_alias!(11),
1541        issue_copy_details_alias!(12),
1542        issue_copy_details_alias!(13),
1543        issue_copy_details_alias!(14),
1544        issue_copy_details_alias!(15),
1545        issue_copy_details_alias!(16),
1546        issue_copy_details_alias!(17),
1547        issue_copy_details_alias!(18),
1548        issue_copy_details_alias!(19),
1549        issue_copy_details_alias!(20),
1550        "\n    }",
1551        board_issue!(),
1552        related_issue!()
1553    );
1554
1555    /// Which issue one comment is on, read before that comment is edited or removed.
1556    ///
1557    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1558    /// comment id given against the wrong task would change a comment on another issue.
1559    pub const COMMENT_ISSUE: &str =
1560        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
1561    /// Adds one comment to an issue, signed as the account the token belongs to.
1562    pub const ADD_COMMENT: &str = concat!(
1563        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1564        issue_comment!(),
1565        r#"}}}}"#
1566    );
1567    /// Replaces the body of one issue comment.
1568    pub const UPDATE_COMMENT: &str = concat!(
1569        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1570        issue_comment!(),
1571        r#"}}}"#
1572    );
1573    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1574    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1575
1576    /// What a mutation of a write the store's cache expects selects of the issue it wrote:
1577    /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
1578    /// change token's every part and the first page of its comments.
1579    ///
1580    /// Sent **only** while that write runs, in place of the document named beside each
1581    /// below, so every other write's documents, prices and request counts are what they
1582    /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
1583    /// mutation's response is stamped seconds after the change it made, so against GitHub
1584    /// itself a response rarely determines a version; the cache is then left unserveable
1585    /// for the task, and its next read refreshes it.
1586    macro_rules! whole_issue {
1587        () => {
1588            concat!(
1589                r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
1590                issue_comment!(),
1591                r#"}pageInfo{hasNextPage endCursor}}}"#
1592            )
1593        };
1594    }
1595
1596    /// The variables every document of `whole_issue!` declares beside its own.
1597    macro_rules! whole_variables {
1598        () => {
1599            concat!(
1600                r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1601                relationship_events_variable!()
1602            )
1603        };
1604    }
1605
1606    /// [`ADD_COMMENT`], selecting the whole issue commented on.
1607    pub const ADD_COMMENT_WHOLE: &str = concat!(
1608        r#"mutation($input:AddCommentInput!"#,
1609        whole_variables!(),
1610        r#"){addComment(input:$input){subject{id "#,
1611        whole_issue!(),
1612        r#"} commentEdge{node{"#,
1613        issue_comment!(),
1614        r#"}}}}"#,
1615        board_issue!()
1616    );
1617    /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
1618    pub const UPDATE_COMMENT_WHOLE: &str = concat!(
1619        r#"mutation($input:UpdateIssueCommentInput!"#,
1620        whole_variables!(),
1621        r#"){updateIssueComment(input:$input){issueComment{"#,
1622        issue_comment!(),
1623        r#" issue{"#,
1624        whole_issue!(),
1625        r#"}}}}"#,
1626        board_issue!()
1627    );
1628    /// [`UPDATE_ISSUE`], selecting the whole issue updated.
1629    pub const UPDATE_ISSUE_WHOLE: &str = concat!(
1630        r#"mutation($input:UpdateIssueInput!"#,
1631        whole_variables!(),
1632        r#"){updateIssue(input:$input){issue{id "#,
1633        whole_issue!(),
1634        r#"}}}"#,
1635        board_issue!()
1636    );
1637    /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
1638    pub const UPDATE_FIELD_WHOLE: &str = concat!(
1639        r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1640        whole_variables!(),
1641        r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1642        whole_issue!(),
1643        r#"}}}}"#,
1644        board_issue!()
1645    );
1646    /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
1647    pub const CLEAR_FIELD_WHOLE: &str = concat!(
1648        r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1649        whole_variables!(),
1650        r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1651        whole_issue!(),
1652        r#"}}}}"#,
1653        board_issue!()
1654    );
1655
1656    /// The document sent in `document`'s place while a write the store's cache expects runs,
1657    /// when it has one.
1658    #[must_use]
1659    pub fn whole_twin(document: &str) -> Option<&'static str> {
1660        [
1661            (ADD_COMMENT, ADD_COMMENT_WHOLE),
1662            (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
1663            (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
1664            (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
1665            (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
1666        ]
1667        .into_iter()
1668        .find(|(plain, _)| *plain == document)
1669        .map(|(_, whole)| whole)
1670    }
1671
1672    /// Every document above, with what this source is doing when it sends one.
1673    ///
1674    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1675    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1676    /// document added later with "talking to GitHub" and never say so.
1677    ///
1678    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1679    /// const` here that this list omits, so the two cannot part — which is the same guard
1680    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1681    pub const DOCUMENTS: [(&str, &str); 42] = [
1682        (SEARCH_ISSUES, "searching this board's issues"),
1683        (ISSUE, "reading one issue"),
1684        (
1685            ISSUE_BOARD_ITEMS,
1686            "reading one issue's board memberships past the page it came with",
1687        ),
1688        (SUB_ISSUES, "reading a project's tasks"),
1689        (BOARD, "reading the board"),
1690        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1691        (CLOSED_ITEMS, "reading the board's closed items"),
1692        (
1693            ISSUES_BY_REFERENCE,
1694            "reading a batch of issues by their references",
1695        ),
1696        (BOARD_FIELDS, "reading the board's fields"),
1697        (DRAFT, "reading one draft"),
1698        (REPOSITORY, "reading the destination repository"),
1699        (
1700            PROJECT_VISIBILITY,
1701            "reading whether the board's project is public",
1702        ),
1703        (
1704            CREATION_CONTEXT,
1705            "reading the board's fields and the destination repository",
1706        ),
1707        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1708        (CREATE_ISSUE, "creating an issue"),
1709        (ADD_TO_BOARD, "adding an issue to the board"),
1710        (UPDATE_ISSUE, "updating an issue"),
1711        (UPDATE_DRAFT, "updating a draft item"),
1712        (UPDATE_FIELD, "writing a board field"),
1713        (UPDATE_FIELDS, "writing board fields together"),
1714        (CLEAR_FIELD, "clearing a board field"),
1715        (
1716            CREATE_FIELD,
1717            "creating a board single-select field with its options",
1718        ),
1719        (
1720            STATUS_OPTIONS_SNAPSHOT,
1721            "snapshotting board Status options and assignments",
1722        ),
1723        (
1724            STATUS_OPTIONS_UPDATE,
1725            "safely replacing the board Status option list",
1726        ),
1727        (ADD_SUB_ISSUE, "filing an issue under its project"),
1728        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1729        (ADD_BLOCKED_BY, "recording a dependency"),
1730        (REMOVE_BLOCKED_BY, "removing a dependency"),
1731        (DELETE_ISSUE, "deleting an issue"),
1732        (ISSUE_COMMENTS, "reading a task's comments"),
1733        (ISSUE_DETAIL, "reading one issue with its comments"),
1734        (
1735            ISSUE_DETAILS,
1736            "reading a batch of issues with their comments",
1737        ),
1738        (
1739            ISSUE_COPY_DETAILS,
1740            "reading a batch of issues with their comments and dependencies for a copy",
1741        ),
1742        (COMMENT_ISSUE, "reading which issue a comment is on"),
1743        (ADD_COMMENT, "adding a comment"),
1744        (UPDATE_COMMENT, "editing a comment"),
1745        (DELETE_COMMENT, "deleting a comment"),
1746        (
1747            ADD_COMMENT_WHOLE,
1748            "adding a comment, answered with the whole issue",
1749        ),
1750        (
1751            UPDATE_COMMENT_WHOLE,
1752            "editing a comment, answered with the whole issue",
1753        ),
1754        (
1755            UPDATE_ISSUE_WHOLE,
1756            "updating an issue, answered with it whole",
1757        ),
1758        (
1759            UPDATE_FIELD_WHOLE,
1760            "writing a board field, answered with the whole issue",
1761        ),
1762        (
1763            CLEAR_FIELD_WHOLE,
1764            "clearing a board field, answered with the whole issue",
1765        ),
1766    ];
1767}
1768
1769/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
1770/// under, the variable carrying its input, and the boolean variable that includes it.
1771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1772pub struct FieldSlot {
1773    /// The key of the answer's `data` this field's payload is under.
1774    pub alias: &'static str,
1775    /// The variable carrying this field's input.
1776    pub variable: &'static str,
1777    /// The boolean variable whose `true` sends this field.
1778    pub include: &'static str,
1779}
1780
1781/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
1782pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
1783    FieldSlot {
1784        alias: "updateProjectV2ItemFieldValue",
1785        variable: "input",
1786        include: "writeFirst",
1787    },
1788    FieldSlot {
1789        alias: "second",
1790        variable: "second",
1791        include: "writeSecond",
1792    },
1793    FieldSlot {
1794        alias: "third",
1795        variable: "third",
1796        include: "writeThird",
1797    },
1798    FieldSlot {
1799        alias: "fourth",
1800        variable: "fourth",
1801        include: "writeFourth",
1802    },
1803    FieldSlot {
1804        alias: "fifth",
1805        variable: "fifth",
1806        include: "writeFifth",
1807    },
1808    FieldSlot {
1809        alias: "sixth",
1810        variable: "sixth",
1811        include: "writeSixth",
1812    },
1813];
1814
1815/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
1816pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
1817    FieldSlot {
1818        alias: "cleared",
1819        variable: "clear",
1820        include: "writeClear",
1821    },
1822    FieldSlot {
1823        alias: "clearedSecond",
1824        variable: "clearSecond",
1825        include: "writeClearSecond",
1826    },
1827    FieldSlot {
1828        alias: "clearedThird",
1829        variable: "clearThird",
1830        include: "writeClearThird",
1831    },
1832];
1833
1834/// Which of GitHub's two rate limiters refused a request.
1835///
1836/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1837/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1838/// the whole reason this is carried rather than collapsed into "rate limited".
1839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1840enum Limiter {
1841    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1842    Primary,
1843    /// The burst limiter over content-generating requests, which nothing reports.
1844    Secondary,
1845}
1846
1847/// The wordings GitHub answers a secondary rate limit with.
1848///
1849/// It sends them under a forbidden status, under a too-many-requests status, and inside
1850/// the `errors` of a *successful* response, which is why the text is what this matches on
1851/// rather than the status. `abuse detection` is the wording GitHub used before the
1852/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1853/// what a burst of content creation is refused with.
1854///
1855/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1856/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1857/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1858/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1859pub const SECONDARY_WORDINGS: [&str; 5] = [
1860    "secondary rate limit",
1861    "temporarily blocked from content creation",
1862    "abuse detection",
1863    "submitted too quickly",
1864    "exceeded a secondary",
1865];
1866
1867/// The wordings GitHub answers an exhausted primary budget with.
1868///
1869/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1870/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1871/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1872/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1873/// two phrases is a substring of it, so without it that answer read as a refusal that will
1874/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1875/// one reason.
1876pub const PRIMARY_WORDINGS: [&str; 4] = [
1877    "api rate limit exceeded",
1878    "api rate limit already exceeded",
1879    "rate limit exceeded",
1880    "rate_limited",
1881];
1882
1883/// What a response *says about itself*, which is the only place a refusal can be read.
1884///
1885/// Deliberately not the whole response body. A board is a place people write about their
1886/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1887/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1888/// reported. So the item data is never read: what is read is GitHub's own REST-style
1889/// `message` envelope, which is what a forbidden status carries, and the `message` and
1890/// `type` of each GraphQL error, which is where a *successful* response says it.
1891///
1892/// A body that is not JSON at all has nothing structured to read, so only a failing
1893/// response's own text is taken — a successful response that is not JSON is malformed
1894/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1895fn refusal_wording(status: StatusCode, body: &str) -> String {
1896    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1897        return if status.is_success() {
1898            String::new()
1899        } else {
1900            body.to_owned()
1901        };
1902    };
1903    let mut said: Vec<&str> = parsed
1904        .get("message")
1905        .and_then(Value::as_str)
1906        .into_iter()
1907        .collect();
1908    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1909        for error in errors {
1910            said.extend(
1911                ["message", "type"]
1912                    .into_iter()
1913                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1914            );
1915        }
1916    }
1917    said.join("; ")
1918}
1919
1920impl Limiter {
1921    /// Which limiter refused this response, or `None` when none of them did.
1922    ///
1923    /// The wording is read first and the status only decides what carries none of it,
1924    /// because GitHub answers a secondary limit with a forbidden status far more often
1925    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1926    /// really is a credential this token lacks.
1927    ///
1928    /// A response is a refusal because of its status or its own wording. A spent budget
1929    /// only ever explains one; it never turns an answer into a refusal.
1930    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1931        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1932        if SECONDARY_WORDINGS
1933            .iter()
1934            .any(|wording| normalized.contains(wording))
1935        {
1936            return Some(Self::Secondary);
1937        }
1938        if status == StatusCode::TOO_MANY_REQUESTS {
1939            return Some(Self::Primary);
1940        }
1941        // An exhausted budget *explains* a response that failed; it does not make one that
1942        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1943        // request the budget allowed as well as on the ones it then refuses, so reading
1944        // the header alone threw away a good answer — and, once refusals were retried,
1945        // replayed a request that had already taken effect.
1946        if !status.is_success() && budget_exhausted {
1947            return Some(Self::Primary);
1948        }
1949        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1950        // `errors` of an HTTP 200, where nothing about the status says so at all.
1951        if status.is_success()
1952            && PRIMARY_WORDINGS
1953                .iter()
1954                .any(|wording| normalized.contains(wording))
1955        {
1956            return Some(Self::Primary);
1957        }
1958        None
1959    }
1960
1961    /// What this limiter is called where an operator can look it up.
1962    const fn name(self) -> &'static str {
1963        match self {
1964            Self::Primary => "GitHub's primary API rate limit",
1965            Self::Secondary => "GitHub's secondary rate limit",
1966        }
1967    }
1968
1969    /// What the endpoint an operator would go and check says about this limiter.
1970    const fn where_to_look(self) -> &'static str {
1971        match self {
1972            Self::Primary => {
1973                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1974                 comes back."
1975            }
1976            Self::Secondary => {
1977                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1978                 primary budget and does not report this one, so budget showing there says \
1979                 nothing about this refusal, and every further attempt extends it."
1980            }
1981        }
1982    }
1983
1984    /// The next step this limiter actually calls for.
1985    const fn what_to_do(self) -> &'static str {
1986        match self {
1987            Self::Primary => {
1988                "wait for the reset `gh api rate_limit` reports, then run the command again."
1989            }
1990            Self::Secondary => {
1991                "leave this board alone for a few minutes, then run the command again — or \
1992                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1993            }
1994        }
1995    }
1996}
1997
1998/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1999#[derive(Debug, Clone, Copy)]
2000struct Limited {
2001    limiter: Limiter,
2002    hint: Option<u64>,
2003}
2004
2005impl Limited {
2006    /// What the caller is told once this source has waited as long as it may.
2007    ///
2008    /// Both limiters report as [`SourceError::RateLimited`], because that is what
2009    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
2010    /// about *which* limiter it was makes it a different kind of failure. What differs is
2011    /// the operator's next step, and that is what the message carries — a secondary
2012    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
2013    /// budget looks fine, and then back to retry the very burst that was refused.
2014    fn exhausted(
2015        self,
2016        doing: &str,
2017        waits: u32,
2018        waited: Duration,
2019        needed: Duration,
2020        budget: Duration,
2021    ) -> SourceError {
2022        SourceError::RateLimited {
2023            retry_after_seconds: self.hint,
2024            message: Some(format!(
2025                "{} refused this source while {doing}; it waited {} out over {} and was refused \
2026                 again, and the next wait of {} would take it past the {} one call may spend \
2027                 waiting. {} next: {}",
2028                self.limiter.name(),
2029                plural(waits, "refusal"),
2030                seconds(waited),
2031                seconds(needed),
2032                seconds(budget),
2033                self.limiter.where_to_look(),
2034                self.limiter.what_to_do(),
2035            )),
2036        }
2037    }
2038}
2039
2040/// One HTTP attempt's result, with what its response said about the rate limit.
2041///
2042/// The two travel together so the record and the outcome are written from the same place:
2043/// what a response said about the budget is only readable while that response is in hand,
2044/// and what the attempt *meant* is only decidable once its body has been read.
2045struct Attempted {
2046    result: Result<Value, Attempt>,
2047    limits: accounting::RateLimit,
2048    /// GitHub's own reported cost for this call, for a document that asked for it.
2049    reported_cost: Option<u64>,
2050    response_outcome: accounting::ResponseOutcome,
2051}
2052
2053/// One attempt's outcome: an error to report, or a rate limit to wait out.
2054enum Attempt {
2055    Failed(SourceError),
2056    Limited(Limited),
2057}
2058
2059fn plural(count: u32, thing: &str) -> String {
2060    if count == 1 {
2061        format!("{count} {thing}")
2062    } else {
2063        format!("{count} {thing}s")
2064    }
2065}
2066
2067fn seconds(duration: Duration) -> String {
2068    format!("{:.1}s", duration.as_secs_f64())
2069}
2070
2071/// The member [`stamp_served`] writes onto every node of a response that says when it last
2072/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
2073const SERVED_AT: &str = "onetaskgraphServedAt";
2074
2075/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
2076/// node's time later knows the server's clock on the response that read it — which is what
2077/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
2078fn stamp_served(data: &mut Value, served: &str) {
2079    match data {
2080        Value::Object(members) => {
2081            if members.contains_key("updatedAt") {
2082                members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
2083            }
2084            for member in members.values_mut() {
2085                stamp_served(member, served);
2086            }
2087        }
2088        Value::Array(items) => {
2089            for item in items {
2090                stamp_served(item, served);
2091            }
2092        }
2093        _ => {}
2094    }
2095}
2096
2097/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
2098///
2099/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
2100/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
2101/// header, and neither is what makes a response a refusal — so the whole cost of one this
2102/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
2103/// instead. Refusing the response over the header would turn a readable refusal into an
2104/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
2105fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
2106    value
2107        .and_then(|value| value.to_str().ok())
2108        .and_then(|value| value.trim().parse::<u64>().ok())
2109}
2110
2111/// Every mutation this source sends creates content — an issue, a board item, a field of
2112/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
2113/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
2114/// and what the keyword says are the same set. That is what makes the keyword a sound test
2115/// rather than a convenient one: pacing an edit or a removal the limiter might not have
2116/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
2117fn is_mutation(query: &str) -> bool {
2118    query.trim_start().starts_with("mutation")
2119}
2120
2121/// What this source was doing, for a diagnostic that has to say so.
2122///
2123/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
2124/// a document added without a description is caught by that list's own gate instead of
2125/// falling through to the vague arm below.
2126fn operation_description(query: &str) -> &'static str {
2127    graphql::DOCUMENTS
2128        .iter()
2129        .find(|(document, _)| *document == query)
2130        .map_or("talking to GitHub", |(_, doing)| *doing)
2131}
2132
2133/// GitHub's published ceiling on content-generating requests, per minute.
2134///
2135/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
2136/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
2137/// from it, so a pacing value checked only against itself cannot go stale here.
2138pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
2139/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
2140/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
2141/// governs; it is pinned beside its sibling so the gate would notice either one moving.
2142pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
2143/// Shortest interval between two content-creating mutations, in milliseconds.
2144///
2145/// GitHub documents two secondary limits on content-generating requests:
2146/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
2147/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
2148/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
2149/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
2150/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
2151/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
2152/// deliberately *not* what this paces at. An installation that wants the hourly bound
2153/// honoured for a long sequence of copies says so through
2154/// `pacing.min_mutation_interval_ms`.
2155pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
2156/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
2157///
2158/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
2159/// own advice for a secondary limit — wait, and wait longer each time — without spending
2160/// the first minute of a transient refusal doing nothing.
2161pub const RETRY_BACKOFF_MS: u64 = 1_000;
2162/// Total time one call may spend waiting out rate limits before it reports a failure.
2163///
2164/// Two minutes is long enough to ride out the refusals a paced copy still collects and
2165/// short enough that a command an operator is watching returns. The bound is what makes
2166/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
2167/// the limiter, not in a process nobody can tell from a wedged one.
2168pub const RETRY_BUDGET_MS: u64 = 120_000;
2169
2170fn default_token_env() -> String {
2171    "GH_PROJECTS_TOKEN".to_owned()
2172}
2173fn default_endpoint() -> String {
2174    "https://api.github.com/graphql".to_owned()
2175}
2176
2177/// The name of a `Status` single-select option on the board.
2178///
2179/// Validated on the way in rather than checked later, so a blank option name — which
2180/// nothing on a board can be — is a state this type cannot hold.
2181#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2182#[serde(try_from = "String")]
2183#[schemars(extend("minLength" = 1))]
2184pub struct ColumnName(String);
2185
2186impl ColumnName {
2187    /// The option name, as the board spells it.
2188    fn as_str(&self) -> &str {
2189        &self.0
2190    }
2191}
2192
2193impl TryFrom<String> for ColumnName {
2194    type Error = String;
2195
2196    fn try_from(name: String) -> Result<Self, Self::Error> {
2197        if name.trim().is_empty() {
2198            return Err("a status_mapping option name cannot be blank".to_owned());
2199        }
2200        Ok(Self(name))
2201    }
2202}
2203
2204/// The two closed states this product can mean.
2205///
2206/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
2207/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
2208/// product never decides on a person's behalf that two issues are the same work.
2209#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
2210#[serde(rename_all = "kebab-case")]
2211pub enum ClosedState {
2212    /// `COMPLETED` — precisely done.
2213    Completed,
2214    /// `NOT_PLANNED` — precisely cancelled.
2215    NotPlanned,
2216}
2217
2218impl ClosedState {
2219    const fn reason(self) -> &'static str {
2220        match self {
2221            Self::Completed => "COMPLETED",
2222            Self::NotPlanned => "NOT_PLANNED",
2223        }
2224    }
2225}
2226
2227/// Configuration for one GitHub Projects v2 board.
2228///
2229/// It serializes to a document this same type reads back as itself, so a caller writing a
2230/// configuration — a consumer filing onto a board it configures — can build one here rather
2231/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
2232#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2233#[serde(default, deny_unknown_fields)]
2234pub struct GitHubProjectsConfig {
2235    /// Login of the user or organization which owns the board.
2236    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
2237    /// The project number shown in the board's GitHub URL.
2238    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
2239    // 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.
2240    /// `owner/name` of the repository this source creates an issue in when the item's own
2241    /// `repositories` field does not decide it.
2242    ///
2243    /// An item naming exactly one repository is created there; a task or a document naming
2244    /// none or several is created in its parent project's repository; and a project, or a
2245    /// task or document with no parent, naming none or several is created here. A board
2246    /// has no repository of its own and `createIssue` requires one, so a write without
2247    /// this is refused naming the field. Reads never need it.
2248    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
2249    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2250    /// Environment variable containing a fine-grained token with Projects and Issues
2251    /// read/write plus Pull requests read-only access for every repository represented on
2252    /// the board.
2253    #[serde(default = "default_token_env")]
2254    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
2255    /// GraphQL endpoint. GitHub Enterprise installations may override it.
2256    #[serde(default = "default_endpoint")]
2257    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
2258    /// Per-instance mapping from a status category to the option of the board's one
2259    /// `Status` field it lands on, for a task and for a project.
2260    ///
2261    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
2262    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
2263    /// where a kind left out leaves the category unmapped for that kind. A category this
2264    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
2265    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
2266    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
2267    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
2268    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
2269    /// either kind. No two categories may name one option for the same kind, ignoring case.
2270    /// `unknown` may name one existing option; every unknown word then lands on it and
2271    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
2272    /// each unknown word because it never creates board options.
2273    #[serde(default)]
2274    pub status_mapping: StatusMapping,
2275    /// Per-instance mapping from a task's priority to an option of this board's
2276    /// single-select field named `Priority`.
2277    ///
2278    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
2279    /// other priority is refused before it reaches this board. Present, each of `urgent`,
2280    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
2281    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
2282    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
2283    /// no two levels may name one option. Reads and writes never create the field or an
2284    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
2285    /// the board lacks is refused pointing there.
2286    #[serde(default)]
2287    pub priority_mapping: Option<PriorityMappingConfig>,
2288    /// Caller metadata values this source also writes to board text fields, so the board can
2289    /// filter on them.
2290    ///
2291    /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
2292    /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
2293    /// value. On every item write — create, update and copy, of a task, a project or a
2294    /// document — a string found there is written to the field, and nothing is sent when the
2295    /// field already holds it; nothing there, or `null`, clears the field when it holds a
2296    /// value; any other JSON type is refused before any mutation. The metadata comment in the
2297    /// issue body stays the one home of the value, and every read reports metadata from it.
2298    /// Reads and writes never create the field: `onetaskgraph sources fields <source>
2299    /// --apply` does, as a text field, and a write to a board lacking it, or holding a
2300    /// non-text field of that name, is refused pointing there.
2301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2302    // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
2303    // list out, so both SDKs model an absent list as an empty one rather than as `null`.
2304    #[schemars(!skip_serializing_if)]
2305    pub metadata_fields: Vec<MetadataFieldConfig>,
2306    /// How fast this source writes, and how long it waits out a rate-limit refusal.
2307    ///
2308    /// Every field keeps its shipped default when it is absent, and the defaults are
2309    /// GitHub's own published limits rather than taste. See [`Pacing`].
2310    #[serde(default)]
2311    pub pacing: PacingConfig,
2312}
2313
2314/// Which option of the board's `Priority` field each priority lands on.
2315///
2316/// One member per level rather than a map, so a key that is not a level is refused where
2317/// the configuration is read, naming the levels there are. `none` is not a member: it is no
2318/// value in the field, not an option of it.
2319#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2320#[serde(default, deny_unknown_fields)]
2321pub struct PriorityMappingConfig {
2322    /// The option `urgent` lands on; `Urgent` when absent.
2323    pub urgent: Option<PriorityOptionName>,
2324    /// The option `high` lands on; `High` when absent.
2325    pub high: Option<PriorityOptionName>,
2326    /// The option `medium` lands on; `Medium` when absent.
2327    pub medium: Option<PriorityOptionName>,
2328    /// The option `low` lands on; `Low` when absent.
2329    pub low: Option<PriorityOptionName>,
2330}
2331
2332/// The name of an option of the board's `Priority` single-select field.
2333///
2334/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
2335/// blank name.
2336#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2337#[serde(try_from = "String")]
2338#[schemars(extend("minLength" = 1))]
2339pub struct PriorityOptionName(String);
2340
2341impl PriorityOptionName {
2342    /// The option name, as the board spells it.
2343    fn as_str(&self) -> &str {
2344        &self.0
2345    }
2346}
2347
2348impl TryFrom<String> for PriorityOptionName {
2349    type Error = String;
2350
2351    fn try_from(name: String) -> Result<Self, Self::Error> {
2352        if name.trim().is_empty() {
2353            return Err("a priority_mapping option name cannot be blank".to_owned());
2354        }
2355        Ok(Self(name))
2356    }
2357}
2358
2359/// The name of the board field a priority is held in.
2360pub const PRIORITY_FIELD: &str = "Priority";
2361
2362/// The four priorities a board option can hold, in the order a new `Priority` field lists
2363/// them. `none` is not among them: it is the field holding no value.
2364///
2365/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
2366/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
2367/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
2368/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
2369pub const PRIORITY_LEVELS: [Priority; 4] = [
2370    Priority::Urgent,
2371    Priority::High,
2372    Priority::Medium,
2373    Priority::Low,
2374];
2375
2376/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
2377/// see that list for what this pins.
2378#[must_use]
2379pub const fn level_position(priority: Priority) -> Option<usize> {
2380    match priority {
2381        Priority::None => None,
2382        Priority::Urgent => Some(0),
2383        Priority::High => Some(1),
2384        Priority::Medium => Some(2),
2385        Priority::Low => Some(3),
2386    }
2387}
2388
2389/// This instance's complete priority-to-option mapping, read in both directions.
2390///
2391/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
2392/// two levels name one option.
2393#[derive(Debug, Clone)]
2394struct PriorityMapping {
2395    options: [PriorityOptionName; 4],
2396}
2397
2398impl PriorityMapping {
2399    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2400        let shipped = |name: &str| PriorityOptionName(name.to_owned());
2401        let mapping = Self {
2402            options: [
2403                config.urgent.unwrap_or_else(|| shipped("Urgent")),
2404                config.high.unwrap_or_else(|| shipped("High")),
2405                config.medium.unwrap_or_else(|| shipped("Medium")),
2406                config.low.unwrap_or_else(|| shipped("Low")),
2407            ],
2408        };
2409        for (index, option) in mapping.options.iter().enumerate() {
2410            if let Some(earlier) = mapping.options[..index]
2411                .iter()
2412                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
2413            {
2414                return Err(SourceError::Config {
2415                    message: format!(
2416                        "priority_mapping of source {instance} sends both {} and {} to the board \
2417                         option {:?}; one option cannot read back as two priorities",
2418                        PRIORITY_LEVELS[earlier],
2419                        PRIORITY_LEVELS[index],
2420                        option.as_str()
2421                    ),
2422                });
2423            }
2424        }
2425        Ok(mapping)
2426    }
2427
2428    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
2429    fn option(&self, priority: Priority) -> Option<&str> {
2430        level_position(priority).map(|index| self.options[index].as_str())
2431    }
2432
2433    /// The priority a board option name reports, or `None` when nothing maps to it.
2434    fn priority_of(&self, option: &str) -> Option<Priority> {
2435        self.options
2436            .iter()
2437            .position(|name| name.as_str().eq_ignore_ascii_case(option))
2438            .map(|index| PRIORITY_LEVELS[index])
2439    }
2440
2441    /// Every mapped option name, in the order a new `Priority` field lists them.
2442    fn names(&self) -> impl Iterator<Item = &str> {
2443        self.options.iter().map(PriorityOptionName::as_str)
2444    }
2445}
2446
2447/// One metadata value this source projects onto a board text field.
2448#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2449#[serde(deny_unknown_fields)]
2450pub struct MetadataFieldConfig {
2451    /// The name of the board text field the value is written to.
2452    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.
2453    /// The top-level metadata key the value is read from. A key under the reserved
2454    /// `onetaskgraph.` prefix is refused.
2455    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.
2456    /// Object keys walked inside that key's value, outermost first. Absent or empty, the
2457    /// key's own value is the one projected. Left out of a serialized entry when empty.
2458    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2459    // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
2460    #[schemars(!skip_serializing_if)]
2461    pub path: Vec<String>,
2462}
2463
2464/// The board fields no metadata value may be projected onto, compared ignoring case: the
2465/// three this source writes itself, and those GitHub owns on every board.
2466// 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.
2467const UNPROJECTABLE_FIELDS: [&str; 13] = [
2468    STATUS_FIELD,
2469    PRIORITY_FIELD,
2470    ORIGIN_FIELD,
2471    "Title",
2472    "Assignees",
2473    "Labels",
2474    "Linked pull requests",
2475    "Milestone",
2476    "Repository",
2477    "Reviewers",
2478    "Parent issue",
2479    "Sub-issues progress",
2480    "Type",
2481];
2482
2483/// One validated [`MetadataFieldConfig`] entry.
2484#[derive(Debug, Clone)]
2485struct MetadataField {
2486    field: ProjectedField,
2487    key: ProjectedKey,
2488    path: Vec<String>,
2489}
2490
2491/// The name of a board text field a metadata value is projected onto: never blank, never one
2492/// this source or GitHub writes, and never one another entry of the same source names.
2493#[derive(Debug, Clone, PartialEq, Eq)]
2494struct ProjectedField(String);
2495
2496/// The top-level metadata key a projected value is read from: never blank, and never under
2497/// the namespace this product reserves.
2498#[derive(Debug, Clone, PartialEq, Eq)]
2499struct ProjectedKey(String);
2500
2501impl std::ops::Deref for ProjectedField {
2502    type Target = str;
2503    fn deref(&self) -> &str {
2504        &self.0
2505    }
2506}
2507
2508impl std::ops::Deref for ProjectedKey {
2509    type Target = str;
2510    fn deref(&self) -> &str {
2511        &self.0
2512    }
2513}
2514
2515/// What one item's metadata says one projected field should hold.
2516#[derive(Debug, Clone, PartialEq, Eq)]
2517enum Projected {
2518    /// A string to hold.
2519    Text(String),
2520    /// Nothing: the key or a step of the path is absent, or the value is `null`.
2521    Nothing,
2522}
2523
2524impl MetadataField {
2525    /// Validate every entry of one instance's `metadata_fields`, refusing the first that
2526    /// cannot stand, naming the source and the entry.
2527    fn resolve(
2528        configured: Vec<MetadataFieldConfig>,
2529        instance: &SourceName,
2530    ) -> Result<Vec<Self>, SourceError> {
2531        let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
2532        for (index, entry) in configured.into_iter().enumerate() {
2533            let refuse = |why: String| SourceError::Config {
2534                message: format!(
2535                    "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
2536                    entry.field, entry.key
2537                ),
2538            };
2539            if entry.field.trim().is_empty() {
2540                return Err(refuse("names a blank field".to_owned()));
2541            }
2542            if entry.key.trim().is_empty() {
2543                return Err(refuse("names a blank key".to_owned()));
2544            }
2545            if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
2546                return Err(refuse(format!(
2547                    "names a key under the reserved \"{}.\" prefix, which this product keeps \
2548                     for its own keys; next: project a caller key instead",
2549                    MetadataKey::RESERVED_NAMESPACE
2550                )));
2551            }
2552            if let Some(owned) = UNPROJECTABLE_FIELDS
2553                .iter()
2554                .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
2555            {
2556                return Err(refuse(format!(
2557                    "names the board field {owned:?}, which this source or GitHub already \
2558                     writes; next: name a text field of your own"
2559                )));
2560            }
2561            if let Some(earlier) = resolved
2562                .iter()
2563                .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
2564            {
2565                return Err(refuse(format!(
2566                    "names the same board field as metadata_fields[{earlier}]; one field \
2567                     cannot hold two values"
2568                )));
2569            }
2570            resolved.push(Self {
2571                field: ProjectedField(entry.field),
2572                key: ProjectedKey(entry.key),
2573                path: entry.path,
2574            });
2575        }
2576        Ok(resolved)
2577    }
2578
2579    /// The path as a refusal spells it: `[]` for the key's own value.
2580    fn spelled_path(&self) -> String {
2581        serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
2582    }
2583
2584    /// What `metadata` says this field should hold, or the refusal for a value no text field
2585    /// can hold.
2586    fn projected(
2587        &self,
2588        metadata: &BTreeMap<String, Value>,
2589        instance: &SourceName,
2590    ) -> Result<Projected, SourceError> {
2591        let mut value = metadata.get(&*self.key);
2592        for step in &self.path {
2593            value = value.and_then(|held| held.get(step.as_str()));
2594        }
2595        let found = match value {
2596            None | Some(Value::Null) => return Ok(Projected::Nothing),
2597            Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
2598            Some(Value::Bool(_)) => "a boolean",
2599            Some(Value::Number(_)) => "a number",
2600            Some(Value::Array(_)) => "an array",
2601            Some(Value::Object(_)) => "an object",
2602        };
2603        Err(SourceError::Refused {
2604            message: format!(
2605                "source {instance} projects metadata key {:?} at path {} onto its board text \
2606                 field {:?}, and this item holds {found} there; only a string, null or nothing \
2607                 can be written to a text field; next: store a string there, or remove the \
2608                 value",
2609                &*self.key,
2610                self.spelled_path(),
2611                &*self.field
2612            ),
2613        })
2614    }
2615}
2616
2617/// What one item's `Priority` field says, read through this instance's mapping.
2618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
2619enum HeldPriority {
2620    /// A priority this source reports: an option the mapping names, or no value (`none`).
2621    Read(Priority),
2622    /// An option the mapping does not name, which is never read as a level or as `none`.
2623    Unmapped(String),
2624}
2625
2626/// How fast this source writes, and how long it waits out a rate-limit refusal.
2627///
2628/// Configurable because a GitHub Enterprise installation sets its own limits and an
2629/// operator who has already been refused may want to go slower still — not because the
2630/// defaults are guesses.
2631#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2632#[serde(default, deny_unknown_fields)]
2633pub struct PacingConfig {
2634    /// Shortest interval between two content-creating mutations, in milliseconds.
2635    ///
2636    /// Zero sends them as fast as they are asked for, which is what a fixture server on
2637    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
2638    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.
2639    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
2640    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
2641    /// zero while there is a budget to spend, because a schedule of zero-length waits
2642    /// consumes none of it and so never ends.
2643    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.
2644    /// Total time one call may spend waiting out rate limits, in milliseconds.
2645    ///
2646    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
2647    /// the bound is what makes this a wait rather than a hang.
2648    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.
2649}
2650
2651/// The largest any pacing setting may be, in milliseconds.
2652///
2653/// One hour. GitHub's own harshest published bound on content-generating requests works
2654/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
2655/// anything a real limit asks for, and past it the settings stop describing pacing at all:
2656/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
2657/// and an interval beyond it is a command that never sends its second mutation. It also
2658/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
2659/// what a `Duration` can hold on every platform.
2660pub const MAX_PACING_MS: u64 = 3_600_000;
2661
2662/// [`PacingConfig`] with every default resolved and every value checked, which is what the
2663/// source holds.
2664#[derive(Debug, Clone, Copy)]
2665struct Pacing {
2666    min_mutation_interval: Duration,
2667    retry_backoff: Duration,
2668    retry_budget: Duration,
2669}
2670
2671impl Pacing {
2672    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
2673    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2674        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
2675            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
2676                message: format!(
2677                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
2678                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
2679                     GitHub's own harshest published limit"
2680                ),
2681            }),
2682            Some(value) => Ok(Duration::from_millis(value)),
2683            None => Ok(Duration::from_millis(default)),
2684        };
2685        let retry_backoff = bounded(
2686            config.retry_backoff_ms,
2687            RETRY_BACKOFF_MS,
2688            "retry_backoff_ms",
2689        )?;
2690        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
2691        if retry_backoff.is_zero() && !retry_budget.is_zero() {
2692            return Err(SourceError::Config {
2693                message: format!(
2694                    "pacing.retry_backoff_ms of source {instance} is 0 while \
2695                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
2696                     none of that budget, so it would retry a refusal forever. Set a backoff of \
2697                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
2698                     waiting at all",
2699                    retry_budget.as_millis()
2700                ),
2701            });
2702        }
2703        Ok(Self {
2704            min_mutation_interval: bounded(
2705                config.min_mutation_interval_ms,
2706                MIN_MUTATION_INTERVAL_MS,
2707                "min_mutation_interval_ms",
2708            )?,
2709            retry_backoff,
2710            retry_budget,
2711        })
2712    }
2713}
2714
2715/// Factory for [`GitHubProjectsSource`].
2716#[derive(Debug, Clone, Copy, Default)]
2717pub struct Plugin;
2718
2719impl SourcePlugin for Plugin {
2720    fn kind(&self) -> &'static str {
2721        KIND
2722    }
2723    /// The endpoint, the owner and the board's number: what names one board.
2724    fn remote_identity(&self, config: &Value) -> Option<String> {
2725        let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
2726        Some(format!(
2727            "{}\n{}\n{}",
2728            config.endpoint, config.owner, config.project_number
2729        ))
2730    }
2731    fn config_schema(&self) -> Schema {
2732        schema_for!(GitHubProjectsConfig)
2733    }
2734    fn build(
2735        &self,
2736        name: &SourceName,
2737        config: &Value,
2738        secrets: &dyn SecretResolver,
2739    ) -> Result<Box<dyn TaskSource>, SourceError> {
2740        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2741    }
2742
2743    fn build_with_clock(
2744        &self,
2745        name: &SourceName,
2746        config: &Value,
2747        secrets: &dyn SecretResolver,
2748        clock: SharedClock,
2749    ) -> Result<Box<dyn TaskSource>, SourceError> {
2750        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2751    }
2752}
2753
2754impl Plugin {
2755    /// Build a source recording every request it sends into an accounting the caller holds.
2756    ///
2757    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2758    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2759    /// session total rather than two — see [`accounting`] and
2760    /// [`GitHubProjectsSource::recording_into`].
2761    ///
2762    /// # Errors
2763    ///
2764    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2765    /// [`SourceError::Config`] for configuration this plugin cannot use and
2766    /// [`SourceError::Auth`] for a credential it cannot find.
2767    pub fn build_recording_into(
2768        &self,
2769        name: &SourceName,
2770        config: &Value,
2771        secrets: &dyn SecretResolver,
2772        ledger: Arc<Accounting>,
2773    ) -> Result<Box<dyn TaskSource>, SourceError> {
2774        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2775    }
2776
2777    fn build_recording_with_clock(
2778        &self,
2779        name: &SourceName,
2780        config: &Value,
2781        secrets: &dyn SecretResolver,
2782        ledger: Arc<Accounting>,
2783        clock: SharedClock,
2784    ) -> Result<Box<dyn TaskSource>, SourceError> {
2785        let config: GitHubProjectsConfig =
2786            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2787                message: format!("source {name}: {e}"),
2788            })?;
2789        let prefix = format!("source {name}: ");
2790        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2791            .map_err(|error| match error {
2792                // The shared `StatusMapping::distinct` names the source itself.
2793                SourceError::Config { message } if message.starts_with(&prefix) => {
2794                    SourceError::Config { message }
2795                }
2796                SourceError::Config { message } => SourceError::Config {
2797                    message: format!("{prefix}{message}"),
2798                },
2799                SourceError::Auth { message } => SourceError::Auth {
2800                    message: format!("source {name}: {message}"),
2801                },
2802                other => other,
2803            })?;
2804        source.clock = clock;
2805        Ok(Box::new(source))
2806    }
2807}
2808
2809/// Where a status category lands on this board, once configuration is resolved.
2810#[derive(Debug, Clone, PartialEq, Eq)]
2811enum StatusTarget {
2812    /// Not usable against this instance for this kind, and why.
2813    Disabled(UnmappedStatus),
2814    /// The board's `Status` option of this name.
2815    Column(ColumnName),
2816    /// A closed issue, with both its board option and the reason that says which closed it means.
2817    // 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.
2818    Terminal(ColumnName, ClosedState),
2819}
2820
2821/// Every status category, in the order the vocabulary declares them.
2822///
2823/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2824/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2825/// added to the shared vocabulary fails to compile until it is named there, and this
2826/// crate's suite reconciles this list against that enum's own derived schema, which is
2827/// generated from the variants rather than written beside them. The schema is what
2828/// catches a list left one short — a list checking only the positions it already holds
2829/// would pass while every mapping indexed by the new position panicked.
2830pub const CATEGORIES: [StatusCategory; 8] = [
2831    StatusCategory::Draft,
2832    StatusCategory::Backlog,
2833    StatusCategory::Todo,
2834    StatusCategory::Queued,
2835    StatusCategory::InProgress,
2836    StatusCategory::Done,
2837    StatusCategory::Cancelled,
2838    StatusCategory::Unknown,
2839];
2840
2841/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2842#[must_use]
2843pub const fn category_position(category: StatusCategory) -> usize {
2844    match category {
2845        StatusCategory::Draft => 0,
2846        StatusCategory::Backlog => 1,
2847        StatusCategory::Todo => 2,
2848        StatusCategory::Queued => 3,
2849        StatusCategory::InProgress => 4,
2850        StatusCategory::Done => 5,
2851        StatusCategory::Cancelled => 6,
2852        StatusCategory::Unknown => 7,
2853    }
2854}
2855
2856/// The spelling a status category is configured and reported under.
2857fn category_name(category: StatusCategory) -> &'static str {
2858    match category {
2859        StatusCategory::Draft => "draft",
2860        StatusCategory::Backlog => "backlog",
2861        StatusCategory::Todo => "todo",
2862        StatusCategory::Queued => "queued",
2863        StatusCategory::InProgress => "in-progress",
2864        StatusCategory::Done => "done",
2865        StatusCategory::Cancelled => "cancelled",
2866        StatusCategory::Unknown => "unknown",
2867    }
2868}
2869
2870/// A shipped default's option name.
2871///
2872/// The literals below are this file's own and non-blank, and they are validated by the
2873/// one constructor a configured name goes through rather than beside it.
2874fn shipped_column(name: &'static str) -> ColumnName {
2875    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2876}
2877
2878/// The shipped default for one category this instance's `status_mapping` does not mention,
2879/// for either kind.
2880fn shipped_default(category: StatusCategory) -> StatusTarget {
2881    match category {
2882        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2883        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2884        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2885        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2886        StatusCategory::Done => {
2887            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2888        }
2889        StatusCategory::Cancelled => {
2890            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2891        }
2892        StatusCategory::Draft | StatusCategory::Unknown => {
2893            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2894        }
2895    }
2896}
2897
2898/// The two kinds a status is written and read for, each with its own half of the mapping.
2899const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2900
2901/// This instance's complete category-to-target mapping for each kind, read in both
2902/// directions.
2903///
2904/// One target per category per kind, held at that category's own [`category_position`], so
2905/// a category missing from the mapping, named twice in it, or filed out of order is a state
2906/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2907/// targets are options of the board's one `Status` field.
2908#[derive(Debug, Clone)]
2909struct BoardStatuses {
2910    tasks: [StatusTarget; CATEGORIES.len()],
2911    projects: [StatusTarget; CATEGORIES.len()],
2912}
2913
2914impl BoardStatuses {
2915    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2916    /// would read back from one option.
2917    ///
2918    /// A category the mapping does not mention keeps its shipped default for both kinds; one
2919    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2920    /// omits unmapped rather than defaulted.
2921    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2922        let resolve_kind =
2923            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2924                // `CATEGORIES[position] == category` for every category — the crate's suite
2925                // asserts it — so mapping the list in order fills each category's own slot.
2926                let mut targets = CATEGORIES.map(shipped_default);
2927                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2928                    if !configured.mentions(category) {
2929                        continue;
2930                    }
2931                    *slot = match configured.name_for(category, kind) {
2932                        Err(why) => StatusTarget::Disabled(why),
2933                        Ok(name) => {
2934                            let option = ColumnName::try_from(name.as_str().to_owned())
2935                                .map_err(|message| SourceError::Config { message })?;
2936                            match category {
2937                                StatusCategory::Done => {
2938                                    StatusTarget::Terminal(option, ClosedState::Completed)
2939                                }
2940                                StatusCategory::Cancelled => {
2941                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2942                                }
2943                                _ => StatusTarget::Column(option),
2944                            }
2945                        }
2946                    };
2947                }
2948                StatusMapping::distinct(
2949                    instance,
2950                    kind,
2951                    CATEGORIES
2952                        .iter()
2953                        .zip(&targets)
2954                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2955                )?;
2956                Ok(targets)
2957            };
2958        Ok(Self {
2959            tasks: resolve_kind(ItemKind::Task)?,
2960            projects: resolve_kind(ItemKind::Project)?,
2961        })
2962    }
2963
2964    /// Every category's target for `kind`, in category order.
2965    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2966        match kind {
2967            ItemKind::Task => &self.tasks,
2968            ItemKind::Project => &self.projects,
2969        }
2970    }
2971
2972    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2973        &self.targets(kind)[category_position(category)]
2974    }
2975
2976    /// The category a board option name reports for `kind`, or `None` when nothing of that
2977    /// kind maps to it.
2978    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2979        CATEGORIES.into_iter().find(|category| {
2980            self.target(kind, *category)
2981                .option()
2982                .is_some_and(|name| name.eq_ignore_ascii_case(option))
2983        })
2984    }
2985
2986    /// Every option name either kind maps a category to, each once ignoring case, in
2987    /// category order with a task's name before a project's — what the guarded setup asks
2988    /// the `Status` field to hold.
2989    fn wanted(&self) -> Vec<String> {
2990        let mut wanted: Vec<String> = Vec::new();
2991        for category in CATEGORIES {
2992            for kind in STATUS_KINDS {
2993                if let Some(name) = self.target(kind, category).option()
2994                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2995                {
2996                    wanted.push(name.to_owned());
2997                }
2998            }
2999        }
3000        wanted
3001    }
3002
3003    /// The status an item of `kind` reports, from the three things a read of it says: its
3004    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
3005    ///
3006    /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
3007    /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
3008    /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
3009    /// produce, so it is read permissively rather than refused — reads are faithful, and
3010    /// refusals belong on writes.
3011    ///
3012    /// **Its name follows the closure too, unless the option agrees with it.** The option's
3013    /// own name is kept when this kind's mapping reads that option as the same category, or
3014    /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
3015    /// named `Shipped`. An option the mapping reads as another category contradicts the
3016    /// closure, and so does no option at all: the name is then the option the mapping gives
3017    /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
3018    /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
3019    /// it. An `unknown` closure keeps the option, or `Closed` without one.
3020    ///
3021    /// An open item's option reads through its own kind's mapping, and an option that mapping
3022    /// does not name reads as `Unknown` under its own name.
3023    ///
3024    /// One function of those three rather than of a response, so a narrow status write can
3025    /// answer what a re-read would report by applying it to the state it has just written.
3026    fn status(
3027        &self,
3028        kind: ItemKind,
3029        option: Option<&str>,
3030        closed: bool,
3031        reason: Option<&str>,
3032    ) -> Status {
3033        if closed {
3034            let category = closed_category(reason);
3035            let agrees = option.filter(|option| {
3036                category == StatusCategory::Unknown
3037                    || self
3038                        .category_of(kind, option)
3039                        .is_none_or(|read| read == category)
3040            });
3041            let name = agrees.or_else(|| self.target(kind, category).option());
3042            let fallback = match category {
3043                StatusCategory::Done => "Done",
3044                StatusCategory::Cancelled => "Cancelled",
3045                _ => "Closed",
3046            };
3047            return Status {
3048                category,
3049                name: name.or(option).unwrap_or(fallback).to_owned(),
3050            };
3051        }
3052        let name = option.unwrap_or("Open").to_owned();
3053        Status {
3054            category: self
3055                .category_of(kind, &name)
3056                .unwrap_or(StatusCategory::Unknown),
3057            name,
3058        }
3059    }
3060
3061    /// The option a closed item's `Status` has to hold for its card to agree with its
3062    /// closure, and the category that closure reads as — or `None` when the item is open,
3063    /// its closure reads `unknown`, or its option already agrees.
3064    ///
3065    /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
3066    /// in line, each to the one option the mapping gives its category — an item with no
3067    /// option, or at one the mapping does not name, included.
3068    fn contradicted(
3069        &self,
3070        kind: ItemKind,
3071        option: Option<&str>,
3072        closed: bool,
3073        reason: Option<&str>,
3074    ) -> Option<(ClosedAs, &str)> {
3075        if !closed {
3076            return None;
3077        }
3078        let closed_as = match closed_category(reason) {
3079            StatusCategory::Done => ClosedAs::Done,
3080            StatusCategory::Cancelled => ClosedAs::Cancelled,
3081            _ => return None,
3082        };
3083        let wanted = self.target(kind, closed_as.category()).option()?;
3084        if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
3085            return None;
3086        }
3087        Some((closed_as, wanted))
3088    }
3089}
3090
3091/// The category a closed issue reads as, from the reason it was closed with.
3092///
3093/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
3094/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
3095/// is one this source cannot read a category from.
3096fn closed_category(reason: Option<&str>) -> StatusCategory {
3097    match reason {
3098        None | Some("COMPLETED") => StatusCategory::Done,
3099        Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
3100        Some(_) => StatusCategory::Unknown,
3101    }
3102}
3103
3104impl BoardStatuses {
3105    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
3106    /// case; a kind lacking none is left out.
3107    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
3108        STATUS_KINDS
3109            .into_iter()
3110            .filter_map(|kind| {
3111                let missing: Vec<String> = self
3112                    .targets(kind)
3113                    .iter()
3114                    .filter_map(StatusTarget::option)
3115                    .filter(|wanted| {
3116                        !existing
3117                            .iter()
3118                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3119                    })
3120                    .map(str::to_owned)
3121                    .collect();
3122                (!missing.is_empty()).then_some(KindMissing { kind, missing })
3123            })
3124            .collect()
3125    }
3126}
3127
3128impl StatusTarget {
3129    /// The board option this target selects, or `None` for an unmapped one.
3130    fn option(&self) -> Option<&str> {
3131        match self {
3132            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
3133            Self::Disabled(_) => None,
3134        }
3135    }
3136}
3137
3138// 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.
3139/// One repository this source can create an issue in, as `owner/name`.
3140///
3141/// Every `createIssue` this source sends names one of these: the item's own single
3142/// `repositories` entry, else its parent project issue's repository, else the configured
3143/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
3144/// that choice and says what it refuses before `createIssue`.
3145// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
3146#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3147struct RepositoryTarget {
3148    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3149    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3150}
3151
3152impl RepositoryTarget {
3153    fn parse(value: &str) -> Result<Self, SourceError> {
3154        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
3155            message: format!(
3156                "repository must be spelled owner/name; {value:?} names no repository"
3157            ),
3158        })?;
3159        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
3160            return Err(SourceError::Config {
3161                message: format!(
3162                    "repository must be spelled owner/name with a GitHub login and one \
3163                     repository name; {value:?} is not"
3164                ),
3165            });
3166        }
3167        Ok(Self {
3168            owner: owner.to_owned(),
3169            name: name.to_owned(),
3170        })
3171    }
3172
3173    /// The one host whose repositories this source creates issues in, spelled once: it is
3174    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
3175    const HOST: &str = "github.com";
3176
3177    fn origin(&self) -> String {
3178        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
3179    }
3180
3181    /// The repository a normalized origin names, or why it is none this source can create
3182    /// an issue in: another host, or more or fewer than `owner/name` under this one.
3183    fn from_origin(origin: &Repository) -> Result<Self, String> {
3184        let not_here = || {
3185            format!(
3186                "{} is not a {}/owner/name repository",
3187                origin.as_str(),
3188                Self::HOST
3189            )
3190        };
3191        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
3192        if host != Self::HOST {
3193            return Err(not_here());
3194        }
3195        Self::parse(rest).map_err(|_| not_here())
3196    }
3197
3198    fn slug(&self) -> String {
3199        format!("{}/{}", self.owner, self.name)
3200    }
3201}
3202
3203/// A source which reads GitHub afresh for every operation.
3204pub struct GitHubProjectsSource {
3205    /// This source's configured name, used both to tell a far end naming this source
3206    /// from one naming a system it knows nothing about, and to name the instance a
3207    /// status refusal is about.
3208    name: SourceName,
3209    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
3210    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
3211    repository: Option<RepositoryTarget>,
3212    endpoint: Url,
3213    token: SecretString,
3214    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
3215    statuses: BoardStatuses,
3216    /// Where each priority lands on this board, or `None` when this instance holds none.
3217    priorities: Option<PriorityMapping>,
3218    /// The metadata values this instance projects onto board text fields, in configured order.
3219    metadata_fields: Vec<MetadataField>,
3220    client: Client,
3221    asset_client: Client,
3222    /// Every item this source has created in this command, in the order it created them —
3223    /// dropped by [`TaskSource::end_command`].
3224    ///
3225    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
3226    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
3227    /// a copy resolving a dependency on an item it had just created refused it as not
3228    /// found. A board read is completed from this — an item remembered here and absent from
3229    /// the read is added back, because the board really does hold it and only the read is
3230    /// behind.
3231    ///
3232    /// It is not a cache of a user's work: nothing is remembered that this process did not
3233    /// itself just write, it lives and dies with the process, and it is never consulted for
3234    /// an item this source did not create.
3235    created: Mutex<Vec<Resolved>>,
3236    /// Every item that already existed and that this source has written in this command, as
3237    /// it wrote it — dropped by [`TaskSource::end_command`].
3238    ///
3239    /// The other half of [`Self::created`], held on the same terms and for the reason a
3240    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
3241    /// filter is an index behind a write this process made moments ago, so a query matching
3242    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
3243    /// is remembered that this process did not itself just write.
3244    updated: Mutex<Vec<Resolved>>,
3245    /// Every issue this source has added a comment to or edited a comment of in this command
3246    /// — dropped by [`TaskSource::end_command`].
3247    ///
3248    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
3249    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
3250    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
3251    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
3252    /// from before — until the index caught up. Each id here is a candidate of every such
3253    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
3254    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
3255    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
3256    /// process wrote is still found only once the index has it.
3257    commented: Mutex<Vec<NativeId>>,
3258    /// How fast this source writes, and how long it waits out a refusal.
3259    pacing: Pacing,
3260    /// When the last content-creating mutation finished, or the moment the furthest-out
3261    /// reserved slot releases the next one, whichever is later — so the one after it can be
3262    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
3263    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
3264    /// what it is measured from.
3265    last_mutation: Mutex<Option<Duration>>,
3266    clock: SharedClock,
3267    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
3268    /// The board as this process last read it, for the length of one command — dropped by
3269    /// [`TaskSource::end_command`].
3270    ///
3271    /// A copy of a project used to re-read the whole board, paged, before writing each of
3272    /// its items, which is by far the largest part of a copy's request count and none of
3273    /// its work. Nothing else changes this board while a command runs — this source's own
3274    /// writes are the only writer — so one read answers them all.
3275    ///
3276    /// It is not a store of a user's work and it is not the cache the no-persistence
3277    /// invariant forbids: it lives and dies with the process exactly as `created` does,
3278    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
3279    /// an item this command created and then depends on resolves whether or not GitHub's
3280    /// own eventually-consistent read has caught up. A write to an item already on the
3281    /// board updates the entry here too, so what this holds is the last read plus this
3282    /// process's own writes rather than a snapshot taken before them.
3283    board_cache: Mutex<Option<Board>>,
3284    /// Every issue this board's own search reported, for the length of one command — dropped
3285    /// by [`TaskSource::end_command`].
3286    ///
3287    /// The second half of a board read, and cached for the same reason and on the same
3288    /// terms as the first: it lives and dies with the process, nothing is written down, and
3289    /// a write this process makes updates the entry here exactly as it updates the one in
3290    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
3291    /// that lists this board's projects and its tasks pays for one search rather than two.
3292    search_cache: Mutex<Option<Vec<Resolved>>>,
3293    /// What each narrowed question GitHub was asked answered, keyed by that question, for
3294    /// the length of one command — dropped by [`TaskSource::end_command`].
3295    ///
3296    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
3297    /// and dies with the process, nothing is written down, a write this process makes
3298    /// updates the entry here as it updates the other two, and every answer is completed
3299    /// with this process's own writes each time it is given. A command that asks the same
3300    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
3301    /// write — pays for it once, which is what the whole-board read it replaced gave it.
3302    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
3303    search_next: Mutex<BTreeMap<String, Option<String>>>,
3304    /// Records already resolved in this command, reused by writes and for comment identity.
3305    /// Explicit item reads still reach GitHub. Nothing is persisted, and
3306    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
3307    /// its item as a person has since left it.
3308    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3309    /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
3310    /// answered as holding nothing — each taken by the next read of that item by id and by
3311    /// nothing else, dropped by any mutation naming it, and cleared by
3312    /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
3313    /// read-before-write, made in the same request as the others' rather than on its own.
3314    read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
3315    /// Every item a listing of one project's members carried whole in this command — the
3316    /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
3317    /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
3318    /// it again. No other read is answered from it, so a read by id outside a copy's
3319    /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
3320    /// item, and cleared by [`TaskSource::end_command`].
3321    carried: Mutex<BTreeMap<NativeId, Resolved>>,
3322    /// Items an issue read by reference reached, held for the one status write each was read
3323    /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
3324    /// what such a write needs. Dropped by [`TaskSource::end_command`].
3325    held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3326    /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
3327    /// asked for under, with the project that selector named — for the length of one command,
3328    /// dropped by [`TaskSource::end_command`].
3329    ///
3330    /// A caller pages through a project's tasks one engine page at a time, and every page
3331    /// is cut from the whole list of them, so without this each page walked every
3332    /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
3333    /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
3334    /// dies with the process, nothing is written down, a write this process makes updates or
3335    /// removes the entry here as it does there, and every answer is completed with this
3336    /// process's own writes each time it is given.
3337    children_cache: Mutex<ProjectChildren>,
3338    /// The board's own id and field definitions as this process last read them on their
3339    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
3340    ///
3341    /// What a write needs of the board and its item does not say, read once per command
3342    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
3343    /// lives and dies with the process and nothing is written down. It holds no item and so
3344    /// can answer no question about one — see [`Self::board_fields`].
3345    fields_cache: Mutex<Option<BoardFields>>,
3346    /// Each destination repository's node id, resolved once per repository
3347    /// rather than per issue created.
3348    ///
3349    /// A repository's node id does not change, and re-reading it for every issue of a copy
3350    /// spent one request per item on an answer this source already had. It is a map rather
3351    /// than one entry because a copy files each item in the repository its own
3352    /// `repositories` field names, so a plan across five repositories asks GitHub five
3353    /// times and not once per item.
3354    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
3355    /// What every request this source sends is recorded into.
3356    ///
3357    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
3358    /// a request leaves this crate, so nothing has to be switched on for a session to be
3359    /// counted. It is shared rather than owned so a caller accounting for a whole session —
3360    /// its own schema verification, board lookups, residue sweep and cleanup beside this
3361    /// source's reads and writes — adds up one accounting instead of two. See
3362    /// [`accounting`] for what a record carries and what a session's spend is and is not.
3363    ledger: Arc<Accounting>,
3364    /// What [`expected`] holds for a write the store's cache expects: the journal while one is
3365    /// open, the issues primed from the cache, and the comments known on each.
3366    expected: expected::Held,
3367}
3368
3369/// GitHub's closed single-select color vocabulary.
3370#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3371#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
3372pub enum StatusOptionColor {
3373    /// Gray.
3374    Gray,
3375    /// Blue.
3376    Blue,
3377    /// Green.
3378    Green,
3379    /// Yellow.
3380    Yellow,
3381    /// Purple.
3382    Purple,
3383    /// Red.
3384    Red,
3385    /// Orange.
3386    Orange,
3387    /// Pink.
3388    Pink,
3389}
3390
3391/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
3392/// applies its additions.
3393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3394pub enum SetupMode {
3395    /// Read without mutation.
3396    Plan,
3397    /// Apply and verify.
3398    Apply,
3399}
3400
3401/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
3402/// against it goes on compiling.
3403pub type StatusOptionsMode = SetupMode;
3404
3405/// The explicit result of the requested operation.
3406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3407#[serde(rename_all = "kebab-case")]
3408pub enum StatusOptionsOutcome {
3409    /// A read-only plan.
3410    Planned,
3411    /// Apply found nothing missing.
3412    Unchanged,
3413    /// Additions were applied and verified.
3414    Applied,
3415}
3416
3417/// A GitHub single-select option's opaque GraphQL node identifier.
3418#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3419#[serde(transparent)]
3420pub struct StatusOptionId(#[schemars(length(min = 1))] String);
3421
3422impl TryFrom<String> for StatusOptionId {
3423    type Error = String;
3424
3425    fn try_from(id: String) -> Result<Self, Self::Error> {
3426        if id.trim().is_empty() {
3427            return Err("a GitHub Status option id cannot be blank".to_owned());
3428        }
3429        Ok(Self(id))
3430    }
3431}
3432
3433/// One existing or proposed option in a guarded Status-field update.
3434#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3435pub struct StatusOption {
3436    /// GitHub's stable id.
3437    pub id: StatusOptionId,
3438    /// The visible option name.
3439    pub name: ColumnName,
3440    /// GitHub's single-select color token.
3441    pub color: StatusOptionColor,
3442    /// The option description, including an empty one.
3443    pub description: String,
3444}
3445
3446/// One board item's Status assignment, retained as recovery data.
3447#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3448pub struct StatusAssignment {
3449    /// The project item id whose assignment this is.
3450    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
3451    // carried verbatim as operator recovery data; introducing a semantic type would claim
3452    // validation rules GitHub does not publish and no operation here interprets.
3453    pub item_id: String,
3454    /// The selected option, absent when the item has no status.
3455    #[serde(skip_serializing_if = "Option::is_none")]
3456    pub option: Option<AssignedStatusOption>,
3457}
3458
3459/// The inseparable id and name of an assigned option.
3460#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3461pub struct AssignedStatusOption {
3462    /// GitHub's stable id.
3463    pub id: StatusOptionId,
3464    /// The visible name.
3465    pub name: ColumnName,
3466}
3467
3468/// The plan and verified outcome of reconciling configured Status options.
3469#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3470pub struct StatusOptionsReport {
3471    /// The configured source name.
3472    pub source: SourceName,
3473    /// Configured option names absent before the operation.
3474    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
3475    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
3476    // serialized string here preserves the report's intentionally simple public contract.
3477    pub missing: Vec<String>,
3478    /// What the requested operation did.
3479    pub outcome: StatusOptionsOutcome,
3480    /// The complete option list observed before any mutation.
3481    pub existing: Vec<StatusOption>,
3482}
3483
3484#[derive(Debug, Clone, PartialEq, Eq)]
3485struct StatusSnapshot {
3486    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3487    // passed back as the mutation's project identity; a newtype could enforce no stronger
3488    // invariant because GitHub publishes no grammar for it.
3489    board_id: String,
3490    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3491    // passed back as the mutation's field identity; a newtype could enforce no stronger
3492    // invariant because GitHub publishes no grammar for it.
3493    field_id: String,
3494    options: Vec<StatusOption>,
3495    assignments: Vec<StatusAssignment>,
3496}
3497
3498/// The name of the board field a status is held in.
3499const STATUS_FIELD: &str = "Status";
3500
3501/// Every item's value of each field `report` names, as it stood before the setup wrote
3502/// anything — what a person puts back when the setup is refused part way.
3503fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
3504    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
3505        .fields
3506        .iter()
3507        .map(|field| (field.field.name(), before.assignments(field.field)))
3508        .collect();
3509    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
3510        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
3511    })
3512}
3513
3514/// One board field the guarded setup reads and writes — every one it reads, and the only
3515/// ones it writes.
3516#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
3517pub enum BoardField {
3518    /// The single-select `Status` field every instance's `status_mapping` resolves into.
3519    Status,
3520    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
3521    Priority,
3522}
3523
3524impl BoardField {
3525    /// The field's name on the board.
3526    #[must_use]
3527    pub const fn name(self) -> &'static str {
3528        match self {
3529            Self::Status => STATUS_FIELD,
3530            Self::Priority => PRIORITY_FIELD,
3531        }
3532    }
3533
3534    /// The field a board calls `name`, or `None` for one this setup does not own.
3535    fn named(name: &str) -> Option<Self> {
3536        [Self::Status, Self::Priority]
3537            .into_iter()
3538            .find(|field| field.name() == name)
3539    }
3540}
3541
3542/// What the guarded setup did to one field.
3543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3544#[serde(rename_all = "kebab-case")]
3545pub enum FieldOutcome {
3546    /// A read-only plan.
3547    Planned,
3548    /// Apply found the field there with every configured option.
3549    Unchanged,
3550    /// Missing options were added to the field that was there, and verified.
3551    Applied,
3552    /// The field was not there; it was created holding the configured options, and verified.
3553    Created,
3554}
3555
3556/// One field's plan, or its verified outcome.
3557#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3558pub struct FieldReport {
3559    /// Which field.
3560    pub field: BoardField,
3561    /// Whether the board had the field before the operation.
3562    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
3563    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
3564    // "outcome", "existing"}` — so folding one into the other would change a published JSON
3565    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
3566    // one constructor, and it derives `outcome` from `exists` in one match.
3567    pub exists: bool,
3568    /// Configured option names the field lacked before the operation — every one of them,
3569    /// in the order a new field lists them, when the field was not there at all.
3570    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3571    // mapping name and has therefore already passed its nonblank validation; the serialized
3572    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
3573    pub missing: Vec<String>,
3574    /// For the `Status` field, which item kind each missing name is configured for: one
3575    /// entry per kind `status_mapping` names a missing option for, task before project, each
3576    /// listing that kind's missing names in category order. A name both kinds use is in
3577    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
3578    /// `Priority`, which only a task holds.
3579    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3580    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3581    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
3582    #[schemars(!skip_serializing_if)]
3583    pub kinds: Vec<KindMissing>,
3584    /// What the requested operation did.
3585    pub outcome: FieldOutcome,
3586    /// The field's complete option list observed before any mutation; empty when the field
3587    /// was not there.
3588    pub existing: Vec<StatusOption>,
3589}
3590
3591/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
3592#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3593pub struct KindMissing {
3594    /// The kind these names are configured for.
3595    pub kind: ItemKind,
3596    /// The names that kind maps a category to and the field lacked, in category order.
3597    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3598    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
3599    // intentionally simple public contract.
3600    pub missing: Vec<String>,
3601}
3602
3603/// The plan and verified outcome of setting up every field a source's configuration names.
3604#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3605pub struct FieldsReport {
3606    /// The configured source name.
3607    pub source: SourceName,
3608    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
3609    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
3610    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
3611    // per field would change a published JSON shape. The states the list could hold and the
3612    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
3613    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
3614    pub fields: Vec<FieldReport>,
3615    /// Each board text field the source's `metadata_fields` projects a value onto, in
3616    /// configured order. Empty — and left out of the JSON — when it projects none.
3617    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3618    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3619    // both SDKs model an absent list as an empty one rather than as `null`.
3620    #[schemars(!skip_serializing_if)]
3621    pub metadata_fields: Vec<MetadataFieldReport>,
3622}
3623
3624/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
3625/// its GraphQL type when it has none — which is what a projected field's conflict reports.
3626///
3627/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
3628/// no conflict.
3629// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
3630// refuses exactly what `try_from` below does.
3631#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3632#[serde(transparent)]
3633pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
3634
3635/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
3636/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
3637/// neither SDK's validator can be relied on to support.
3638const NON_TEXT_FIELD_TYPE_PATTERN: &str =
3639    r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
3640
3641impl TryFrom<String> for NonTextFieldType {
3642    type Error = String;
3643
3644    fn try_from(kind: String) -> Result<Self, Self::Error> {
3645        if kind.is_empty() || kind.starts_with(char::is_whitespace) {
3646            return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
3647        }
3648        if kind == "TEXT" {
3649            return Err("a TEXT field is no conflict for a projected text field".to_owned());
3650        }
3651        Ok(Self(kind))
3652    }
3653}
3654
3655impl std::fmt::Display for NonTextFieldType {
3656    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3657        formatter.write_str(&self.0)
3658    }
3659}
3660
3661/// One projected metadata text field's plan, or its verified outcome.
3662///
3663/// The setup creates a missing one as a text field and never alters a field that is there:
3664/// a same-named field of another type is reported in `conflict` and left as it is.
3665#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3666pub struct MetadataFieldReport {
3667    /// The board field's name.
3668    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.
3669    /// The metadata key the value is read from.
3670    pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
3671    /// The object keys walked inside that key's value; empty for the key's own value.
3672    pub path: Vec<String>,
3673    /// Whether the board had a text field of that name before the operation.
3674    // 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.
3675    pub exists: bool,
3676    /// The type of a field of that name that is not a text field, which the setup leaves as
3677    /// it is; absent — and left out of the JSON — when there is none.
3678    #[serde(default, skip_serializing_if = "Option::is_none")]
3679    pub conflict: Option<NonTextFieldType>,
3680    /// What the requested operation did: `planned`; `created` for a field that was not
3681    /// there; `unchanged` for one that was, and for a conflict, which is never changed.
3682    pub outcome: FieldOutcome,
3683}
3684
3685/// Which options one field is configured with, in the order a new field would list them.
3686struct FieldPlan {
3687    field: BoardField,
3688    wanted: Vec<String>,
3689}
3690
3691/// One single-select field as the guarded setup snapshots it.
3692#[derive(Debug, Clone, PartialEq, Eq)]
3693struct SnapshotField {
3694    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3695    // passed back as the mutation's field identity; a newtype could enforce no stronger
3696    // invariant because GitHub publishes no grammar for it.
3697    field_id: String,
3698    options: Vec<StatusOption>,
3699}
3700
3701/// Every single-select field of a board and every item's value of each.
3702#[derive(Debug, Clone, PartialEq, Eq)]
3703struct BoardSnapshot {
3704    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3705    // passed back as the mutation's project identity; a newtype could enforce no stronger
3706    // invariant because GitHub publishes no grammar for it.
3707    board_id: String,
3708    fields: BTreeMap<BoardField, SnapshotField>,
3709    /// Each board item's id, and its value of each field this setup owns that it holds one of.
3710    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
3711}
3712
3713impl BoardSnapshot {
3714    /// Every item's value of `field`, in board order — the recovery data a drift refusal
3715    /// carries.
3716    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
3717        self.items
3718            .iter()
3719            .map(|(item_id, values)| StatusAssignment {
3720                item_id: item_id.clone(),
3721                option: values.get(&field).cloned(),
3722            })
3723            .collect()
3724    }
3725}
3726
3727impl GitHubProjectsSource {
3728    /// Report missing configured Status options and, when `apply` is true, add them with
3729    /// a whole-list mutation that preserves every existing id and verifies the result.
3730    ///
3731    /// # Errors
3732    ///
3733    /// Refuses a board without a single-select `Status` field. A post-write difference in
3734    /// any pre-existing option id or item assignment is refused with the complete pre-write
3735    /// assignment snapshot in the diagnostic for recovery.
3736    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
3737    // successful mutation, both drift refusals, source selection, missing Status, casing,
3738    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
3739    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
3740    // responses from entering the defensive malformed-response branches below.
3741    pub async fn status_options(
3742        &self,
3743        mode: StatusOptionsMode,
3744    ) -> Result<StatusOptionsReport, SourceError> {
3745        let before = self.status_snapshot().await?;
3746        // A terminal category's option is as configured as an open one's: a terminal
3747        // write validates it before closing and refuses when the board lacks it. Both
3748        // kinds' names are options of the one field, so both are asked for.
3749        let missing = self
3750            .statuses
3751            .wanted()
3752            .into_iter()
3753            .filter(|wanted| {
3754                !before
3755                    .options
3756                    .iter()
3757                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3758            })
3759            .collect::<Vec<_>>();
3760        let report = StatusOptionsReport {
3761            source: self.name.clone(),
3762            missing: missing.clone(),
3763            outcome: match (mode, missing.is_empty()) {
3764                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
3765                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
3766                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
3767            },
3768            existing: before.options.clone(),
3769        };
3770        if mode == StatusOptionsMode::Plan || missing.is_empty() {
3771            return Ok(report);
3772        }
3773        let mut options = before
3774            .options
3775            .iter()
3776            .map(|option| {
3777                json!({
3778                    "id": option.id, "name": option.name, "color": option.color,
3779                    "description": option.description,
3780                })
3781            })
3782            .collect::<Vec<_>>();
3783        options.extend(missing.iter().map(|name| {
3784            json!({
3785                "name": name, "color": "GRAY", "description": ""
3786            })
3787        }));
3788        self.graphql(
3789            graphql::STATUS_OPTIONS_UPDATE,
3790            json!({"input": {
3791                "projectId": before.board_id, "fieldId": before.field_id,
3792                "singleSelectOptions": options,
3793            }}),
3794        )
3795        .await?;
3796        let after = self.status_snapshot().await?;
3797        let options_preserved = before
3798            .options
3799            .iter()
3800            .all(|old| after.options.iter().any(|new| new == old));
3801        let additions_present = missing.iter().all(|wanted| {
3802            after
3803                .options
3804                .iter()
3805                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3806        });
3807        if !options_preserved || !additions_present || after.assignments != before.assignments {
3808            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
3809                SourceError::Malformed {
3810                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
3811                }
3812            })?;
3813            return Err(SourceError::Refused {
3814                message: format!(
3815                    "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}"
3816                ),
3817            });
3818        }
3819        Ok(report)
3820    }
3821
3822    /// A fresh snapshot of the Status field and every board item's assignment of it.
3823    ///
3824    /// # Errors
3825    ///
3826    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
3827    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
3828        // Status alone, as this operation has always read it: a `Priority` field is another
3829        // operation's, so nothing about it can refuse this one.
3830        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
3831        let field = board
3832            .fields
3833            .remove(&BoardField::Status)
3834            .ok_or_else(|| self.no_status_field())?;
3835        Ok(StatusSnapshot {
3836            assignments: board.assignments(BoardField::Status),
3837            board_id: board.board_id,
3838            field_id: field.field_id,
3839            options: field.options,
3840        })
3841    }
3842
3843    /// The refusal a board with no `Status` field is answered with by the guarded setup.
3844    fn no_status_field(&self) -> SourceError {
3845        SourceError::Refused {
3846            message: format!("source {} board has no Status field", self.name),
3847        }
3848    }
3849
3850    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
3851    // the real CLI loopback journey, including pagination. The individual malformed guards
3852    // are defensive validation of a schema-pinned third-party response, not separate user
3853    // journeys; drift and missing-field failures cover the operation's recovery behavior.
3854    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
3855    /// every board item's value of each, walked to the end of the board's items. A field not
3856    /// in `owned` is read past whatever it holds.
3857    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
3858        let mut after: Option<String> = None;
3859        let mut snapshot: Option<BoardSnapshot> = None;
3860        loop {
3861            let data = self
3862                .graphql(
3863                    graphql::STATUS_OPTIONS_SNAPSHOT,
3864                    json!({
3865                        "owner": self.owner, "number": self.project_number,
3866                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3867                    }),
3868                )
3869                .await?;
3870            let board = data
3871                .pointer("/owner/projectV2")
3872                .filter(|board| board.is_object())
3873                .ok_or_else(|| SourceError::Refused {
3874                    message: format!(
3875                        "source {} has no accessible GitHub Projects board",
3876                        self.name
3877                    ),
3878                })?;
3879            if board
3880                .pointer("/fields/pageInfo/hasNextPage")
3881                .and_then(Value::as_bool)
3882                != Some(false)
3883            {
3884                return Err(SourceError::Malformed {
3885                    message:
3886                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3887                            .into(),
3888                });
3889            }
3890            let mut fields = BTreeMap::new();
3891            // Only the fields this setup owns, by name: a node the single-select fragment did not
3892            // match carries no name, and a person's own single-select field — a `Size`, a
3893            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3894            // `Status` or `Priority` field without its options is malformed, not absent.
3895            // 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.
3896            for (owned, field) in board
3897                .pointer("/fields/nodes")
3898                .and_then(Value::as_array)
3899                .ok_or_else(|| SourceError::Malformed {
3900                    message: "GitHub project fields.nodes is not an array".into(),
3901                })?
3902                .iter()
3903                .filter_map(|field| {
3904                    let named = BoardField::named(field.get("name")?.as_str()?)?;
3905                    owned.contains(&named).then_some((named, field))
3906                })
3907            {
3908                let options = field
3909                    .get("options")
3910                    .and_then(Value::as_array)
3911                    .ok_or_else(|| SourceError::Malformed {
3912                        message: "GitHub single-select field options is not an array".into(),
3913                    })?
3914                    .iter()
3915                    .map(|option| {
3916                        Ok(StatusOption {
3917                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3918                                .map_err(|message| SourceError::Malformed { message })?,
3919                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3920                                .map_err(|message| SourceError::Malformed {
3921                                    message: format!(
3922                                        "GitHub single-select option name is invalid: {message}"
3923                                    ),
3924                                })?,
3925                            color: serde_json::from_value(
3926                                option.get("color").cloned().unwrap_or(Value::Null),
3927                            )
3928                            .map_err(|error| {
3929                                SourceError::Malformed {
3930                                    message: format!(
3931                                        "GitHub single-select option color is invalid: {error}"
3932                                    ),
3933                                }
3934                            })?,
3935                            description: optional_str(option, "description")?
3936                                .unwrap_or_default()
3937                                .to_owned(),
3938                        })
3939                    })
3940                    .collect::<Result<Vec<_>, SourceError>>()?;
3941                let snapshot = SnapshotField {
3942                    field_id: required_nonblank_str(field, "id")?.to_owned(),
3943                    options,
3944                };
3945                // A board's field names are unique, so a second one is an answer that cannot
3946                // say which field the setup would act on — refused rather than one chosen.
3947                if fields.insert(owned, snapshot).is_some() {
3948                    return Err(SourceError::Malformed {
3949                        message: format!(
3950                            "GitHub answered two {} fields for this board",
3951                            owned.name()
3952                        ),
3953                    });
3954                }
3955            }
3956            let board_id = required_nonblank_str(board, "id")?.to_owned();
3957            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3958                board_id,
3959                fields,
3960                items: Vec::new(),
3961            });
3962            let items = board
3963                .pointer("/items/nodes")
3964                .and_then(Value::as_array)
3965                .ok_or_else(|| SourceError::Malformed {
3966                    message: "GitHub project items.nodes is not an array".into(),
3967                })?;
3968            for item in items {
3969                let field_values =
3970                    item.get("fieldValues")
3971                        .ok_or_else(|| SourceError::Malformed {
3972                            message: "GitHub project item is missing fieldValues".into(),
3973                        })?;
3974                if field_values
3975                    .pointer("/pageInfo/hasNextPage")
3976                    .and_then(Value::as_bool)
3977                    != Some(false)
3978                {
3979                    return Err(SourceError::Malformed {
3980                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3981                    });
3982                }
3983                let values = item
3984                    .pointer("/fieldValues/nodes")
3985                    .and_then(Value::as_array)
3986                    .ok_or_else(|| SourceError::Malformed {
3987                        message: "GitHub project item fieldValues.nodes is not an array".into(),
3988                    })?;
3989                let item_id = required_nonblank_str(item, "id")?;
3990                let mut assigned = BTreeMap::new();
3991                for value in values {
3992                    let Some(field) = value
3993                        .pointer("/field/name")
3994                        .and_then(Value::as_str)
3995                        .and_then(BoardField::named)
3996                        .filter(|field| owned.contains(field))
3997                    else {
3998                        continue;
3999                    };
4000                    let held = assigned.insert(
4001                        field,
4002                        AssignedStatusOption {
4003                            id: StatusOptionId::try_from(
4004                                required_str(value, "optionId")?.to_owned(),
4005                            )
4006                            .map_err(|message| SourceError::Malformed { message })?,
4007                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
4008                                .map_err(|message| SourceError::Malformed {
4009                                    message: format!(
4010                                        "GitHub assigned {} name is invalid: {message}",
4011                                        field.name()
4012                                    ),
4013                                })?,
4014                        },
4015                    );
4016                    // An item holds one value of a field, so a second one leaves no way to
4017                    // tell which it holds — and a verification or recovery built on either
4018                    // could restore the wrong one.
4019                    if held.is_some() {
4020                        return Err(SourceError::Malformed {
4021                            message: format!(
4022                                "GitHub answered two {} values for board item {item_id}",
4023                                field.name()
4024                            ),
4025                        });
4026                    }
4027                }
4028                current.items.push((item_id.to_owned(), assigned));
4029            }
4030            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
4031                message: "GitHub project is missing items".into(),
4032            })?;
4033            let has_next = page
4034                .pointer("/pageInfo/hasNextPage")
4035                .and_then(Value::as_bool)
4036                .ok_or_else(|| SourceError::Malformed {
4037                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
4038                })?;
4039            if !has_next {
4040                break;
4041            }
4042            let next =
4043                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
4044            validate_cursor_progress(after.as_deref(), next)?;
4045            after = Some(next.to_owned());
4046        }
4047        snapshot.ok_or_else(|| SourceError::Malformed {
4048            message: "GitHub returned no board field snapshot".into(),
4049        })
4050    }
4051    // llmlint: ignore-end[changed_behavior_has_e2e]
4052
4053    /// Report every board field this source's configuration names and, with
4054    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
4055    /// the `Priority` field when the board has none.
4056    ///
4057    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
4058    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
4059    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
4060    /// color and description: the whole option list goes back with every existing id, because
4061    /// a re-minted id clears every item's value.
4062    ///
4063    /// # Errors
4064    ///
4065    /// Refuses a board without a single-select `Status` field. After an apply the board is
4066    /// read again, and a pre-existing option or any item's value of either field that moved is
4067    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
4068    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
4069    // unchanged apply, a created field, an added option to each field, drift refusal, a board
4070    // with no Status field and a non-github-projects source through the compiled CLI against
4071    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
4072    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
4073        let owned: Vec<BoardField> = if self.priorities.is_some() {
4074            vec![BoardField::Status, BoardField::Priority]
4075        } else {
4076            vec![BoardField::Status]
4077        };
4078        let before = self.board_snapshot(&owned).await?;
4079        let mut plans = vec![FieldPlan {
4080            field: BoardField::Status,
4081            wanted: self.statuses.wanted(),
4082        }];
4083        if !before.fields.contains_key(&BoardField::Status) {
4084            return Err(self.no_status_field());
4085        }
4086        if let Some(mapping) = &self.priorities {
4087            plans.push(FieldPlan {
4088                field: BoardField::Priority,
4089                wanted: mapping.names().map(str::to_owned).collect(),
4090            });
4091        }
4092        // The snapshot reads single-select fields alone, so a field it did not find may still
4093        // be on the board under the name, of another type: creating one beside it would fail
4094        // part way, or leave two fields of one name. Asked of the board's own field list, and
4095        // only when a field is missing.
4096        if plans
4097            .iter()
4098            .any(|plan| !before.fields.contains_key(&plan.field))
4099        {
4100            let board = self.board_fields().await?;
4101            for plan in plans
4102                .iter()
4103                .filter(|plan| !before.fields.contains_key(&plan.field))
4104            {
4105                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
4106                    return Err(SourceError::Refused {
4107                        message: format!(
4108                            "source {}'s board has a {} field that is not a single-select field \
4109                             (it is a {}), so it cannot hold this source's options; next: rename \
4110                             or remove that field, then run this again",
4111                            self.name,
4112                            plan.field.name(),
4113                            optional_str(field, "__typename")?.unwrap_or("field of another type")
4114                        ),
4115                    });
4116                }
4117            }
4118        }
4119        let mut reports = Vec::new();
4120        for plan in &plans {
4121            let held = before.fields.get(&plan.field);
4122            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
4123            let mut missing: Vec<String> = Vec::new();
4124            for wanted in &plan.wanted {
4125                let present = existing
4126                    .iter()
4127                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4128                    || missing
4129                        .iter()
4130                        .any(|named| named.eq_ignore_ascii_case(wanted));
4131                if !present {
4132                    missing.push(wanted.clone());
4133                }
4134            }
4135            let kinds = match plan.field {
4136                BoardField::Status => self.statuses.missing_by_kind(&existing),
4137                BoardField::Priority => Vec::new(),
4138            };
4139            reports.push(FieldReport {
4140                field: plan.field,
4141                exists: held.is_some(),
4142                kinds,
4143                outcome: match (mode, held.is_some(), missing.is_empty()) {
4144                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
4145                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
4146                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
4147                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
4148                },
4149                missing,
4150                existing,
4151            });
4152        }
4153        // A projected field is a text field, which the single-select snapshot cannot see, so
4154        // it is asked of the board's own field list — and only when one is configured.
4155        let mut metadata_fields = Vec::new();
4156        if !self.metadata_fields.is_empty() {
4157            let board = self.board_fields().await?;
4158            for projection in &self.metadata_fields {
4159                let conflict = match Board::field(&board.fields, &projection.field)? {
4160                    None => None,
4161                    Some(field) => {
4162                        let typename = optional_str(field, "__typename")?;
4163                        let data_type = optional_str(field, "dataType")?;
4164                        if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
4165                            None
4166                        } else {
4167                            let named = data_type
4168                                .filter(|kind| *kind != "TEXT")
4169                                .or(typename)
4170                                .unwrap_or("field of another type");
4171                            Some(NonTextFieldType::try_from(named.to_owned()).map_err(
4172                                |message| SourceError::Malformed {
4173                                    message: format!(
4174                                        "GitHub answered the board's {} field with a type this \
4175                                         setup cannot read: {message}",
4176                                        &*projection.field
4177                                    ),
4178                                },
4179                            )?)
4180                        }
4181                    }
4182                };
4183                let exists =
4184                    conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
4185                metadata_fields.push(MetadataFieldReport {
4186                    field: projection.field.to_string(),
4187                    key: projection.key.to_string(),
4188                    path: projection.path.clone(),
4189                    exists,
4190                    outcome: match (mode, exists || conflict.is_some()) {
4191                        (SetupMode::Plan, _) => FieldOutcome::Planned,
4192                        (SetupMode::Apply, true) => FieldOutcome::Unchanged,
4193                        (SetupMode::Apply, false) => FieldOutcome::Created,
4194                    },
4195                    conflict,
4196                });
4197            }
4198        }
4199        let report = FieldsReport {
4200            source: self.name.clone(),
4201            fields: reports,
4202            metadata_fields,
4203        };
4204        let writes: Vec<&FieldReport> = report
4205            .fields
4206            .iter()
4207            .filter(|field| !field.missing.is_empty() || !field.exists)
4208            .collect();
4209        let creates: Vec<&MetadataFieldReport> = report
4210            .metadata_fields
4211            .iter()
4212            .filter(|field| field.outcome == FieldOutcome::Created)
4213            .collect();
4214        if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
4215            return Ok(report);
4216        }
4217        let mut landed: Vec<&str> = Vec::new();
4218        for field in &writes {
4219            let added = field
4220                .missing
4221                .iter()
4222                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
4223            let sent = match before.fields.get(&field.field) {
4224                Some(held) => {
4225                    let mut options = held
4226                        .options
4227                        .iter()
4228                        .map(|option| {
4229                            json!({
4230                                "id": option.id, "name": option.name, "color": option.color,
4231                                "description": option.description,
4232                            })
4233                        })
4234                        .collect::<Vec<_>>();
4235                    options.extend(added);
4236                    self.graphql(
4237                        graphql::STATUS_OPTIONS_UPDATE,
4238                        json!({"input": {
4239                            "projectId": before.board_id, "fieldId": held.field_id,
4240                            "singleSelectOptions": options,
4241                        }}),
4242                    )
4243                    .await
4244                }
4245                None => {
4246                    self.graphql(
4247                        graphql::CREATE_FIELD,
4248                        json!({"input": {
4249                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
4250                            "name": field.field.name(),
4251                            "singleSelectOptions": added.collect::<Vec<_>>(),
4252                        }}),
4253                    )
4254                    .await
4255                }
4256            };
4257            // A mutation that failed does not establish that GitHub left its field as it was,
4258            // so every failure from here on carries the recovery data a drift refusal does.
4259            match sent {
4260                Ok(_) => landed.push(field.field.name()),
4261                Err(error) => {
4262                    let changed = if landed.is_empty() {
4263                        String::new()
4264                    } else {
4265                        format!("changed the {} field and then ", landed.join(" and "))
4266                    };
4267                    return Err(SourceError::Refused {
4268                        message: format!(
4269                            "the guarded field setup {changed}failed on the {} field, which it may \
4270                             have changed part way: {error}; the pre-write item assignments \
4271                             are:\n{}",
4272                            field.field.name(),
4273                            recovery(&report, &before)?
4274                        ),
4275                    });
4276                }
4277            }
4278        }
4279        for field in &creates {
4280            let sent = self
4281                .graphql(
4282                    graphql::CREATE_FIELD,
4283                    json!({"input": {
4284                        "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
4285                    }}),
4286                )
4287                .await;
4288            if let Err(error) = sent {
4289                let changed = if landed.is_empty() {
4290                    String::new()
4291                } else {
4292                    format!("changed the {} field and then ", landed.join(" and "))
4293                };
4294                return Err(SourceError::Refused {
4295                    message: format!(
4296                        "the guarded field setup {changed}failed creating the {} text field: \
4297                         {error}; run it again to create what is still missing",
4298                        field.field
4299                    ),
4300                });
4301            }
4302            landed.push(&field.field);
4303        }
4304        // The board has been written, so a verification read that fails leaves it unverified
4305        // rather than unchanged, and says what to put back.
4306        let after = match self.board_snapshot(&owned).await {
4307            Ok(after) => after,
4308            Err(error) => {
4309                return Err(SourceError::Refused {
4310                    message: format!(
4311                        "the guarded field setup changed the {} field and then could not read the \
4312                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
4313                        landed.join(" and "),
4314                        recovery(&report, &before)?
4315                    ),
4316                });
4317            }
4318        };
4319        let mut moved = Vec::new();
4320        for field in &report.fields {
4321            let name = field.field.name();
4322            let now = after
4323                .fields
4324                .get(&field.field)
4325                .map(|held| held.options.as_slice())
4326                .unwrap_or_default();
4327            if !field.existing.iter().all(|old| now.contains(old)) {
4328                moved.push(format!(
4329                    "a pre-existing {name} option id, name, color or description"
4330                ));
4331            }
4332            if !field.missing.iter().all(|wanted| {
4333                now.iter()
4334                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4335            }) {
4336                moved.push(format!("an added {name} option"));
4337            }
4338            if after.assignments(field.field) != before.assignments(field.field) {
4339                moved.push(format!("an item's {name} value"));
4340            }
4341        }
4342        if !creates.is_empty() {
4343            // Read afresh: the view held for this command is the one from before the creates.
4344            *self.fields_cache()? = None;
4345            let board = match self.board_fields().await {
4346                Ok(board) => board,
4347                Err(error) => {
4348                    return Err(SourceError::Refused {
4349                        message: format!(
4350                            "the guarded field setup changed the {} field and then could not \
4351                             read the board back to verify it: {error}; run it again to verify \
4352                             it, which creates nothing a second time",
4353                            landed.join(" and ")
4354                        ),
4355                    });
4356                }
4357            };
4358            for field in &creates {
4359                let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
4360                    held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
4361                        && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
4362                });
4363                if !text {
4364                    moved.push(format!("the created {} text field", field.field));
4365                }
4366            }
4367        }
4368        if !moved.is_empty() {
4369            return Err(SourceError::Refused {
4370                message: format!(
4371                    "GitHub changed {} after the guarded field setup; the pre-write item \
4372                     assignments are:\n{}",
4373                    moved.join(", "),
4374                    recovery(&report, &before)?
4375                ),
4376            });
4377        }
4378        Ok(report)
4379    }
4380
4381    /// Validate configuration and capture the named credential without exposing it.
4382    ///
4383    /// # Errors
4384    ///
4385    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
4386    /// [`SourceError::Auth`] when the named credential is missing or empty.
4387    pub fn new(
4388        name: &SourceName,
4389        config: GitHubProjectsConfig,
4390        secrets: &dyn SecretResolver,
4391    ) -> Result<Self, SourceError> {
4392        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
4393    }
4394
4395    /// The same, recording every request it sends into an accounting the caller holds too.
4396    ///
4397    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
4398    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
4399    /// up — passes the one it records those into, so the session total accounts for the
4400    /// whole session rather than for this source's share of it.
4401    ///
4402    /// # Errors
4403    ///
4404    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
4405    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
4406    pub fn recording_into(
4407        name: &SourceName,
4408        config: GitHubProjectsConfig,
4409        secrets: &dyn SecretResolver,
4410        ledger: Arc<Accounting>,
4411    ) -> Result<Self, SourceError> {
4412        if !valid_github_owner(&config.owner) {
4413            return Err(SourceError::Config {
4414                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
4415            });
4416        }
4417        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
4418            return Err(SourceError::Config {
4419                message: format!("project_number must be between 1 and {}", i32::MAX),
4420            });
4421        }
4422        if !valid_environment_name(&config.token_env) {
4423            return Err(SourceError::Config {
4424                message: "token_env must be a valid environment-variable name".into(),
4425            });
4426        }
4427        let repository = config
4428            .repository
4429            .as_deref()
4430            .map(RepositoryTarget::parse)
4431            .transpose()?;
4432        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
4433            message: format!("endpoint is not a valid URL: {e}"),
4434        })?;
4435        if endpoint.scheme() != "https"
4436            && !(endpoint.scheme() == "http"
4437                && endpoint
4438                    .host_str()
4439                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
4440        {
4441            return Err(SourceError::Config {
4442                message:
4443                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
4444                        .into(),
4445            });
4446        }
4447        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
4448            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),
4449        })?;
4450        Ok(Self {
4451            name: name.clone(),
4452            owner: config.owner,
4453            project_number: config.project_number,
4454            repository,
4455            asset_client: assets::client(&endpoint)?,
4456            endpoint,
4457            token,
4458            credential_name: config.token_env,
4459            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
4460            priorities: config
4461                .priority_mapping
4462                .map(|mapping| PriorityMapping::resolve(mapping, name))
4463                .transpose()?,
4464            metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
4465            client: Client::builder()
4466                .user_agent("onetaskgraph")
4467                .build()
4468                .map_err(|e| SourceError::Config {
4469                    message: format!("cannot build HTTP client: {e}"),
4470                })?,
4471            created: Mutex::new(Vec::new()),
4472            updated: Mutex::new(Vec::new()),
4473            commented: Mutex::new(Vec::new()),
4474            pacing: Pacing::resolve(config.pacing, name)?,
4475            last_mutation: Mutex::new(None),
4476            clock: system_clock(),
4477            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
4478            board_cache: Mutex::new(None),
4479            search_cache: Mutex::new(None),
4480            narrowed_cache: Mutex::new(BTreeMap::new()),
4481            resolved_cache: Mutex::new(BTreeMap::new()),
4482            read_ahead: Mutex::new(BTreeMap::new()),
4483            carried: Mutex::new(BTreeMap::new()),
4484            held_cache: Mutex::new(BTreeMap::new()),
4485            children_cache: Mutex::new(BTreeMap::new()),
4486            search_next: Mutex::new(BTreeMap::new()),
4487            fields_cache: Mutex::new(None),
4488            repository_cache: Mutex::new(BTreeMap::new()),
4489            ledger,
4490            expected: expected::Held::default(),
4491        })
4492    }
4493
4494    /// A snapshot of every request this source has sent, and what each cost.
4495    ///
4496    /// A value to hold and compare rather than a borrow of the accounting itself, so two
4497    /// of them can sit side by side. When this source was built with
4498    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
4499    /// point of building it that way.
4500    #[must_use]
4501    pub fn accounting(&self) -> accounting::Session {
4502        self.ledger.snapshot()
4503    }
4504
4505    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
4506    /// rate limit rather than handing it straight back as an error.
4507    ///
4508    /// Retrying is safe for every document here, including the mutations, and the reason
4509    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
4510    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
4511    /// this replays has already taken effect. An outcome this source cannot know — the
4512    /// send failed, or the body could not be read, so the mutation may well have landed —
4513    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
4514    /// attempt. A duplicate write would come from replaying one of those, and none is
4515    /// replayed.
4516    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4517        if !self.expected.journaling() {
4518            return self.graphql_sent(query, variables).await;
4519        }
4520        // While a write the store's cache expects runs, its mutations select the whole issue
4521        // they leave behind, so the response vouches for what the cache keeps — and every
4522        // request is recorded, so the write's parts are reported from their own responses.
4523        let (query, variables) = match graphql::whole_twin(query) {
4524            Some(twin) => (twin, expected::whole_variables(variables)),
4525            None => (query, variables),
4526        };
4527        let sent = self.graphql_sent(query, variables.clone()).await;
4528        self.journal(query, &variables, &sent).await;
4529        sent
4530    }
4531
4532    /// One document sent, retried while GitHub asks for a wait, exactly as asked.
4533    async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4534        if is_mutation(query)
4535            && ![
4536                graphql::ADD_COMMENT,
4537                graphql::UPDATE_COMMENT,
4538                graphql::DELETE_COMMENT,
4539                graphql::ADD_COMMENT_WHOLE,
4540                graphql::UPDATE_COMMENT_WHOLE,
4541            ]
4542            .contains(&query)
4543        {
4544            let mut cache = self.resolved_cache()?;
4545            let mut ahead = self.read_ahead()?;
4546            let mut carried = self.carried()?;
4547            for argument in ["input", "second", "third", "clear"] {
4548                if let Some(input) = variables.get(argument) {
4549                    let names = |id: &NativeId, item_id: Option<&str>| {
4550                        ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
4551                            input
4552                                .get(key)
4553                                .and_then(Value::as_str)
4554                                .is_some_and(|value| value == id.0 || Some(value) == item_id)
4555                        })
4556                    };
4557                    cache.retain(|id, item| !names(id, Some(&item.item_id)));
4558                    ahead.retain(|id, item| {
4559                        !names(id, item.as_ref().map(|item| item.item_id.as_str()))
4560                    });
4561                    carried.retain(|id, item| !names(id, Some(&item.item_id)));
4562                }
4563            }
4564        }
4565        let doing = operation_description(query);
4566        let mut waited = Duration::ZERO;
4567        let mut waits = 0_u32;
4568        let mut backoff = self.pacing.retry_backoff;
4569        loop {
4570            if is_mutation(query) {
4571                let spacing = self.reserve_mutation_slot();
4572                if !spacing.is_zero() {
4573                    self.clock.sleep(spacing).await;
4574                }
4575            }
4576            let attempt = self.send_once(query, &variables, waits + 1).await;
4577            if is_mutation(query) {
4578                self.finish_mutation();
4579            }
4580            let limited = match attempt {
4581                Ok(data) => return Ok(data),
4582                Err(Attempt::Failed(error)) => return Err(error),
4583                Err(Attempt::Limited(limited)) => limited,
4584            };
4585            // GitHub really does send `retry-after: 0`, and retrying at once is the one
4586            // move that extends a secondary limit, so a hint below the schedule's own next
4587            // wait is raised to it.
4588            let wait = match limited.hint {
4589                Some(hint) => Duration::from_secs(hint).max(backoff),
4590                None => backoff,
4591            };
4592            let remaining = self.pacing.retry_budget.saturating_sub(waited);
4593            // A wait of nothing spends none of the budget, so it is exhaustion rather
4594            // than a retry. `Pacing::resolve` rules out every way of configuring one
4595            // except a budget of zero, where reporting the first refusal is the ask.
4596            if wait.is_zero() || wait > remaining {
4597                return Err(limited.exhausted(
4598                    doing,
4599                    waits,
4600                    waited,
4601                    wait,
4602                    self.pacing.retry_budget,
4603                ));
4604            }
4605            self.clock.sleep(wait).await;
4606            waited += wait;
4607            waits += 1;
4608            backoff = backoff.saturating_mul(2);
4609        }
4610    }
4611
4612    /// The next moment a content-creating mutation may leave this source, as a wait from
4613    /// now.
4614    ///
4615    /// The slot is reserved under the lock and the waiting happens outside it, so two
4616    /// callers take two slots rather than the same one — and no lock is held across an
4617    /// await.
4618    ///
4619    /// The moment it is spaced from is the previous mutation's *completion*, which
4620    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
4621    /// own is the wrong thing to measure from.
4622    fn reserve_mutation_slot(&self) -> Duration {
4623        if self.pacing.min_mutation_interval.is_zero() {
4624            return Duration::ZERO;
4625        }
4626        // A poisoned lock here costs pacing, not correctness, and refusing the write over
4627        // it would turn an earlier failure into a second one for no gain.
4628        let mut last = self
4629            .last_mutation
4630            .lock()
4631            .unwrap_or_else(std::sync::PoisonError::into_inner);
4632        let now = self.clock.now();
4633        // `checked_add` rather than `+`: adding durations can panic on overflow, and
4634        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
4635        let at = last.map_or(now, |previous| {
4636            previous
4637                .checked_add(self.pacing.min_mutation_interval)
4638                .map_or(now, |earliest| earliest.max(now))
4639        });
4640        *last = Some(at);
4641        at.saturating_sub(now)
4642    }
4643
4644    /// Record that a content-creating mutation has finished, so the next one is spaced
4645    /// from here rather than from the moment this one was released.
4646    ///
4647    /// This source can only choose when a request *departs*; the limiter counts when it
4648    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
4649    /// departure from the last therefore hands the limiter a gap of the interval less that
4650    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
4651    /// exactly how a copy paced well inside a board's threshold was refused by it on a
4652    /// slower machine while passing on a quick one.
4653    ///
4654    /// Spacing from completion removes the subtraction rather than budgeting for it. The
4655    /// previous request had already arrived before its response came back, so its arrival
4656    /// is no later than this moment, and the next mutation is released at least the
4657    /// interval after this moment and arrives no earlier than it is released: the gap the
4658    /// limiter measures is therefore at least the interval, whatever transit costs and on
4659    /// whatever platform. The price is that a mutation's own round trip no longer counts
4660    /// towards its spacing, which makes this source slightly slower than the configured
4661    /// rate rather than slightly faster — the safe side of a limit that punishes being
4662    /// wrong by refusing reads for the next fifty minutes.
4663    ///
4664    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
4665    /// and one that never left costs only a wait nobody needed.
4666    fn finish_mutation(&self) {
4667        if self.pacing.min_mutation_interval.is_zero() {
4668            return;
4669        }
4670        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
4671        let mut last = self
4672            .last_mutation
4673            .lock()
4674            .unwrap_or_else(std::sync::PoisonError::into_inner);
4675        let now = self.clock.now();
4676        // `max` rather than an assignment: a concurrent caller may already have reserved a
4677        // slot further out, and completing this request must never pull that slot back in.
4678        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
4679    }
4680
4681    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
4682    /// failure that waiting cannot help — and recorded, whichever of the three it was.
4683    ///
4684    /// This is the one place a request leaves this crate, which is why the accounting is
4685    /// here rather than at each of the callers: a read path added later is counted without
4686    /// anybody remembering to count it, and
4687    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
4688    /// when one is not.
4689    async fn send_once(
4690        &self,
4691        query: &str,
4692        variables: &Value,
4693        number: u32,
4694    ) -> Result<Value, Attempt> {
4695        let executed = self.ledger.priced_document(query);
4696        let Attempted {
4697            result,
4698            limits,
4699            reported_cost,
4700            response_outcome,
4701        } = self.attempt(executed.as_ref(), variables).await;
4702        // No `otherwise` name: every document this source sends is one of its own, and the
4703        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
4704        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
4705        let outcome = match &result {
4706            Ok(_) => accounting::Outcome::Answered,
4707            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
4708            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
4709        };
4710        self.ledger
4711            .record(sending.finished(outcome, limits).with_execution(
4712                Some(executed.into_owned()),
4713                response_outcome,
4714                number,
4715            ));
4716        result
4717    }
4718
4719    /// The attempt itself, with what its response said about the rate limit alongside.
4720    ///
4721    /// The two are returned together rather than recorded here because every one of the
4722    /// early exits below is a different outcome, and a record written at each of them is a
4723    /// record one of them can be added without.
4724    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
4725        let mut limits = accounting::RateLimit::default();
4726        let mut reported_cost = None;
4727        let mut response_outcome = accounting::ResponseOutcome::TransportError;
4728        let result = self
4729            .attempted(
4730                query,
4731                variables,
4732                &mut limits,
4733                &mut reported_cost,
4734                &mut response_outcome,
4735            )
4736            .await;
4737        Attempted {
4738            result,
4739            limits,
4740            reported_cost,
4741            response_outcome,
4742        }
4743    }
4744
4745    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
4746    async fn attempted(
4747        &self,
4748        query: &str,
4749        variables: &Value,
4750        limits: &mut accounting::RateLimit,
4751        reported_cost: &mut Option<u64>,
4752        response_outcome: &mut accounting::ResponseOutcome,
4753    ) -> Result<Value, Attempt> {
4754        let response = self
4755            .client
4756            .post(self.endpoint.clone())
4757            .bearer_auth(self.token.expose_secret())
4758            .json(&json!({"query": query, "variables": variables}))
4759            .send()
4760            .await
4761            .map_err(|e| {
4762                Attempt::Failed(SourceError::Unavailable {
4763                    message: format!("GitHub GraphQL request failed: {e}"),
4764                })
4765            })?;
4766        let status = response.status();
4767        if !status.is_success() {
4768            *response_outcome = accounting::ResponseOutcome::HttpError;
4769        }
4770        let header = |name: &str| whole_seconds(response.headers().get(name));
4771        let served = response
4772            .headers()
4773            .get(reqwest::header::DATE)
4774            .and_then(|value| value.to_str().ok())
4775            .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
4776            .map(|at| at.with_timezone(&Utc));
4777        *limits = accounting::RateLimit::read(|name| {
4778            response
4779                .headers()
4780                .get(name)
4781                .and_then(|value| value.to_str().ok())
4782                .map(str::to_owned)
4783        });
4784        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
4785        // that are not text at all — is "not known to be exhausted". This never makes a
4786        // response a refusal on its own: it says which limiter a refusal is attributed to
4787        // and where its hint comes from, so a value this cannot read costs a hint rather
4788        // than an answer.
4789        let exhausted = response
4790            .headers()
4791            .get("x-ratelimit-remaining")
4792            .and_then(|value| value.to_str().ok())
4793            == Some("0");
4794        // `retry-after` is what GitHub asks for when it asks; when it does not and the
4795        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
4796        // which is the same question answered as an absolute time. Nothing else here is a
4797        // hint, and a schedule is what answers a refusal that carries none.
4798        let hint = header("retry-after").or_else(|| {
4799            exhausted
4800                .then(|| header("x-ratelimit-reset"))
4801                .flatten()
4802                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
4803        });
4804        // Read before it is parsed, because the evidence which tells a secondary rate
4805        // limit from a rejected credential is in the body of a response whose status says
4806        // only "forbidden" — and a non-success response was never parsed at all.
4807        let body = response.text().await.map_err(|e| {
4808            Attempt::Failed(SourceError::Unavailable {
4809                message: format!("GitHub GraphQL response could not be read: {e}"),
4810            })
4811        })?;
4812        *reported_cost = serde_json::from_str::<Value>(&body)
4813            .ok()
4814            .as_ref()
4815            .and_then(|body| body.pointer("/data/rateLimit/cost"))
4816            .and_then(Value::as_u64);
4817        let parsed = serde_json::from_str::<Value>(&body).ok();
4818        *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
4819        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
4820            return Err(Attempt::Limited(Limited { limiter, hint }));
4821        }
4822        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
4823            return Err(Attempt::Failed(SourceError::Auth {
4824                message: format!(
4825                    "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"
4826                ),
4827            }));
4828        }
4829        if !status.is_success() {
4830            return Err(Attempt::Failed(SourceError::Unavailable {
4831                message: format!("GitHub GraphQL returned HTTP {status}"),
4832            }));
4833        }
4834        let answered = if query == graphql::ISSUES_BY_REFERENCE {
4835            self.answer_unresolved(&body)
4836        } else {
4837            self.answer(&body)
4838        };
4839        let mut data = answered.map_err(Attempt::Failed)?;
4840        if let Some(served) = served {
4841            stamp_served(&mut data, &served.to_rfc3339());
4842        }
4843        Ok(data)
4844    }
4845
4846    /// What one successful HTTP response says, once its GraphQL errors are read.
4847    /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
4848    /// for this credential — a repository it cannot see, an issue number it does not hold —
4849    /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
4850    /// that reference rather than a failure of the others. Any other error refuses the read,
4851    /// as there.
4852    fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
4853        let parsed: Option<Value> = serde_json::from_str(body).ok();
4854        // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
4855        // `issue`, and the answer holds `null` at exactly that path: anything else — another
4856        // kind of error, one naming no slot, or one beside a value it says is not there — is a
4857        // refusal of the whole read.
4858        let unresolved = |data: &Value, error: &Value| {
4859            error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
4860                && error
4861                    .get("path")
4862                    .and_then(Value::as_array)
4863                    .is_some_and(|path| {
4864                        let alias = path.first().and_then(Value::as_str).and_then(|alias| {
4865                            alias
4866                                .strip_prefix('r')
4867                                .and_then(|slot| slot.parse::<usize>().ok())
4868                                .filter(|slot| *slot < REFERENCE_BATCH)
4869                                .map(|_| alias)
4870                        });
4871                        match (alias, path.get(1).and_then(Value::as_str), path.len()) {
4872                            (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
4873                            (Some(alias), Some("issue"), 2) => data
4874                                .get(alias)
4875                                .and_then(|repository| repository.get("issue"))
4876                                .is_some_and(Value::is_null),
4877                            _ => false,
4878                        }
4879                    })
4880        };
4881        let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
4882            parsed.get("data").is_some_and(|data| {
4883                data.is_object()
4884                    && parsed
4885                        .get("errors")
4886                        .and_then(Value::as_array)
4887                        .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
4888            })
4889        });
4890        match parsed {
4891            Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
4892            _ => self.answer(body),
4893        }
4894    }
4895
4896    fn answer(&self, body: &str) -> Result<Value, SourceError> {
4897        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
4898            message: format!("GitHub returned invalid JSON: {e}"),
4899        })?;
4900        let errors = body
4901            .get("errors")
4902            .map(|value| {
4903                value.as_array().ok_or_else(|| SourceError::Malformed {
4904                    message: "GitHub response errors is not an array".into(),
4905                })
4906            })
4907            .transpose()?;
4908        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
4909            let messages = errors
4910                .iter()
4911                .filter_map(|e| e.get("message").and_then(Value::as_str))
4912                .collect::<Vec<_>>()
4913                .join("; ");
4914            let message = if messages.is_empty() {
4915                "GitHub returned GraphQL errors".into()
4916            } else {
4917                messages
4918            };
4919            let normalized = message.to_ascii_lowercase();
4920            if normalized.contains("resource not accessible") || normalized.contains("scope") {
4921                return Err(SourceError::Auth {
4922                    message: format!(
4923                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
4924                        self.credential_name
4925                    ),
4926                });
4927            }
4928            return Err(SourceError::Refused { message });
4929        }
4930        body.get("data")
4931            .filter(|data| data.is_object())
4932            .cloned()
4933            .ok_or_else(|| SourceError::Malformed {
4934                message: "GitHub response has no data object".into(),
4935            })
4936    }
4937
4938    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
4939    // GraphQL cannot independently page them inside the outer item page. This source page is
4940    // deliberately bounded at that published maximum; the live drift journey exercises it.
4941    async fn board_page(
4942        &self,
4943        items_after: Option<&str>,
4944        items_first: u32,
4945    ) -> Result<Value, SourceError> {
4946        let data = self
4947            .graphql(
4948                graphql::BOARD,
4949                json!({"owner":self.owner,"number":self.project_number,
4950                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
4951                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
4952            )
4953            .await?;
4954        data.pointer("/owner/projectV2")
4955            .filter(|v| !v.is_null())
4956            .cloned()
4957            .ok_or_else(|| SourceError::Refused {
4958                message: format!(
4959                    "GitHub project {}/{} was not found or is not visible to the token",
4960                    self.owner, self.project_number
4961                ),
4962            })
4963    }
4964
4965    /// The search that finds the issues of this board, narrowed by `also` when it is
4966    /// given.
4967    ///
4968    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
4969    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
4970    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
4971    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
4972    /// from a task by the `parent` field each issue carries rather than by the search.
4973    fn board_search(&self, also: Option<&str>) -> String {
4974        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
4975        match also {
4976            Some(also) => format!("{scope} {also}"),
4977            None => scope,
4978        }
4979    }
4980
4981    /// One issue this source reached directly, as the board item a read of the board would
4982    /// have produced — or `None` when this board does not hold it.
4983    ///
4984    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
4985    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
4986    /// item's own id, that item's field values, and the issue as its content. One resolver
4987    /// for both routes is what makes an issue read through a search, through its own node
4988    /// id, or through its project's sub-issues report the same title, the same status, the
4989    /// same labels and the same qualified id.
4990    ///
4991    /// An issue with no entry for *this* board is not this source's to report, which is
4992    /// what keeps an id naming some other repository's issue from being answered as an item
4993    /// of this board. That answer is given about an **exhausted** connection and never
4994    /// about an unread page: the entry is looked for on the page in hand, and only if that
4995    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
4996    /// rest of it.
4997    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
4998        self.resolve_issue_as(issue, true).await
4999    }
5000
5001    /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
5002    /// and writes only when `remember` says to.
5003    async fn resolve_issue_as(
5004        &self,
5005        issue: &Value,
5006        remember: bool,
5007    ) -> Result<Option<Resolved>, SourceError> {
5008        if optional_str(issue, "__typename")? != Some("Issue") {
5009            return Ok(None);
5010        }
5011        let memberships = issue
5012            .get("projectItems")
5013            .ok_or_else(|| SourceError::Malformed {
5014                message: "GitHub issue is missing projectItems".into(),
5015            })?;
5016        let nodes = memberships
5017            .get("nodes")
5018            .and_then(Value::as_array)
5019            .ok_or_else(|| SourceError::Malformed {
5020                message: "GitHub issue projectItems.nodes is not an array".into(),
5021            })?;
5022        let held = match self.board_entry(nodes) {
5023            Some(held) => held.clone(),
5024            None => {
5025                let info = memberships
5026                    .get("pageInfo")
5027                    .ok_or_else(|| SourceError::Malformed {
5028                        message: "GitHub issue projectItems has no pageInfo".into(),
5029                    })?;
5030                // The page held no entry for this board. Whether that means the issue is
5031                // not on it is a question about the rest of the connection, and only a
5032                // connection with no rest answers it here.
5033                if !required_bool(info, "hasNextPage")? {
5034                    return Ok(None);
5035                }
5036                let cursor = required_str(info, "endCursor")?;
5037                validate_cursor_progress(None, cursor)?;
5038                let issue_id = required_str(issue, "id")?;
5039                match self.board_membership(issue_id, cursor).await? {
5040                    Some(held) => held,
5041                    None => return Ok(None),
5042                }
5043            }
5044        };
5045        // The membership's own `updatedAt` rides along: it is the board half of the item's change
5046        // token, which a field change moves where the issue's own time does not.
5047        let item = json!({
5048            "id": required_str(&held, "id")?,
5049            "updatedAt": held.get("updatedAt"),
5050            "project": held.get("project"),
5051            "fieldValues": held.get("fieldValues"),
5052            "content": issue,
5053        });
5054        if remember {
5055            self.resolve(&item)
5056        } else {
5057            self.resolve_unremembered(&item)
5058        }
5059    }
5060
5061    /// This board's own entry among one page of an issue's `Issue.projectItems`.
5062    ///
5063    /// One spelling of *which membership is this board's*, so the page a read carries and
5064    /// the pages [`Self::board_membership`] walks are searched by the same rule.
5065    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
5066        nodes.iter().find(|node| {
5067            node.pointer("/project/number").and_then(Value::as_u64)
5068                == Some(u64::from(self.project_number))
5069        })
5070    }
5071
5072    /// The rest of one issue's board memberships, from `after`, for this board's entry.
5073    ///
5074    /// The recovery read: a page of memberships that holds no entry for this board says
5075    /// nothing about the memberships past it, so the connection is walked to exhaustion
5076    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
5077    /// positive answer — the whole connection was read and no entry named this board —
5078    /// rather than a failure, and the walk is held to
5079    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
5080    /// with a cursor that does not advance is refused instead of spun on.
5081    async fn board_membership(
5082        &self,
5083        issue: &str,
5084        after: &str,
5085    ) -> Result<Option<Value>, SourceError> {
5086        let mut after = after.to_owned();
5087        loop {
5088            let data = self
5089                .graphql(
5090                    graphql::ISSUE_BOARD_ITEMS,
5091                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
5092                           "nestedFirst":NESTED_PAGE_SIZE}),
5093                )
5094                .await?;
5095            let Some(connection) = data
5096                .pointer("/node/projectItems")
5097                .filter(|value| !value.is_null())
5098            else {
5099                // The id resolved to nothing, or to something with no memberships to walk —
5100                // which is the same answer as a connection holding no entry for this board.
5101                return Ok(None);
5102            };
5103            let nodes = connection
5104                .get("nodes")
5105                .and_then(Value::as_array)
5106                .ok_or_else(|| SourceError::Malformed {
5107                    message: "GitHub issue projectItems.nodes is not an array".into(),
5108                })?;
5109            if let Some(held) = self.board_entry(nodes) {
5110                return Ok(Some(held.clone()));
5111            }
5112            let info = connection
5113                .get("pageInfo")
5114                .ok_or_else(|| SourceError::Malformed {
5115                    message: "GitHub issue projectItems has no pageInfo".into(),
5116                })?;
5117            let next = required_bool(info, "hasNextPage")?
5118                .then(|| required_str(info, "endCursor"))
5119                .transpose()?;
5120            match next {
5121                Some(next) => {
5122                    validate_cursor_progress(Some(&after), next)?;
5123                    after = next.to_owned();
5124                }
5125                None => return Ok(None),
5126            }
5127        }
5128    }
5129
5130    /// One page of a board-scoped issue search, and where the next page resumes.
5131    async fn search_page(
5132        &self,
5133        search: &str,
5134        first: u32,
5135        after: Option<&str>,
5136    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
5137        let data = self
5138            .graphql(
5139                graphql::SEARCH_ISSUES,
5140                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
5141                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
5142                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5143            )
5144            .await?;
5145        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
5146            message: "GitHub search response has no search connection".into(),
5147        })?;
5148        let mut found = Vec::new();
5149        for node in connection
5150            .get("nodes")
5151            .and_then(Value::as_array)
5152            .ok_or_else(|| SourceError::Malformed {
5153                message: "GitHub search nodes is not an array".into(),
5154            })?
5155        {
5156            if let Some(resolved) = self.resolve_issue(node).await? {
5157                found.push(resolved);
5158            }
5159        }
5160        let info = connection
5161            .get("pageInfo")
5162            .ok_or_else(|| SourceError::Malformed {
5163                message: "GitHub search connection has no pageInfo".into(),
5164            })?;
5165        let next = required_bool(info, "hasNextPage")?
5166            .then(|| required_str(info, "endCursor"))
5167            .transpose()?
5168            .map(str::to_owned);
5169        if let Some(next) = &next {
5170            validate_cursor_progress(after, next)?;
5171        }
5172        Ok((found, next))
5173    }
5174
5175    /// Every issue this board holds, completed with what this run wrote.
5176    ///
5177    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
5178    /// is an index and is eventually consistent, so an issue this run created seconds ago
5179    /// can be absent from it, and a project listed straight after being written would
5180    /// otherwise be missing from its own board. What is added back is only what this
5181    /// process itself wrote, out of [`Self::created`], which lives and dies with the
5182    /// process.
5183    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
5184        let found = self.searched_issues().await?;
5185        self.completed_with_written(found, |_| true)
5186    }
5187
5188    /// Every issue this board's own search reports, walked to exhaustion, read once per
5189    /// source.
5190    ///
5191    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
5192    /// needs it too and the two would otherwise walk the same search twice in one command.
5193    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
5194    /// is.
5195    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
5196        let cached = self.search_cache()?.clone();
5197        if let Some(held) = cached {
5198            return Ok(held);
5199        }
5200        let mut after: Option<String> = None;
5201        let mut found = Vec::new();
5202        let search = self.board_search(None);
5203        loop {
5204            let (page, next) = self
5205                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
5206                .await?;
5207            found.extend(page);
5208            match next {
5209                Some(next) => after = Some(next),
5210                None => break,
5211            }
5212        }
5213        *self.search_cache()? = Some(found.clone());
5214        Ok(found)
5215    }
5216
5217    /// Every issue this board's own search reports with `also` beside its scope, walked to
5218    /// exhaustion — held for no later read, which may not be narrowed alike.
5219    async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
5220        let mut after: Option<String> = None;
5221        let mut found = Vec::new();
5222        let search = self.board_search(Some(also));
5223        loop {
5224            let (page, next) = self
5225                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
5226                .await?;
5227            found.extend(page);
5228            match next {
5229                Some(next) => after = Some(next),
5230                None => return Ok(found),
5231            }
5232        }
5233    }
5234
5235    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
5236    fn search_cache(
5237        &self,
5238    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
5239        self.search_cache
5240            .lock()
5241            .map_err(|_| SourceError::Unavailable {
5242                message: "this source's view of the board's issues was left inconsistent by an \
5243                      earlier failure; next: run the command again"
5244                    .into(),
5245            })
5246    }
5247
5248    /// `found`, with everything this run wrote that `keep` accepts and the read did not
5249    /// report.
5250    ///
5251    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
5252    /// at all: the search index is behind, and a node read of an item filed moments ago can
5253    /// be too.
5254    fn completed_with_written(
5255        &self,
5256        mut found: Vec<Resolved>,
5257        keep: impl Fn(&Resolved) -> bool,
5258    ) -> Result<Vec<Resolved>, SourceError> {
5259        for own in self.created()?.iter().filter(|own| keep(own)) {
5260            if !found.iter().any(|item| item.id == own.id) {
5261                found.push(own.clone());
5262            }
5263        }
5264        Ok(found)
5265    }
5266
5267    /// What resolving one node id reached.
5268    ///
5269    /// Three answers rather than an `Option`, because a board *draft* is none of the other
5270    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
5271    /// is completed by a read of the draft itself rather than reported as nothing.
5272    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
5273        let asked = self
5274            .graphql(
5275                graphql::ISSUE,
5276                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
5277                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5278            )
5279            .await;
5280        let data = match asked {
5281            Ok(data) => data,
5282            // A string that is not a node id at all is not a failure to report: it is an id
5283            // this board does not hold, which is what every read of one already answers.
5284            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
5285            Err(error) => return Err(error),
5286        };
5287        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
5288            return Ok(Reached::Nothing);
5289        };
5290        if optional_str(node, "__typename")? == Some("DraftIssue") {
5291            return Ok(Reached::Draft);
5292        }
5293        Ok(match self.resolve_issue(node).await? {
5294            Some(item) => Reached::Held(Box::new(item)),
5295            None => Reached::Nothing,
5296        })
5297    }
5298
5299    /// One item of this board by its own id, whatever kind it is.
5300    ///
5301    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
5302    /// run wrote is read first, because a node read of an item created moments ago can
5303    /// still be behind the board field values written onto it — see [`Self::created`].
5304    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5305        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
5306            return Ok(Some(own.clone()));
5307        }
5308        if let Some(held) = self.read_ahead()?.remove(id) {
5309            return Ok(held);
5310        }
5311        match self.reach(id).await? {
5312            Reached::Held(item) => Ok(Some(*item)),
5313            Reached::Nothing => Ok(None),
5314            Reached::Draft => self.draft_by_id(id).await,
5315        }
5316    }
5317
5318    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
5319    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
5320    /// than one request per id.
5321    ///
5322    /// What this run wrote answers first, as it does there, and only the rest is read. One id
5323    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
5324    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
5325    /// id at a time, so that id is answered as not held and the others as themselves; a draft
5326    /// is completed by a read of the draft, exactly as there.
5327    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
5328        let mut found: Vec<Option<Option<Resolved>>> = {
5329            let created = self.created()?;
5330            ids.iter()
5331                .map(|id| {
5332                    created
5333                        .iter()
5334                        .find(|own| own.id == *id)
5335                        .map(|own| Some(own.clone()))
5336                })
5337                .collect()
5338        };
5339        let unread: Vec<NativeId> = ids
5340            .iter()
5341            .zip(&found)
5342            .filter(|(_, found)| found.is_none())
5343            .map(|(id, _)| id.clone())
5344            .collect();
5345        let mut read = Vec::with_capacity(unread.len());
5346        if let [one] = unread.as_slice() {
5347            read.push(self.item_by_id(one).await?);
5348        } else {
5349            for batch in unread.chunks(DETAIL_BATCH) {
5350                let data = match self
5351                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
5352                    .await
5353                {
5354                    Ok(data) => data,
5355                    Err(error) if unresolvable_node(&error) => {
5356                        for id in batch {
5357                            read.push(self.item_by_id(id).await?);
5358                        }
5359                        continue;
5360                    }
5361                    Err(error) => return Err(error),
5362                };
5363                for (slot, id) in batch.iter().enumerate() {
5364                    let node =
5365                        data.get(format!("i{slot}"))
5366                            .ok_or_else(|| SourceError::Malformed {
5367                                message: format!(
5368                                    "GitHub answered a batch read with no item for {}",
5369                                    id.0
5370                                ),
5371                            })?;
5372                    read.push(if node.is_null() {
5373                        None
5374                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
5375                        self.draft_by_id(id).await?
5376                    } else {
5377                        if optional_str(node, "__typename")? == Some("Issue")
5378                            && required_str(node, "id")? != id.0
5379                        {
5380                            return Err(SourceError::Malformed {
5381                                message: format!(
5382                                    "GitHub answered the read of {} with issue {}",
5383                                    id.0,
5384                                    required_str(node, "id")?
5385                                ),
5386                            });
5387                        }
5388                        self.resolve_issue(node).await?
5389                    });
5390                }
5391            }
5392        }
5393        let mut read = read.into_iter();
5394        Ok(found
5395            .iter_mut()
5396            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
5397            .collect())
5398    }
5399
5400    fn read_ahead(
5401        &self,
5402    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
5403        self.read_ahead
5404            .lock()
5405            .map_err(|_| SourceError::Unavailable {
5406                message:
5407                    "items read ahead for a copy were left inconsistent; run the command again"
5408                        .into(),
5409            })
5410    }
5411
5412    fn resolved_cache(
5413        &self,
5414    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
5415        self.resolved_cache
5416            .lock()
5417            .map_err(|_| SourceError::Unavailable {
5418                message: "resolved item records were left inconsistent; run the command again"
5419                    .into(),
5420            })
5421    }
5422
5423    /// Reuse a record this invocation already resolved. The mutation sender invalidates
5424    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
5425    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5426        let cached = self.resolved_cache()?.get(id).cloned();
5427        if let Some(item) = cached {
5428            return Ok(Some(item));
5429        }
5430        self.item_by_id(id).await
5431    }
5432
5433    /// Take out the item an issue read by reference reached for `id`, when one is held: the
5434    /// status write it was read for is the one caller, so no other read or write is ever
5435    /// answered from a read that selected only what that write needs.
5436    fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5437        Ok(self
5438            .held_cache
5439            .lock()
5440            .map_err(|_| SourceError::Unavailable {
5441                message: "items read by issue reference were left inconsistent; run the \
5442                          command again"
5443                    .into(),
5444            })?
5445            .remove(id))
5446    }
5447
5448    /// One board draft by its own id, with the board item it sits in — or `None` when no
5449    /// item of this board is that draft's.
5450    ///
5451    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
5452    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
5453    /// links a draft to one board item, so the page this read carries is the whole of that
5454    /// connection, and a page that reports more than it holds is refused rather than read
5455    /// as an answer about memberships nobody read.
5456    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5457        let data = self
5458            .graphql(
5459                graphql::DRAFT,
5460                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
5461                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
5462            )
5463            .await?;
5464        // Gone between the two reads is an answer — the draft is no longer there. Anything
5465        // else than the draft [`Self::reach`] was just told this id is, is not one.
5466        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
5467            return Ok(None);
5468        };
5469        if optional_str(draft, "__typename")? != Some("DraftIssue") {
5470            return Err(SourceError::Malformed {
5471                message: format!(
5472                    "GitHub answered {} as a draft and then as something else",
5473                    id.0
5474                ),
5475            });
5476        }
5477        if required_str(draft, "id")? != id.0 {
5478            return Err(SourceError::Malformed {
5479                message: format!("GitHub answered a different draft for {}", id.0),
5480            });
5481        }
5482        let memberships = draft
5483            .get("projectV2Items")
5484            .ok_or_else(|| SourceError::Malformed {
5485                message: format!("GitHub draft {} is missing projectV2Items", id.0),
5486            })?;
5487        let nodes = memberships
5488            .get("nodes")
5489            .and_then(Value::as_array)
5490            .ok_or_else(|| SourceError::Malformed {
5491                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
5492            })?;
5493        let info = memberships
5494            .get("pageInfo")
5495            .ok_or_else(|| SourceError::Malformed {
5496                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
5497            })?;
5498        // Read whether or not this board's entry is on the page: a page claiming more than
5499        // the one item GitHub links a draft to is a malformed answer either way.
5500        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
5501            return Err(SourceError::Malformed {
5502                message: format!(
5503                    "GitHub draft {} reports more board items than the one GitHub links a draft \
5504                     to",
5505                    id.0
5506                ),
5507            });
5508        }
5509        if let Some(node) = nodes.first()
5510            && node
5511                .pointer("/project/number")
5512                .and_then(Value::as_u64)
5513                .is_none()
5514        {
5515            return Err(SourceError::Malformed {
5516                message: format!(
5517                    "GitHub draft {} board item has no numeric project number",
5518                    id.0
5519                ),
5520            });
5521        }
5522        let Some(held) = self.board_entry(nodes) else {
5523            return Ok(None);
5524        };
5525        if required_str(
5526            held.get("project").ok_or_else(|| SourceError::Malformed {
5527                message: format!("GitHub draft {} board item has no project", id.0),
5528            })?,
5529            "id",
5530        )? != self.board_fields().await?.id.as_str()
5531        {
5532            return Ok(None);
5533        }
5534        let item = json!({
5535            "id": required_str(held, "id")?,
5536            "project": held.get("project"),
5537            "fieldValues": held.get("fieldValues"),
5538            "content": draft,
5539        });
5540        self.resolve(&item)
5541    }
5542
5543    /// The board's own id and field definitions, for a write whose item does not carry
5544    /// them — never its items.
5545    ///
5546    /// A board this command has already listed supplies them, since it read them beside its
5547    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
5548    /// is consulted about which items the board holds: see the module documentation for
5549    /// why a question about one known item is answered by reading that item.
5550    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
5551        if let Some(board) = self.board_cache()?.as_ref() {
5552            return Ok(BoardFields {
5553                id: BoardId::parse(&board.id)?,
5554                fields: board.fields.clone(),
5555            });
5556        }
5557        if let Some(held) = self.fields_cache()?.clone() {
5558            return Ok(held);
5559        }
5560        let data = self
5561            .graphql(
5562                graphql::BOARD_FIELDS,
5563                json!({"owner":self.owner,"number":self.project_number,
5564                       "nestedFirst":NESTED_PAGE_SIZE}),
5565            )
5566            .await?;
5567        self.fields_read(&data)
5568    }
5569
5570    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
5571    /// the rest of this command.
5572    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
5573        let board = data
5574            .pointer("/boardFields/projectV2")
5575            .filter(|value| !value.is_null())
5576            .ok_or_else(|| SourceError::Refused {
5577                message: format!(
5578                    "GitHub project {}/{} was not found or is not visible to the token",
5579                    self.owner, self.project_number
5580                ),
5581            })?;
5582        let read = BoardFields {
5583            id: BoardId::parse(required_str(board, "id")?)?,
5584            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5585        };
5586        *self.fields_cache()? = Some(read.clone());
5587        Ok(read)
5588    }
5589
5590    /// Read what creating an issue in `repository` needs and this command has not read yet —
5591    /// the board's fields and the repository's node id — in one request when it needs both.
5592    ///
5593    /// When either is already known this sends nothing, and the other is read by its own
5594    /// document where it is asked for, so no create reads anything twice.
5595    async fn creation_context(
5596        &self,
5597        repository: &RepositoryTarget,
5598        incoming: &Incoming<'_>,
5599    ) -> Result<(), SourceError> {
5600        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
5601        if fields_known || self.repository_cache()?.contains_key(repository) {
5602            return Ok(());
5603        }
5604        let data = self
5605            .graphql(
5606                graphql::CREATION_CONTEXT,
5607                json!({"owner":self.owner,"number":self.project_number,
5608                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
5609                       "repositoryName":repository.name}),
5610            )
5611            .await?;
5612        self.fields_read(&data)?;
5613        self.repository_read(&data, repository, incoming)?;
5614        Ok(())
5615    }
5616
5617    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
5618    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
5619        self.fields_cache
5620            .lock()
5621            .map_err(|_| SourceError::Unavailable {
5622                message: "this source's view of the board's fields was left inconsistent by an \
5623                      earlier failure; next: run the command again"
5624                    .into(),
5625            })
5626    }
5627
5628    /// What a write to `item` needs of the board, read off that item when it says enough and
5629    /// off [`Self::board_fields`] when it does not.
5630    ///
5631    /// A node read of an item names its board and carries the definition of every field it
5632    /// holds a value of — so an item naming its board, holding a value of the origin field,
5633    /// and, when the write carries a status, holding a `Status` value, needs no read of the
5634    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
5635    /// of may still be on the board, and a view reading it as absent would refuse a write the
5636    /// board can take or skip a field write the board needs, so such an item — and a create,
5637    /// which has no item yet — takes the board's fields from their own read instead.
5638    async fn fields_for(
5639        &self,
5640        item: Option<&Resolved>,
5641        writes_status: bool,
5642        selects_priority: bool,
5643    ) -> Result<BoardFields, SourceError> {
5644        if let Some(board) = item.and_then(Resolved::carried_board) {
5645            return Ok(board);
5646        }
5647        if let Some(item) = item
5648            && let Some(board_id) = item.named_board()
5649            && item.defines(ORIGIN_FIELD)
5650            && (!writes_status || item.defines("Status"))
5651            && (!selects_priority || item.defines(PRIORITY_FIELD))
5652            && self
5653                .metadata_fields
5654                .iter()
5655                .all(|projection| item.defines(&projection.field))
5656        {
5657            return Ok(BoardFields {
5658                id: board_id,
5659                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5660            });
5661        }
5662        self.board_fields().await
5663    }
5664
5665    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
5666    /// when that id names nothing here with a sub-issue relationship to walk.
5667    ///
5668    /// `None` and an empty answer are different: `None` is *this is not an issue of this
5669    /// GitHub*, which is what sends a project selector on to be read as a name, and an
5670    /// empty vector is a project that holds nothing.
5671    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
5672        let mut after: Option<String> = None;
5673        let mut children = Vec::new();
5674        // The project issue as the first page carried it, held once the walk has ended.
5675        let mut parent: Option<Resolved> = None;
5676        loop {
5677            let asked = self
5678                .graphql(
5679                    graphql::SUB_ISSUES,
5680                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
5681                           "nestedFirst":NESTED_PAGE_SIZE,
5682                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5683                )
5684                .await;
5685            let data = match asked {
5686                Ok(data) => data,
5687                // A string that is not a node id at all is not a failure to report: it is
5688                // the ordinary answer to a selector naming a project by its name.
5689                Err(error) if unresolvable_node(&error) => return Ok(None),
5690                Err(error) => return Err(error),
5691            };
5692            let Some(connection) = data
5693                .pointer("/node/subIssues")
5694                .filter(|value| !value.is_null())
5695            else {
5696                // No such node, or one with no sub-issue relationship — a board draft is
5697                // the one this board can really hold.
5698                return Ok(None);
5699            };
5700            if after.is_none()
5701                && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
5702            {
5703                parent = self
5704                    .resolve_issue(node)
5705                    .await?
5706                    .filter(|resolved| resolved.id == *id);
5707            }
5708            for node in connection
5709                .get("nodes")
5710                .and_then(Value::as_array)
5711                .ok_or_else(|| SourceError::Malformed {
5712                    message: "GitHub subIssues.nodes is not an array".into(),
5713                })?
5714            {
5715                if let Some(resolved) = self.resolve_issue(node).await? {
5716                    children.push(resolved);
5717                }
5718            }
5719            let info = connection
5720                .get("pageInfo")
5721                .ok_or_else(|| SourceError::Malformed {
5722                    message: "GitHub subIssues connection has no pageInfo".into(),
5723                })?;
5724            let next = required_bool(info, "hasNextPage")?
5725                .then(|| required_str(info, "endCursor"))
5726                .transpose()?;
5727            match next {
5728                Some(next) => {
5729                    validate_cursor_progress(after.as_deref(), next)?;
5730                    after = Some(next.to_owned());
5731                }
5732                None => {
5733                    // Held only once the walk has ended, so one failing part way holds
5734                    // nothing and every read it would have answered is made.
5735                    let mut carried = self.carried()?;
5736                    for item in parent.into_iter().chain(children.iter().cloned()) {
5737                        carried.insert(item.id.clone(), item);
5738                    }
5739                    return Ok(Some(children));
5740                }
5741            }
5742        }
5743    }
5744
5745    fn carried(
5746        &self,
5747    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
5748        self.carried.lock().map_err(|_| SourceError::Unavailable {
5749            message: "items a project's listing carried were left inconsistent; run the \
5750                      command again"
5751                .into(),
5752        })
5753    }
5754
5755    /// Which issue of this board a project *name* is, or `None` when none is.
5756    ///
5757    /// One bounded query which filters on that name at the server, rather than a walk of
5758    /// every issue the board holds. The name is compared again here: the qualifier narrows
5759    /// what GitHub sends, and this source decides what it names.
5760    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
5761        let search = self.board_search(Some(&title_qualifier(name)));
5762        let mut after = None;
5763        loop {
5764            let (candidates, next) = self
5765                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5766                .await?;
5767            if let Some(item) = candidates.into_iter().find(|item| {
5768                item.kind == BoardKind::Work(ItemKind::Project)
5769                    && item.title.eq_ignore_ascii_case(name)
5770            }) {
5771                return Ok(Some(item.id));
5772            }
5773            match next {
5774                Some(next) => after = Some(next),
5775                None => return Ok(None),
5776            }
5777        }
5778    }
5779
5780    /// Everything filed under one project of this board: the sub-issues of the issue that
5781    /// project is.
5782    ///
5783    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
5784    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
5785    /// gains projects, or as another project gains tasks.
5786    ///
5787    /// A qualified id names the issue and is asked for its sub-issues directly: one
5788    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
5789    /// read as a project *name*, which costs the one bounded search
5790    /// [`Self::project_by_name`] makes.
5791    ///
5792    /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
5793    /// — and each answer is still cut to the items whose parent is this project, so one this
5794    /// process has since filed elsewhere is not reported here, and completed with what this
5795    /// process filed under it.
5796    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
5797        let held = self.children_cache()?.get(selector).cloned();
5798        let (project, children) = match held {
5799            Some(held) => held,
5800            None => {
5801                let answered = match self.sub_issues(selector).await? {
5802                    Some(children) => (selector.clone(), children),
5803                    None => match self.project_by_name(&selector.0).await? {
5804                        Some(project) => {
5805                            let children = self.sub_issues(&project).await?.unwrap_or_default();
5806                            (project, children)
5807                        }
5808                        None => return Ok(Vec::new()),
5809                    },
5810                };
5811                self.children_cache()?
5812                    .insert(selector.clone(), answered.clone());
5813                answered
5814            }
5815        };
5816        let mut children: Vec<Resolved> = children
5817            .into_iter()
5818            .filter(|child| child.parent.as_ref() == Some(&project))
5819            .collect();
5820        for own in self.updated()?.iter() {
5821            if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
5822                children.push(own.clone());
5823            }
5824        }
5825        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
5826    }
5827
5828    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
5829    /// completed with what this run wrote — the candidates a comment-activity read confirms.
5830    ///
5831    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
5832    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
5833    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
5834    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
5835    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
5836    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
5837    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
5838    /// rather than silently narrowing a caller's answer.
5839    ///
5840    /// The instant is written to the second, rounded down, which can only widen what the
5841    /// search returns; confirmation against each candidate's own comments is what makes the
5842    /// answer exact. The search is an index that lags a write by a second or two — the module
5843    /// documentation records it — so a caller that asks again from its last instant should
5844    /// overlap the two by more than that.
5845    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
5846        let found = self.searched(&updated_qualifier(since)).await?;
5847        self.completed_with_written(found, |_| true)
5848    }
5849
5850    /// Every issue of this board GitHub's issue search reports for the board-scoped search
5851    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
5852    ///
5853    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
5854    /// own record is the fresher of the two.
5855    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
5856        let search = self.board_search(Some(also));
5857        let mut after: Option<String> = None;
5858        let mut found = Vec::new();
5859        loop {
5860            let (page, next) = self
5861                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5862                .await?;
5863            found.extend(page);
5864            match next {
5865                Some(next) => after = Some(next),
5866                None => return Ok(found),
5867            }
5868        }
5869    }
5870
5871    /// A bounded task answer; the versioned cursor carries the connection position, how
5872    /// many rows of the page starting there were already handed out, and the own-write ids
5873    /// already observed, including across a new source instance.
5874    ///
5875    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
5876    /// sliced from the pages it needs; why is the module documentation's paging contract.
5877    async fn search_tasks(
5878        &self,
5879        query: &TaskQuery,
5880        page: &PageRequest,
5881        also: &str,
5882    ) -> Result<Page<Task>, SourceError> {
5883        let mut position = match &page.cursor {
5884            None => SearchPosition::default(),
5885            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
5886                .ok()
5887                .filter(|position| {
5888                    position.version == SEARCH_CURSOR_VERSION
5889                        && position.connection.valid_resume(position.offset)
5890                })
5891                .ok_or_else(|| SourceError::Config {
5892                    message: "page cursor is invalid".into(),
5893                })?,
5894        };
5895        let search = self.board_search(Some(also));
5896        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5897        let own = self.with_own_writes(Vec::new())?;
5898        // An issue this process commented on is a candidate of a comment-activity read
5899        // whether or not the search has caught up with the comment; see `Self::commented`.
5900        let commented = match query.commented_since {
5901            Some(_) => self.commented()?.clone(),
5902            None => Vec::new(),
5903        };
5904        for id in own.iter().map(|item| &item.id).chain(&commented) {
5905            if !position.own.contains(id) {
5906                position.own.push(id.clone());
5907            }
5908        }
5909        let mut tasks = Vec::new();
5910        while !position.connection.exhausted() && tasks.len() < limit {
5911            let first = SEARCH_PAGE_SIZE;
5912            // Page size is part of the key: a short cached answer cannot answer a wider ask.
5913            let key =
5914                serde_json::to_string(&("page", &search, &position.connection.after(), first))
5915                    .expect("search page key is serializable");
5916            let cached = if query.commented_since.is_none() {
5917                self.narrowed_cache()?.get(&key).cloned()
5918            } else {
5919                None
5920            };
5921            let (found, next) = match cached {
5922                Some(found) => {
5923                    let next = self
5924                        .search_next
5925                        .lock()
5926                        .map_err(|_| SourceError::Unavailable {
5927                            message:
5928                                "search pagination was left inconsistent; run the command again"
5929                                    .into(),
5930                        })?
5931                        .get(&key)
5932                        .cloned()
5933                        .flatten();
5934                    (found, next)
5935                }
5936                None => {
5937                    let (found, next) = self
5938                        .search_page(&search, first, position.connection.after())
5939                        .await?;
5940                    if query.commented_since.is_none() {
5941                        self.search_next
5942                            .lock()
5943                            .map_err(|_| SourceError::Unavailable {
5944                                message:
5945                                    "search pagination was left inconsistent; run the command again"
5946                                        .into(),
5947                            })?
5948                            .insert(key.clone(), next.clone());
5949                        self.narrowed_cache()?.insert(key, found.clone());
5950                    }
5951                    (found, next)
5952                }
5953            };
5954            let rows = found.len();
5955            for mut item in found.into_iter().skip(position.offset) {
5956                if tasks.len() == limit {
5957                    break;
5958                }
5959                position.offset += 1;
5960                if position.own.contains(&item.id) {
5961                    if position.seen.contains(&item.id) {
5962                        continue;
5963                    }
5964                    position.seen.push(item.id.clone());
5965                    // The search's own copy of an issue this process only commented on is as
5966                    // good as a node read of it, since its comments are read either way.
5967                    let only_commented = commented.contains(&item.id)
5968                        && !own.iter().any(|written| written.id == item.id);
5969                    if !only_commented {
5970                        let updated_at = item.updated_at;
5971                        let Some(written) = self.search_written(&own, &item.id).await? else {
5972                            continue;
5973                        };
5974                        item = written;
5975                        item.updated_at = item.updated_at.max(updated_at);
5976                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
5977                    }
5978                }
5979                if item.kind == BoardKind::Work(ItemKind::Task) {
5980                    let task = item.task()?;
5981                    if task_matches(&task, query, &query.project)
5982                        && self.commented_since(&item, query.commented_since).await?
5983                    {
5984                        tasks.push(task);
5985                    }
5986                }
5987            }
5988            if position.offset < rows {
5989                continue;
5990            }
5991            position.offset = 0;
5992            position.connection = match next {
5993                Some(after) => SearchConnection::Continuing {
5994                    after: Cursor(after),
5995                },
5996                None => SearchConnection::Exhausted {},
5997            };
5998        }
5999        if position.connection.exhausted() {
6000            for id in position.own.clone() {
6001                if position.seen.contains(&id) {
6002                    continue;
6003                }
6004                if tasks.len() == limit {
6005                    break;
6006                }
6007                position.seen.push(id.clone());
6008                let Some(item) = self.search_written(&own, &id).await? else {
6009                    continue;
6010                };
6011                if item.kind == BoardKind::Work(ItemKind::Task) {
6012                    let task = item.task()?;
6013                    if task_matches(&task, query, &query.project)
6014                        && self.commented_since(&item, query.commented_since).await?
6015                    {
6016                        tasks.push(task);
6017                    }
6018                }
6019            }
6020        }
6021        let more = !position.connection.exhausted()
6022            || position.own.iter().any(|id| !position.seen.contains(id));
6023        Ok(Page {
6024            items: tasks,
6025            next: more.then(|| {
6026                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
6027            }),
6028        })
6029    }
6030
6031    /// A resumed process has the ids but no write records; resolve only a record the
6032    /// current page needs, by its uncached node read rather than the lagging search index.
6033    async fn search_written(
6034        &self,
6035        own: &[Resolved],
6036        id: &NativeId,
6037    ) -> Result<Option<Resolved>, SourceError> {
6038        match own.iter().find(|item| item.id == *id) {
6039            Some(item) => Ok(Some(item.clone())),
6040            None => self.item_by_id(id).await,
6041        }
6042    }
6043
6044    /// The candidates for a task query carrying a text, metadata or origin predicate, read
6045    /// without enumerating the board — or `None` for a query carrying none of the three, which
6046    /// keeps the reads it always had.
6047    ///
6048    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
6049    /// because it names at most a handful of items. Text and metadata are answered by one
6050    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
6051    /// further by `updated:>=` when the query also asks for comment activity, since both
6052    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
6053    /// process afterwards by the same predicates [`task_matches`] applies to every read.
6054    ///
6055    /// Completed with what this process wrote, its own record winning over the index's copy
6056    /// of the same item: see [`Self::with_own_writes`].
6057    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
6058        let asked = match (&query.origin, narrowing_qualifiers(query)) {
6059            (Some(origin), _) => Narrowing::Origin(origin.clone()),
6060            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
6061                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
6062                None => qualifiers,
6063            }),
6064            (None, None) => return Ok(None),
6065        };
6066        // A question about comment activity is asked afresh every time, as it always was: it
6067        // is the one a caller polls from one source while waiting for the index, and an
6068        // answer held from the first poll would be the answer to every later one.
6069        let key = query.commented_since.is_none().then(|| asked.key());
6070        let cached = match &key {
6071            Some(key) => self.narrowed_cache()?.get(key).cloned(),
6072            None => None,
6073        };
6074        let found = match cached {
6075            Some(found) => found,
6076            None => {
6077                let found = match &asked {
6078                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
6079                    Narrowing::Search(also) => self.searched(also).await?,
6080                };
6081                if let Some(key) = key {
6082                    self.narrowed_cache()?.insert(key, found.clone());
6083                }
6084                found
6085            }
6086        };
6087        self.with_own_writes(found).map(Some)
6088    }
6089
6090    /// The candidates for a project or unscoped document query carrying a searchable text,
6091    /// read without enumerating the board — or `None` for a query with no text or a blank one,
6092    /// which keeps the read it always had.
6093    ///
6094    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
6095    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
6096    /// costs is the issues that match and never the board. Its answer is held for the command
6097    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
6098    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
6099    /// substring rule, exactly as an item of the wider read was, and is completed with what this
6100    /// process wrote: see [`Self::with_own_writes`].
6101    async fn text_searched(
6102        &self,
6103        text: Option<&TextQuery>,
6104    ) -> Result<Option<Vec<Resolved>>, SourceError> {
6105        let Some(also) = text_qualifiers(text) else {
6106            return Ok(None);
6107        };
6108        let key = Narrowing::Search(also.clone()).key();
6109        let cached = self.narrowed_cache()?.get(&key).cloned();
6110        let found = match cached {
6111            Some(found) => found,
6112            None => {
6113                let found = self.searched(&also).await?;
6114                self.narrowed_cache()?.insert(key, found.clone());
6115                found
6116            }
6117        };
6118        self.with_own_writes(found).map(Some)
6119    }
6120
6121    /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
6122    /// [`Self::narrowed`] reads for a task query carrying that origin — one
6123    /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
6124    /// what this process wrote — each confirmed against its own origin entry.
6125    ///
6126    /// What answers a project or document listing by origin, so finding every copy of one plan
6127    /// costs the items that carry its origin and never a page of the board.
6128    async fn copied_from(
6129        &self,
6130        origin: &str,
6131        kind: BoardKind,
6132    ) -> Result<Vec<Resolved>, SourceError> {
6133        let wanted = TaskQuery {
6134            origin: Some(origin.to_owned()),
6135            ..TaskQuery::default()
6136        };
6137        Ok(self
6138            .narrowed(&wanted)
6139            .await?
6140            .unwrap_or_default()
6141            .into_iter()
6142            .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
6143            .collect())
6144    }
6145
6146    /// Every item of this board that may carry `origin` — a superset of those that do — found
6147    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
6148    ///
6149    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
6150    /// which reads the field every carrier holds, whichever release wrote it — and the
6151    /// board-scoped issue search for the same id as a phrase in the body, where this source
6152    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
6153    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
6154    /// query's, exactly.
6155    ///
6156    /// Both connections are walked to exhaustion, each from its own cursor. One that has
6157    /// already ended is sent its last cursor again, which answers an empty page, so the one
6158    /// document serves every page of either. What the two leave is stated in the module
6159    /// documentation: a carrier another process added within the last second or two, before
6160    /// either index has it.
6161    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
6162        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
6163        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
6164        let mut items_after: Option<String> = None;
6165        let mut search_after: Option<String> = None;
6166        let mut found: Vec<Resolved> = Vec::new();
6167        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
6168            if !found.iter().any(|held| held.id == resolved.id) {
6169                found.push(resolved);
6170            }
6171        };
6172        loop {
6173            let data = self
6174                .graphql(
6175                    graphql::ORIGIN_LOOKUP,
6176                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
6177                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
6178                           "itemsAfter":items_after,"searchAfter":search_after,
6179                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6180                           "duplicates":true}),
6181                )
6182                .await?;
6183            let items = data
6184                .pointer("/originItems/projectV2/items")
6185                .filter(|value| !value.is_null())
6186                .ok_or_else(|| SourceError::Refused {
6187                    message: format!(
6188                        "GitHub project {}/{} was not found or is not visible to the token",
6189                        self.owner, self.project_number
6190                    ),
6191                })?;
6192            for item in optional_nodes(Some(items), "project items")?
6193                .into_iter()
6194                .flatten()
6195            {
6196                // The board's own items list its drafts too, and a draft is not an issue: no
6197                // narrowed read answers with one, whatever its origin field holds.
6198                if let Some(resolved) = self.resolve(item)?
6199                    && resolved.content_kind == ContentKind::Issue
6200                {
6201                    keep(resolved, &mut found);
6202                }
6203            }
6204            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
6205                message: "GitHub search response has no search connection".into(),
6206            })?;
6207            for node in optional_nodes(Some(searched), "search")?
6208                .into_iter()
6209                .flatten()
6210            {
6211                if let Some(resolved) = self.resolve_issue(node).await? {
6212                    keep(resolved, &mut found);
6213                }
6214            }
6215            let items_next = resumed(items, items_after.as_deref())?;
6216            let search_next = resumed(searched, search_after.as_deref())?;
6217            if !items_next.has_more() && !search_next.has_more() {
6218                return Ok(found);
6219            }
6220            items_after = items_next.cursor();
6221            search_after = search_next.cursor();
6222        }
6223    }
6224
6225    /// `found`, with every item this process created or wrote in its place, and every one of
6226    /// them the read did not report added.
6227    ///
6228    /// This process's own record wins over the read's copy of the same item, because a read
6229    /// of an item written moments ago can still be behind what was written onto it — the
6230    /// origin field included, which is the one a narrowed read is confirmed against — and a
6231    /// read that still names an item under a predicate this process's write moved it out of
6232    /// must not return it. The one thing the read knows that the record cannot is when GitHub
6233    /// last saw the item change, which is what a comment-activity read rules a candidate out
6234    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
6235    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
6236    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
6237        // A board draft is not an issue, so no narrowed read returns one, and this process
6238        // having written one does not make it an answer either.
6239        let own: Vec<Resolved> = self
6240            .created()?
6241            .iter()
6242            .chain(self.updated()?.iter())
6243            .filter(|own| own.content_kind == ContentKind::Issue)
6244            .cloned()
6245            .collect();
6246        for mut own in own {
6247            self.resolved_cache()?.insert(own.id.clone(), own.clone());
6248            match found.iter_mut().find(|read| read.id == own.id) {
6249                Some(read) => {
6250                    own.updated_at = own.updated_at.max(read.updated_at);
6251                    *read = own;
6252                }
6253                None => found.push(own),
6254            }
6255        }
6256        Ok(found)
6257    }
6258
6259    /// Whether `item` has a comment created or last edited at or after `since` — always, when
6260    /// there is no instant to hold it to.
6261    ///
6262    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
6263    /// or after the instant moved it there: an issue not updated since holds no such comment,
6264    /// and its comments are never asked for — unless this process commented on it in this
6265    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
6266    /// only as far as the first that matches. A board draft is not an issue and has no
6267    /// comments, so it never matches.
6268    async fn commented_since(
6269        &self,
6270        item: &Resolved,
6271        since: Option<DateTime<Utc>>,
6272    ) -> Result<bool, SourceError> {
6273        let Some(since) = since else {
6274            return Ok(true);
6275        };
6276        if item.content_kind == ContentKind::DraftIssue {
6277            return Ok(false);
6278        }
6279        // An `updatedAt` this process's own record or a lagging index holds can predate a
6280        // comment this process wrote since, so only an issue it did not comment on is ruled
6281        // out by one.
6282        if item.updated_at.is_some_and(|updated| updated < since)
6283            && !self.commented()?.contains(&item.id)
6284        {
6285            return Ok(false);
6286        }
6287        let query = TaskQuery {
6288            commented_since: Some(since),
6289            ..TaskQuery::default()
6290        };
6291        let mut after: Option<String> = None;
6292        loop {
6293            let data = self
6294                .graphql(
6295                    graphql::ISSUE_COMMENTS,
6296                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
6297                )
6298                .await?;
6299            let Some(connection) = data
6300                .get("node")
6301                .filter(|value| !value.is_null())
6302                .and_then(|node| node.get("comments"))
6303                .filter(|value| !value.is_null())
6304            else {
6305                // Removed since the search reported it: no longer an issue with comments.
6306                return Ok(false);
6307            };
6308            let comments = optional_nodes(Some(connection), "issue comments")?
6309                .into_iter()
6310                .flatten()
6311                .map(comment_from)
6312                .collect::<Result<Vec<_>, _>>()?;
6313            if query.comments_match(&comments) {
6314                return Ok(true);
6315            }
6316            match next_cursor(connection)? {
6317                Some(next) => {
6318                    validate_cursor_progress(after.as_deref(), &next.0)?;
6319                    after = Some(next.0);
6320                }
6321                None => return Ok(false),
6322            }
6323        }
6324    }
6325
6326    /// Every item on the board: the union of both enumerations GitHub offers of one.
6327    ///
6328    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
6329    /// board **draft** and reads the board's own fields beside its items, and only the search
6330    /// reports an item that connection is behind on. The module documentation is where the lag and the
6331    /// measurements behind it are written down.
6332    ///
6333    /// A search result is admitted on the same terms as any other issue this source reaches
6334    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
6335    /// names *this* board — so an issue the index still believes is here after it was taken
6336    /// off is refused rather than reported.
6337    ///
6338    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
6339    /// which is what the cache could otherwise have broken.
6340    async fn board(&self) -> Result<Board, SourceError> {
6341        self.board_completed_by(None).await
6342    }
6343
6344    /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
6345    /// that half could add is then answered only if it holds.
6346    async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
6347        let cached = self.board_cache()?.clone();
6348        let mut board = match cached {
6349            Some(board) => board,
6350            None => {
6351                let read = self.read_board().await?;
6352                *self.board_cache()? = Some(read.clone());
6353                read
6354            }
6355        };
6356        let searched = match also {
6357            None => self.searched_issues().await?,
6358            Some(also) => self.searched_narrowly(also).await?,
6359        };
6360        for held in searched {
6361            if !board.items.iter().any(|item| item.id == held.id) {
6362                board.items.push(held);
6363            }
6364        }
6365        for own in self.created()?.iter() {
6366            if !board.items.iter().any(|item| item.id == own.id) {
6367                board.items.push(own.clone());
6368            }
6369        }
6370        Ok(board)
6371    }
6372
6373    /// This process's own view of the board, or the refusal a poisoned lock is.
6374    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
6375        self.board_cache
6376            .lock()
6377            .map_err(|_| SourceError::Unavailable {
6378                message: "this source's view of the board was left inconsistent by an earlier \
6379                      failure; next: run the command again"
6380                    .into(),
6381            })
6382    }
6383
6384    /// Bring this process's own view of the board up to an item it has just written.
6385    ///
6386    /// A created item goes to `created`, which is what completes a board read GitHub's own
6387    /// eventual consistency has left behind. An item that was already there is replaced
6388    /// where it sits, so a second write of it in the same command reads its real parent
6389    /// rather than the one it had before the first write.
6390    ///
6391    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
6392    /// that wins: an item this same run created is held in `created` and not in the cached
6393    /// board, and `board` completes the cached board *from* `created`, so replacing only
6394    /// the cached copy of such an item replaces nothing and the read still reports the
6395    /// title it was created with. The search is the third, and it is the one an item the
6396    /// board's own projection is behind on sits in *alone* — which is exactly the item this
6397    /// source is least able to re-read, so leaving it out would put the stale title back on
6398    /// the only items the completion in [`Self::board`] exists for.
6399    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
6400        self.resolved_cache()?.insert(item.id.clone(), item.clone());
6401        if created {
6402            self.created()?.push(item);
6403            return Ok(());
6404        }
6405        {
6406            let mut own = self.created()?;
6407            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
6408                *held = item;
6409                return Ok(());
6410            }
6411        }
6412        {
6413            let mut own = self.updated()?;
6414            match own.iter_mut().find(|held| held.id == item.id) {
6415                Some(held) => *held = item.clone(),
6416                None => own.push(item.clone()),
6417            }
6418        }
6419        if let Some(board) = self.board_cache()?.as_mut()
6420            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
6421        {
6422            *held = item.clone();
6423        }
6424        if let Some(found) = self.search_cache()?.as_mut()
6425            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
6426        {
6427            *held = item.clone();
6428        }
6429        for found in self.narrowed_cache()?.values_mut() {
6430            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6431                *held = item.clone();
6432            }
6433        }
6434        for (_, found) in self.children_cache()?.values_mut() {
6435            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6436                *held = item.clone();
6437            }
6438        }
6439        Ok(())
6440    }
6441
6442    /// Forget one item this process has just deleted, from every half of its own view.
6443    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
6444        self.resolved_cache()?.remove(id);
6445        self.read_ahead()?.remove(id);
6446        self.carried()?.remove(id);
6447        self.created()?.retain(|own| own.id != *id);
6448        self.updated()?.retain(|own| own.id != *id);
6449        self.commented()?.retain(|own| own != id);
6450        if let Some(board) = self.board_cache()?.as_mut() {
6451            board.items.retain(|item| item.id != *id);
6452        }
6453        if let Some(found) = self.search_cache()?.as_mut() {
6454            found.retain(|item| item.id != *id);
6455        }
6456        for found in self.narrowed_cache()?.values_mut() {
6457            found.retain(|item| item.id != *id);
6458        }
6459        for (_, found) in self.children_cache()?.values_mut() {
6460            found.retain(|item| item.id != *id);
6461        }
6462        Ok(())
6463    }
6464
6465    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
6466    fn narrowed_cache(
6467        &self,
6468    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
6469        self.narrowed_cache
6470            .lock()
6471            .map_err(|_| SourceError::Unavailable {
6472                message: "this source's view of a narrowed read was left inconsistent by an \
6473                      earlier failure; next: run the command again"
6474                    .into(),
6475            })
6476    }
6477
6478    /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
6479    /// is.
6480    fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
6481        self.children_cache
6482            .lock()
6483            .map_err(|_| SourceError::Unavailable {
6484                message: "this source's view of a project's tasks was left inconsistent by an \
6485                      earlier failure; next: run the command again"
6486                    .into(),
6487            })
6488    }
6489
6490    /// Every page of the board, read from GitHub.
6491    async fn read_board(&self) -> Result<Board, SourceError> {
6492        let mut after: Option<String> = None;
6493        let mut items = Vec::new();
6494        let mut board;
6495        loop {
6496            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
6497            for item in page
6498                .pointer("/items/nodes")
6499                .and_then(Value::as_array)
6500                .ok_or_else(|| SourceError::Malformed {
6501                    message: "GitHub project items.nodes is not an array".into(),
6502                })?
6503            {
6504                if let Some(resolved) = self.resolve(item)? {
6505                    items.push(resolved);
6506                }
6507            }
6508            let info = page
6509                .pointer("/items/pageInfo")
6510                .ok_or_else(|| SourceError::Malformed {
6511                    message: "GitHub project items have no pageInfo".into(),
6512                })?;
6513            let has_next = required_bool(info, "hasNextPage")?;
6514            let next = has_next
6515                .then(|| required_str(info, "endCursor"))
6516                .transpose()?;
6517            board = page.clone();
6518            match next {
6519                Some(next) => {
6520                    validate_cursor_progress(after.as_deref(), next)?;
6521                    after = Some(next.to_owned());
6522                }
6523                None => break,
6524            }
6525        }
6526        Ok(Board {
6527            id: required_str(&board, "id")?.to_owned(),
6528            fields: board.get("fields").cloned().unwrap_or(Value::Null),
6529            items,
6530        })
6531    }
6532
6533    /// The existing items this source has written, for completing a narrowed read that is
6534    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
6535    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6536        self.updated.lock().map_err(|_| SourceError::Unavailable {
6537            message: "this source's record of what it wrote in this run was left inconsistent \
6538                      by an earlier failure; next: run the command again"
6539                .into(),
6540        })
6541    }
6542
6543    /// The issues this source has commented on in this command; see
6544    /// [`Self::commented`](GitHubProjectsSource::commented).
6545    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
6546        self.commented.lock().map_err(|_| SourceError::Unavailable {
6547            message: "this source's record of what it commented on in this run was left \
6548                      inconsistent by an earlier failure; next: run the command again"
6549                .into(),
6550        })
6551    }
6552
6553    /// Called only once GitHub has answered the comment write, so an issue whose comment
6554    /// failed is never made a candidate a later read would pay a node read for.
6555    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
6556        let mut commented = self.commented()?;
6557        if !commented.contains(issue) {
6558            commented.push(issue.clone());
6559        }
6560        Ok(())
6561    }
6562
6563    /// The items this source has created, for completing a board read that is behind.
6564    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6565        self.created.lock().map_err(|_| SourceError::Unavailable {
6566            message: "this source's record of what it created in this run was left \
6567                      inconsistent by an earlier failure; next: run the command again"
6568                .into(),
6569        })
6570    }
6571
6572    /// One board item as this source reports it, or `None` for content it ignores.
6573    ///
6574    /// A pull request is neither a project nor a task — it is somebody's change, not a
6575    /// unit of plan — and an item whose content the token cannot see has nothing to
6576    /// report at all.
6577    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
6578        let resolved = self.resolve_unremembered(item)?;
6579        if let Some(resolved) = &resolved {
6580            self.resolved_cache()?
6581                .insert(resolved.id.clone(), resolved.clone());
6582        }
6583        Ok(resolved)
6584    }
6585
6586    /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
6587    /// write's own response needs, which says nothing this process should answer a later read
6588    /// with.
6589    fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
6590        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
6591            message: "GitHub project item is missing content".into(),
6592        })?;
6593        if content.is_null() {
6594            return Ok(None);
6595        }
6596        let content_kind = match required_str(content, "__typename")? {
6597            "Issue" => ContentKind::Issue,
6598            "DraftIssue" => ContentKind::DraftIssue,
6599            _ => return Ok(None),
6600        };
6601        let field_values = item
6602            .get("fieldValues")
6603            .ok_or_else(|| SourceError::Malformed {
6604                message: "GitHub project item is missing fieldValues".into(),
6605            })?;
6606        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
6607        let nodes = field_values
6608            .get("nodes")
6609            .and_then(Value::as_array)
6610            .ok_or_else(|| SourceError::Malformed {
6611                message: "GitHub project item fieldValues.nodes is not an array".into(),
6612            })?;
6613        if let Some(labels) = content.get("labels") {
6614            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
6615        }
6616        let raw_body = optional_str(content, "body")?.map(str::to_owned);
6617        let (body, slot) = metadata_body(raw_body.clone())?;
6618        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
6619            .map(|id| NativeId(id.to_owned()));
6620        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
6621        // to read one from; it is a task, and never a project.
6622        let sub_issues = match content_kind {
6623            ContentKind::Issue => sub_issue_total(content)?,
6624            ContentKind::DraftIssue => 0,
6625        };
6626        let content_id = required_str(content, "id")?;
6627        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
6628            message: format!("GitHub issue {content_id}: {message}"),
6629        })?;
6630        let raw_title = required_str(content, "title")?;
6631        let kind = BoardKind::of(raw_title, parent.is_some(), sub_issues, marked);
6632        // The title a person wrote, which for a document is the one without the prefix —
6633        // the same way `content` above is the body without this source's metadata slot.
6634        let title = match kind {
6635            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
6636            BoardKind::Work(_) => raw_title.to_owned(),
6637        };
6638        let own_repository = content
6639            .pointer("/repository/nameWithOwner")
6640            .and_then(Value::as_str)
6641            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
6642            .transpose()
6643            .map_err(|message| SourceError::Malformed { message })?;
6644        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
6645            Repository::from_metadata(&slot)
6646                .map_err(|message| SourceError::Malformed { message })?
6647        } else {
6648            own_repository.clone().into_iter().collect()
6649        };
6650        let classification = Classification::from_metadata(&slot)
6651            .map_err(|message| SourceError::Malformed { message })?;
6652        let id = NativeId(content_id.to_owned());
6653        // Read only for a task, because only a task has either list: a project or a
6654        // document holding one of these keys holds nothing this source reports, and the
6655        // keys are left out of its caller-visible metadata all the same.
6656        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
6657            let listed = |key: &str| {
6658                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
6659                    .map_err(|message| SourceError::Malformed { message })
6660            };
6661            (
6662                listed(TaskRef::DELIVERS_KEY)?,
6663                listed(TaskRef::DELIVERED_BY_KEY)?,
6664            )
6665        } else {
6666            (Vec::new(), Vec::new())
6667        };
6668        let (option, closed, reason) = Self::status_parts(nodes, content)?;
6669        let priority = self.held_priority(nodes)?;
6670        let item_labels = labels(content)?;
6671        // Present when the item was reached through its own issue, whose board entry
6672        // names the board; a read of the board's own items has the board already. An
6673        // empty id names nothing a field write could address, so it is read as absent and
6674        // the write goes back to reading the board.
6675        let board_id = item
6676            .pointer("/project/id")
6677            .and_then(Value::as_str)
6678            .filter(|id| !id.is_empty());
6679        let resolved = Resolved {
6680            item_id: required_str(item, "id")?.to_owned(),
6681            id,
6682            content_kind,
6683            kind,
6684            title,
6685            body: body.filter(|value| !value.is_empty()),
6686            raw_body,
6687            status: self
6688                .statuses
6689                .status(kind.status_kind(), option, closed, reason),
6690            option: option.map(str::to_owned),
6691            priority,
6692            closed,
6693            reason: closed.then(|| reason.map(str::to_owned)).flatten(),
6694            delivers,
6695            delivered_by,
6696            labels: item_labels.clone(),
6697            parent,
6698            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
6699            projected: self.held_projections(nodes)?,
6700            number: match content_kind {
6701                ContentKind::Issue => Some(issue_number(content)?),
6702                // A draft is filed in no repository, so nothing ever numbered it:
6703                // `DraftIssue` declares no `number` at all, exactly as it declares no
6704                // `subIssuesSummary` the branch above reads.
6705                ContentKind::DraftIssue => None,
6706            },
6707            url: optional_str(content, "url")?.map(str::to_owned),
6708            created_at: optional_time(content, "createdAt")?,
6709            updated_at: optional_time(content, "updatedAt")?,
6710            item_updated_at: optional_time(item, "updatedAt")?,
6711            comment_count: content
6712                .pointer("/commentCount/totalCount")
6713                .and_then(Value::as_u64),
6714            relationship_events: content
6715                .pointer("/relationshipEvents/totalCount")
6716                .and_then(Value::as_u64),
6717            // The issue node is stamped wherever it was read; a board item a read of the issue
6718            // put together from its memberships is not.
6719            served_at: match optional_time(content, SERVED_AT)? {
6720                Some(served) => Some(served),
6721                None => optional_time(item, SERVED_AT)?,
6722            },
6723            // Both connections were read to their end above — one reporting a further page is
6724            // refused — so these are every field value and every label the item holds. A
6725            // response that did not select the labels at all says nothing of them, and gives
6726            // no facets.
6727            facets: content.get("labels").is_some().then(|| {
6728                change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
6729            }),
6730            own_repository,
6731            repositories,
6732            classification,
6733            slot,
6734            board_id: board_id.map(str::to_owned),
6735            fields: field_definitions(nodes),
6736            board_fields: Self::carried_board_fields(content, board_id)?,
6737            blocked_by: carried_blocked_by(content)?,
6738        };
6739        Ok(Some(resolved))
6740    }
6741
6742    /// The field definitions of the board `board_id` names — the project this issue's own
6743    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
6744    /// `None` when the read carried none, carried no entry for that board, or the board item
6745    /// named no board, which a write then answers by reading the board's fields itself.
6746    ///
6747    /// Matched by the board's node id and never by its number alone: a project number is
6748    /// unique only within its owner, so another owner's board numbered alike can sit on the
6749    /// same page, and its field and option ids address nothing on this one.
6750    fn carried_board_fields(
6751        content: &Value,
6752        board_id: Option<&str>,
6753    ) -> Result<Option<Value>, SourceError> {
6754        let (Some(nodes), Some(board_id)) = (
6755            content.pointer("/boards/nodes").and_then(Value::as_array),
6756            board_id,
6757        ) else {
6758            return Ok(None);
6759        };
6760        let Some(board) = nodes.iter().find_map(|node| {
6761            let project = node.get("project")?;
6762            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
6763        }) else {
6764            return Ok(None);
6765        };
6766        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
6767            return Ok(None);
6768        };
6769        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
6770        Ok(Some(fields.clone()))
6771    }
6772
6773    /// What one board item's `Priority` field says, through this instance's mapping.
6774    ///
6775    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
6776    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
6777    /// option the mapping does not name is kept as itself — never read as a level or as
6778    /// `none` — for a read of the task to report by name.
6779    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
6780        let Some(mapping) = &self.priorities else {
6781            return Ok(HeldPriority::Read(Priority::None));
6782        };
6783        // A value of the field that names no option — a text field someone called `Priority` —
6784        // is malformed rather than `none`: reading it as no priority would let the next copy
6785        // clear one a person set.
6786        let Some(option) = field_values
6787            .iter()
6788            .find(|value| {
6789                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
6790            })
6791            .map(|value| required_str(value, "name"))
6792            .transpose()?
6793        else {
6794            return Ok(HeldPriority::Read(Priority::None));
6795        };
6796        Ok(mapping.priority_of(option).map_or_else(
6797            || HeldPriority::Unmapped(option.to_owned()),
6798            HeldPriority::Read,
6799        ))
6800    }
6801
6802    /// What each projected metadata field holds for one board item, by field name — every text
6803    /// value GitHub answers, an empty one included.
6804    fn held_projections(
6805        &self,
6806        field_values: &[Value],
6807    ) -> Result<BTreeMap<String, String>, SourceError> {
6808        let mut held = BTreeMap::new();
6809        for projection in &self.metadata_fields {
6810            if let Some(text) = text_field(field_values, &projection.field)? {
6811                held.insert(projection.field.to_string(), text);
6812            }
6813        }
6814        Ok(held)
6815    }
6816
6817    /// What one board item's status is read from: its `Status` option, whether its issue
6818    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
6819    /// three into the status it reports.
6820    fn status_parts<'a>(
6821        field_values: &'a [Value],
6822        content: &'a Value,
6823    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
6824        let option = field_values
6825            .iter()
6826            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
6827            .map(|value| required_str(value, "name"))
6828            .transpose()?;
6829        let closed = optional_str(content, "state")? == Some("CLOSED");
6830        Ok((option, closed, optional_str(content, "stateReason")?))
6831    }
6832
6833    /// The board Status option this write selects, or the refusal that says why not.
6834    ///
6835    /// The mapped option is required for both open and terminal targets. A terminal write
6836    /// validates it before changing either representation, so it can never fall back to
6837    /// closing an issue whose board cannot display the matching status.
6838    ///
6839    /// Answers the field's id, the option's id, and the option's name as the board spells
6840    /// it — which is the name a read of the item reports once it sits there.
6841    fn column_for(
6842        &self,
6843        fields: &Value,
6844        kind: ItemKind,
6845        category: StatusCategory,
6846        target: &StatusTarget,
6847    ) -> Result<Option<(String, String, String)>, SourceError> {
6848        let Some(wanted) = target.option() else {
6849            return Ok(None);
6850        };
6851        let missing = |detail: &str| SourceError::Refused {
6852            message: format!(
6853                "{} status {} of source {} needs the board Status option {wanted:?}, and \
6854                 {detail}; next: add that option to the board, which `onetaskgraph sources \
6855                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
6856                 it has",
6857                kind.marker(),
6858                category_name(category),
6859                self.name,
6860                self.name,
6861                category_name(category),
6862                kind.marker()
6863            ),
6864        };
6865        let Some(field) = Board::field(fields, "Status")? else {
6866            return Err(missing("this board has no Status field"));
6867        };
6868        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
6869            return Err(missing(
6870                "this board's Status field is not a single-select field",
6871            ));
6872        }
6873        let option = field
6874            .get("options")
6875            .and_then(Value::as_array)
6876            .and_then(|options| {
6877                options.iter().find(|option| {
6878                    option
6879                        .get("name")
6880                        .and_then(Value::as_str)
6881                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
6882                })
6883            });
6884        match option {
6885            None => Err(missing("this board does not have it")),
6886            Some(option) => Ok(Some((
6887                required_str(field, "id")?.to_owned(),
6888                required_str(option, "id")?.to_owned(),
6889                required_str(option, "name")?.to_owned(),
6890            ))),
6891        }
6892    }
6893
6894    /// The refusal a status that closes an issue is answered with over a board draft.
6895    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
6896        SourceError::Refused {
6897            message: format!(
6898                "status {} of source {} closes the item's issue, and GitHub draft items have \
6899                 no open or closed state",
6900                category_name(category),
6901                self.name
6902            ),
6903        }
6904    }
6905
6906    /// What a status write to one item needs of the board: the board's id and the
6907    /// definition of its `Status` field, read off the item when the item says both.
6908    ///
6909    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
6910    /// and its `Status` value carries that field's definition, options and all. An item that
6911    /// does not say — no board id, or no `Status` value to read the field off — takes them
6912    /// from [`Self::board_fields`], which reads no item.
6913    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
6914        if let Some(board) = item.carried_board() {
6915            return Ok(board);
6916        }
6917        if item.defines("Status")
6918            && let Some(board_id) = item.named_board()
6919        {
6920            return Ok(BoardFields {
6921                id: board_id,
6922                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6923            });
6924        }
6925        self.board_fields().await
6926    }
6927
6928    /// Set one task's or one project's status and nothing else; see
6929    /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
6930    async fn set_status(
6931        &self,
6932        id: &NativeId,
6933        kind: ItemKind,
6934        category: StatusCategory,
6935    ) -> Result<Option<Status>, SourceError> {
6936        // Refused before anything is read, in the words a write of the same status is.
6937        let target = self.resolved_target(kind, category)?;
6938        // An item read by issue reference carries only what this write needs, so what it
6939        // becomes is never remembered for a later read to be answered from.
6940        let held = self.take_held(id)?;
6941        let by_reference = held.is_some();
6942        let bound = match held {
6943            Some(item) => Some(item),
6944            None => self.bound_item(id).await?,
6945        };
6946        let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
6947            return Ok(None);
6948        };
6949        let board = self.status_board(&item).await?;
6950        let (field, option, name) = self
6951            .column_for(&board.fields, kind, category, &target)?
6952            .ok_or_else(|| SourceError::Malformed {
6953                message: format!(
6954                    "status {} of source {} names no board Status option",
6955                    category_name(category),
6956                    self.name
6957                ),
6958            })?;
6959        if item.status.category == category && item.option.as_deref() == Some(&name) {
6960            return Ok(Some(item.status));
6961        }
6962        match &target {
6963            StatusTarget::Terminal(_, reason) => {
6964                if item.content_kind == ContentKind::DraftIssue {
6965                    return Err(self.closes_a_draft(category));
6966                }
6967                // The option first, so a refused selection leaves an open issue open rather
6968                // than closed behind a card that still reads open.
6969                self.set_item_field(
6970                    board.id.as_str(),
6971                    &item.item_id,
6972                    &field,
6973                    json!({"singleSelectOptionId": option}),
6974                )
6975                .await?;
6976                // An issue already closed in a way that reads as this category keeps its state
6977                // and its reason: only its card was out of line, and closing it again would
6978                // overwrite a reason — a duplicate's — that says more than this write does.
6979                let already = item.closed && item.status.category == category;
6980                if !already {
6981                    self.update_content(
6982                        ContentKind::Issue,
6983                        &item.id,
6984                        json!({"stateInput": state_input(Some(&target))}),
6985                    )
6986                    .await?;
6987                }
6988                let reason = if already {
6989                    item.reason.clone()
6990                } else {
6991                    Some(reason.reason().to_owned())
6992                };
6993                item.closed = true;
6994                item.status = self
6995                    .statuses
6996                    .status(kind, Some(&name), true, reason.as_deref());
6997                item.option = Some(name);
6998                item.reason = reason;
6999            }
7000            StatusTarget::Column(_) => {
7001                // An option is what an open item's status is, so a closed issue is reopened
7002                // first — sitting closed in the column, it would read back as closed. A draft has
7003                // no state to reopen.
7004                if item.content_kind == ContentKind::Issue && item.closed {
7005                    self.update_content(
7006                        ContentKind::Issue,
7007                        &item.id,
7008                        json!({"stateInput": state_input(Some(&target))}),
7009                    )
7010                    .await?;
7011                    item.closed = false;
7012                }
7013                self.set_item_field(
7014                    board.id.as_str(),
7015                    &item.item_id,
7016                    &field,
7017                    json!({"singleSelectOptionId": option}),
7018                )
7019                .await?;
7020                item.status = self.statuses.status(kind, Some(&name), false, None);
7021                item.option = Some(name);
7022            }
7023            StatusTarget::Disabled(_) => {
7024                unreachable!("resolved_target refused a disabled status")
7025            }
7026        }
7027        let status = item.status.clone();
7028        if !by_reference {
7029            self.remember_written(item, false)?;
7030        }
7031        Ok(Some(status))
7032    }
7033
7034    /// Replace one task's `delivered_by` and nothing else; see
7035    /// [`TaskSource::set_delivered_by`].
7036    ///
7037    /// One update of the body, which differs from the body GitHub holds only inside the
7038    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
7039    async fn replace_delivered_by(
7040        &self,
7041        id: &NativeId,
7042        delivered_by: &[TaskRef],
7043    ) -> Result<Option<()>, SourceError> {
7044        let entries = TaskRef::listed(
7045            TaskRef::DELIVERED_BY_KEY,
7046            id,
7047            Some(&self.name),
7048            delivered_by.to_vec(),
7049        )
7050        .map_err(|message| SourceError::Refused { message })?;
7051        let Some(mut item) = self
7052            .bound_item(id)
7053            .await?
7054            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7055        else {
7056            return Ok(None);
7057        };
7058        let mut slot = item.slot.clone();
7059        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
7060        self.write_slot(&mut item, &slot).await?;
7061        item.delivered_by = entries;
7062        self.remember_written(item, false)?;
7063        Ok(Some(()))
7064    }
7065
7066    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
7067    /// see [`TaskSource::set_task_metadata`].
7068    ///
7069    /// `None` when this board holds no item by that id, or holds one of another kind. The
7070    /// answer is the item as this source now reads it, so what a caller is told the key
7071    /// holds is what the slot holds.
7072    ///
7073    /// A key already holding the value is answered without a write, compared as JSON rather
7074    /// than as the body's bytes: a slot a person spelled with other whitespace would
7075    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
7076    async fn set_slot_key(
7077        &self,
7078        id: &NativeId,
7079        kind: BoardKind,
7080        key: &MetadataKey,
7081        value: &Value,
7082    ) -> Result<Option<Resolved>, SourceError> {
7083        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
7084            return Ok(None);
7085        };
7086        if item.slot.get(key.as_str()) == Some(value) {
7087            return Ok(Some(item));
7088        }
7089        let mut slot = item.slot.clone();
7090        slot.insert(key.as_str().to_owned(), value.clone());
7091        // A key a board text field projects moves that field first, so the body — the value's
7092        // one authoritative home — is written last, as every write of an existing item is.
7093        if self
7094            .metadata_fields
7095            .iter()
7096            .any(|projection| &*projection.key == key.as_str())
7097        {
7098            let board = self.projection_board(&item).await?;
7099            let (writes, projected) = self.projection_writes(
7100                &board.fields,
7101                Some(&item.projected),
7102                &slot,
7103                Some(&[key.as_str()]),
7104            )?;
7105            let (mut values, mut clears) = (Vec::new(), Vec::new());
7106            for write in &writes {
7107                write.join(&mut values, &mut clears);
7108            }
7109            self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
7110                .await?;
7111            item.projected = projected;
7112        }
7113        self.write_slot(&mut item, &slot).await?;
7114        self.remember_written(item.clone(), false)?;
7115        Ok(Some(item))
7116    }
7117
7118    /// What a projection write to `item` needs of the board, read off the item when it says
7119    /// enough, as [`Self::fields_for`] reads it.
7120    async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
7121        if let Some(board) = item.carried_board() {
7122            return Ok(board);
7123        }
7124        if let Some(board_id) = item.named_board()
7125            && self
7126                .metadata_fields
7127                .iter()
7128                .all(|projection| item.defines(&projection.field))
7129        {
7130            return Ok(BoardFields {
7131                id: board_id,
7132                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7133            });
7134        }
7135        self.board_fields().await
7136    }
7137
7138    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
7139    /// `item` up to what that write left.
7140    ///
7141    /// The body sent differs from the body GitHub holds only inside the slot — see
7142    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
7143    /// the mutation the item's content takes, so a board draft's body is written with
7144    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
7145    async fn write_slot(
7146        &self,
7147        item: &mut Resolved,
7148        slot: &BTreeMap<String, Value>,
7149    ) -> Result<(), SourceError> {
7150        let held = item.raw_body.clone().unwrap_or_default();
7151        let body = with_slot(&held, slot)?;
7152        if body != held {
7153            self.update_content(item.content_kind, &item.id, json!({"body": body}))
7154                .await?;
7155        }
7156        let (visible, slot) = metadata_body(Some(body.clone()))?;
7157        item.body = visible.filter(|value| !value.is_empty());
7158        item.raw_body = Some(body);
7159        item.slot = slot;
7160        Ok(())
7161    }
7162
7163    /// This instance's target for a category written to an item of `kind`, refusing one
7164    /// that kind has no option for — before anything is read or written.
7165    ///
7166    /// Nothing here mutates the board's option set to make room for a status. GitHub
7167    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
7168    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
7169    /// field and every item's status.
7170    fn resolved_target(
7171        &self,
7172        kind: ItemKind,
7173        category: StatusCategory,
7174    ) -> Result<StatusTarget, SourceError> {
7175        let target = self.statuses.target(kind, category).clone();
7176        let StatusTarget::Disabled(why) = target else {
7177            return Ok(target);
7178        };
7179        let refusal = why.refusal(&self.name, category, kind);
7180        // Why there is no shipped default, which is the question a person meeting this
7181        // refusal on a source that never mentioned the category asks.
7182        let shipped_none = match category {
7183            StatusCategory::Draft => Some(
7184                "draft has no shipped default because GitHub draft issues cannot have \
7185                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
7186            ),
7187            StatusCategory::Unknown => Some(
7188                "unknown has no shipped default because this board keeps no open-ended status \
7189                 word: every word classified unknown is written to the one board Status option \
7190                 status_mapping.unknown names",
7191            ),
7192            _ => None,
7193        };
7194        Err(match (refusal, shipped_none, why) {
7195            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
7196                SourceError::Refused {
7197                    message: format!("{message}; {note}"),
7198                }
7199            }
7200            (refusal, _, _) => refusal,
7201        })
7202    }
7203
7204    /// What writing `priority` does to one item's `Priority` field on this board, or the
7205    /// refusal naming what the board lacks.
7206    ///
7207    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
7208    /// none already, or of an item not created yet. Every other priority selects the option
7209    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
7210    /// without that option, is refused rather than given one: reads and writes never create
7211    /// a field or an option.
7212    fn priority_write(
7213        &self,
7214        fields: &Value,
7215        existing: Option<&Resolved>,
7216        priority: Priority,
7217    ) -> Result<Option<PriorityWrite>, SourceError> {
7218        let Some(mapping) = &self.priorities else {
7219            return Err(self.holds_no_priority());
7220        };
7221        let Some(wanted) = mapping.option(priority) else {
7222            if !existing.is_some_and(Resolved::holds_priority) {
7223                return Ok(None);
7224            }
7225            let field =
7226                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
7227                    message: format!(
7228                        "an item holding a {PRIORITY_FIELD} value was read without that field"
7229                    ),
7230                })?;
7231            return Ok(Some(PriorityWrite::Clear {
7232                field: required_str(field, "id")?.to_owned(),
7233            }));
7234        };
7235        let missing = |detail: &str| SourceError::Refused {
7236            message: format!(
7237                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
7238                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
7239                 it, or point priority_mapping.{priority} of this source at an option the board \
7240                 has",
7241                self.name, self.name
7242            ),
7243        };
7244        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
7245            return Err(missing(&format!(
7246                "this board has no {PRIORITY_FIELD} field"
7247            )));
7248        };
7249        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
7250            return Err(missing(&format!(
7251                "this board's {PRIORITY_FIELD} field is not a single-select field"
7252            )));
7253        }
7254        // An options list that is absent or not a list is an answer this source cannot read,
7255        // not a board lacking the option: `sources fields --apply` is no remedy for it.
7256        let option = field
7257            .get("options")
7258            .and_then(Value::as_array)
7259            .ok_or_else(|| SourceError::Malformed {
7260                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
7261            })?
7262            .iter()
7263            .find(|option| {
7264                option
7265                    .get("name")
7266                    .and_then(Value::as_str)
7267                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
7268            })
7269            .ok_or_else(|| missing("this board does not have it"))?;
7270        Ok(Some(PriorityWrite::Select {
7271            field: required_str(field, "id")?.to_owned(),
7272            option: required_str(option, "id")?.to_owned(),
7273        }))
7274    }
7275
7276    /// What writing `metadata` does to each projected text field of one item, and what those
7277    /// fields hold after it — or the refusal, before anything is sent.
7278    ///
7279    /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
7280    /// narrows the work to the entries projecting one of those metadata keys, for a write that
7281    /// touches only them; `None` is every entry. A string the field already holds, and nothing
7282    /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
7283    /// and so is a board without the field, or with a field of that name that is not a text
7284    /// field: reads and writes never create a field.
7285    fn projection_writes(
7286        &self,
7287        fields: &Value,
7288        held: Option<&BTreeMap<String, String>>,
7289        metadata: &BTreeMap<String, Value>,
7290        keys: Option<&[&str]>,
7291    ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
7292        let mut projected = held.cloned().unwrap_or_default();
7293        let mut writes = Vec::new();
7294        for projection in self
7295            .metadata_fields
7296            .iter()
7297            .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
7298        {
7299            let wanted = projection.projected(metadata, &self.name)?;
7300            let field = self.projection_field(fields, projection)?;
7301            let holding = projected.get(&*projection.field);
7302            match wanted {
7303                Projected::Text(text) if holding != Some(&text) => {
7304                    writes.push(ProjectionWrite::Set {
7305                        field,
7306                        text: text.clone(),
7307                    });
7308                    projected.insert(projection.field.to_string(), text);
7309                }
7310                Projected::Nothing if holding.is_some() => {
7311                    writes.push(ProjectionWrite::Clear { field });
7312                    projected.remove(&*projection.field);
7313                }
7314                Projected::Text(_) | Projected::Nothing => {}
7315            }
7316        }
7317        Ok((writes, projected))
7318    }
7319
7320    /// The id of the board text field one projection writes, or the refusal naming what the
7321    /// board has instead.
7322    fn projection_field(
7323        &self,
7324        fields: &Value,
7325        projection: &MetadataField,
7326    ) -> Result<String, SourceError> {
7327        let refuse = |detail: String| SourceError::Refused {
7328            message: format!(
7329                "source {} projects metadata key {:?} onto the board text field {:?}, and \
7330                 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
7331                 text field, or name another field in metadata_fields",
7332                self.name, &*projection.key, &*projection.field, self.name
7333            ),
7334        };
7335        let Some(field) = Board::field(fields, &projection.field)? else {
7336            return Err(refuse("this board has no field of that name".to_owned()));
7337        };
7338        let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
7339        let data_type = optional_str(field, "dataType")?;
7340        if typename != "ProjectV2Field" || data_type != Some("TEXT") {
7341            return Err(refuse(format!(
7342                "this board's field of that name is not a text field (it is a {}), so it is \
7343                 left as it is; rename or remove that field first",
7344                data_type.unwrap_or(typename)
7345            )));
7346        }
7347        Ok(required_str(field, "id")?.to_owned())
7348    }
7349
7350    /// Apply one priority write to one board item.
7351    async fn write_priority(
7352        &self,
7353        board_id: &str,
7354        item_id: &str,
7355        write: &PriorityWrite,
7356    ) -> Result<(), SourceError> {
7357        match write {
7358            PriorityWrite::Select { field, option } => {
7359                self.set_item_field(
7360                    board_id,
7361                    item_id,
7362                    field,
7363                    json!({"singleSelectOptionId": option}),
7364                )
7365                .await
7366            }
7367            PriorityWrite::Clear { field } => {
7368                let data = self
7369                    .graphql(
7370                        graphql::CLEAR_FIELD,
7371                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
7372                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
7373                    )
7374                    .await?;
7375                let returned = data
7376                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
7377                    .ok_or_else(|| SourceError::Malformed {
7378                        message: "GitHub field clear returned no project item".into(),
7379                    })?;
7380                if required_str(returned, "id")? != item_id {
7381                    return Err(SourceError::Malformed {
7382                        message: "GitHub field clear returned the wrong project item".into(),
7383                    });
7384                }
7385                Ok(())
7386            }
7387        }
7388    }
7389
7390    /// The refusal a priority is answered with by an instance configured with no
7391    /// `priority_mapping`, which holds none.
7392    fn holds_no_priority(&self) -> SourceError {
7393        SourceError::Refused {
7394            message: format!(
7395                "source {} holds no task priority: its configuration sets no priority_mapping; \
7396                 next: set priority_mapping on this source, then run `onetaskgraph sources \
7397                 fields {} --apply` to set its board up",
7398                self.name, self.name
7399            ),
7400        }
7401    }
7402
7403    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
7404    ///
7405    /// One field write — a select, or a clear for `none` — and no title, body, label, state
7406    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
7407    async fn set_priority(
7408        &self,
7409        id: &NativeId,
7410        priority: Priority,
7411    ) -> Result<Option<Priority>, SourceError> {
7412        if self.priorities.is_none() {
7413            return Err(self.holds_no_priority());
7414        }
7415        let Some(mut item) = self
7416            .bound_item(id)
7417            .await?
7418            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7419        else {
7420            return Ok(None);
7421        };
7422        if priority == Priority::None && !item.holds_priority() {
7423            return Ok(Some(priority));
7424        }
7425        // The item's own read carries the field's definition whenever it holds a value of
7426        // it, which a clear always does; a select onto an item holding none reads the board.
7427        let board = match (item.carried_board(), item.named_board()) {
7428            (Some(board), _) => board,
7429            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
7430                id,
7431                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
7432            },
7433            _ => self.board_fields().await?,
7434        };
7435        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
7436            return Ok(Some(priority));
7437        };
7438        let (document, root, input) = match write {
7439            PriorityWrite::Select { field, option } => (
7440                graphql::UPDATE_FIELD,
7441                "updateProjectV2ItemFieldValue",
7442                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
7443            ),
7444            PriorityWrite::Clear { field } => (
7445                graphql::CLEAR_FIELD,
7446                "clearProjectV2ItemFieldValue",
7447                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
7448            ),
7449        };
7450        let data = self
7451            .graphql(
7452                document,
7453                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
7454            )
7455            .await?;
7456        let returned = data
7457            .get(root)
7458            .and_then(|value| value.get("projectV2Item"))
7459            .ok_or_else(|| SourceError::Malformed {
7460                message: "GitHub priority write returned no project item".into(),
7461            })?;
7462        if required_str(returned, "id")? != item.item_id {
7463            return Err(SourceError::Malformed {
7464                message: "GitHub priority write returned the wrong project item".into(),
7465            });
7466        }
7467        let value = returned
7468            .get("fieldValueByName")
7469            .ok_or_else(|| SourceError::Malformed {
7470                message: "GitHub priority write returned no priority read-back".into(),
7471            })?;
7472        if !value.is_null()
7473            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
7474        {
7475            return Err(SourceError::Malformed {
7476                message: "GitHub priority read-back is not a Priority field value".into(),
7477            });
7478        }
7479        let values = if value.is_null() {
7480            Vec::new()
7481        } else {
7482            vec![value.clone()]
7483        };
7484        item.priority = self.held_priority(&values)?;
7485        let answer = item.task()?.priority;
7486        self.remember_written(item, false)?;
7487        Ok(Some(answer))
7488    }
7489
7490    /// Replace one task's visible body and nothing else; see
7491    /// [`TaskSource::set_task_content`].
7492    ///
7493    /// One update of the body, which differs from the body GitHub holds only outside the
7494    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
7495    /// this source keeps there reads back as it was. A body that would not change is not
7496    /// sent at all.
7497    async fn replace_content(
7498        &self,
7499        id: &NativeId,
7500        content: &str,
7501    ) -> Result<Option<()>, SourceError> {
7502        let Some(mut item) = self
7503            .bound_item(id)
7504            .await?
7505            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7506        else {
7507            return Ok(None);
7508        };
7509        let held = item.raw_body.clone().unwrap_or_default();
7510        let body = with_content(&held, content)?;
7511        // Checked before anything is sent: content ending in what this source reads as its own
7512        // metadata slot would read back as metadata rather than as the content it was.
7513        let (visible, slot) = metadata_body(Some(body.clone()))?;
7514        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
7515            return Err(SourceError::Refused {
7516                message: format!(
7517                    "this content ends in what source {} reads as its own metadata slot \
7518                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7519                     as content; next: remove that trailing block from the content",
7520                    self.name
7521                ),
7522            });
7523        }
7524        if body != held {
7525            self.update_content(item.content_kind, &item.id, json!({"body": body}))
7526                .await?;
7527        }
7528        item.body = visible.filter(|value| !value.is_empty());
7529        item.raw_body = Some(body);
7530        item.slot = slot;
7531        self.remember_written(item, false)?;
7532        Ok(Some(()))
7533    }
7534
7535    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
7536    ///
7537    /// One read of the item — which carries the board's field definitions and the issue's
7538    /// `blockedBy`, so neither is read again — and then only what differs from it: the
7539    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
7540    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
7541    /// the title, the body — visible content and metadata slot together — and a state change.
7542    /// So an update naming any of title, body, metadata, status and priority is one read and
7543    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
7544    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
7545    /// as a whole write does; an open one selects its option and then reopens. The origin
7546    /// field is never written: an update is of an item that already exists, whose origin is
7547    /// what it is.
7548    ///
7549    /// The task answered is the item as those writes left it, built from the read and what was
7550    /// sent rather than read again — the same record a later read in this run answers from.
7551    async fn targeted_update(
7552        &self,
7553        id: &NativeId,
7554        update: &TaskUpdate,
7555    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7556        // Everything this source can refuse without reading the item is refused first, in the
7557        // words a whole write of the same fields is refused with.
7558        update.consistent()?;
7559        if update
7560            .title
7561            .as_deref()
7562            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
7563        {
7564            return Err(SourceError::Refused {
7565                message: format!(
7566                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
7567                     spells a document, so it would read back as one rather than as a task; \
7568                     retitle it",
7569                    self.name
7570                ),
7571            });
7572        }
7573        if let Some(delivers) = &update.delivers {
7574            TaskRef::listed(
7575                TaskRef::DELIVERS_KEY,
7576                id,
7577                Some(&self.name),
7578                delivers.clone(),
7579            )
7580            .map_err(|message| SourceError::Refused { message })?;
7581        }
7582        if self.priorities.is_none()
7583            && update
7584                .priority
7585                .is_some_and(|priority| priority != Priority::None)
7586        {
7587            return Err(self.holds_no_priority());
7588        }
7589        let target = update
7590            .status
7591            .as_ref()
7592            .map(|status| self.resolved_target(ItemKind::Task, status.category))
7593            .transpose()?;
7594        let Some(mut item) = self
7595            .bound_item(id)
7596            .await?
7597            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7598        else {
7599            return Ok(None);
7600        };
7601        let before = item.task()?;
7602
7603        let mut status_move = None;
7604        if let (Some(status), Some(target)) = (&update.status, target) {
7605            let board = self.status_board(&item).await?;
7606            let (field, option, name) = self
7607                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
7608                .ok_or_else(|| SourceError::Malformed {
7609                    message: format!(
7610                        "status {} of source {} names no board Status option",
7611                        category_name(status.category),
7612                        self.name
7613                    ),
7614                })?;
7615            let terminal = matches!(target, StatusTarget::Terminal(_, _));
7616            if terminal && item.content_kind == ContentKind::DraftIssue {
7617                return Err(self.closes_a_draft(status.category));
7618            }
7619            let landed = match &target {
7620                StatusTarget::Terminal(_, reason) => {
7621                    self.statuses
7622                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
7623                }
7624                _ => self
7625                    .statuses
7626                    .status(ItemKind::Task, Some(&name), false, None),
7627            };
7628            let option_moves = item
7629                .option
7630                .as_deref()
7631                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
7632            let state_moves = item.content_kind == ContentKind::Issue
7633                && (item.closed != terminal || (terminal && item.status != landed));
7634            if let Some(moves) = Moves::of(option_moves, state_moves) {
7635                status_move = Some(StatusMove {
7636                    board: board.id,
7637                    field,
7638                    option,
7639                    name,
7640                    target,
7641                    landed,
7642                    moves,
7643                });
7644            }
7645        }
7646
7647        let mut priority_move = None;
7648        if let Some(priority) = update.priority
7649            && self.priorities.is_some()
7650            && item.priority != HeldPriority::Read(priority)
7651        {
7652            let board = match (item.carried_board(), item.named_board()) {
7653                (Some(board), _) => board,
7654                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
7655                    id: board,
7656                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7657                },
7658                _ => self.board_fields().await?,
7659            };
7660            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
7661                priority_move = Some((board.id, write, priority));
7662            }
7663        }
7664
7665        // The metadata keys this update names that a board text field projects, and what
7666        // that does to those fields — refused, as a whole write refuses it, before anything
7667        // is sent.
7668        let touched: Vec<&str> = update
7669            .metadata_set
7670            .keys()
7671            .chain(update.metadata_remove.iter())
7672            .map(MetadataKey::as_str)
7673            .collect();
7674        let mut projection_move = None;
7675        if self
7676            .metadata_fields
7677            .iter()
7678            .any(|projection| touched.contains(&&*projection.key))
7679        {
7680            let mut metadata = item.slot.clone();
7681            for (key, value) in &update.metadata_set {
7682                metadata.insert(key.as_str().to_owned(), value.clone());
7683            }
7684            for key in &update.metadata_remove {
7685                metadata.remove(key.as_str());
7686            }
7687            let board = self.projection_board(&item).await?;
7688            let (writes, projected) = self.projection_writes(
7689                &board.fields,
7690                Some(&item.projected),
7691                &metadata,
7692                Some(&touched),
7693            )?;
7694            projection_move = Some((board.id, writes, projected));
7695        }
7696
7697        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
7698        // recorded in the slot, and the slot travels in the one body update below.
7699        let edges = match &update.depends_on {
7700            Some(edges) => Some(
7701                self.partition_edges(
7702                    BoardKind::Work(ItemKind::Task),
7703                    item.content_kind,
7704                    item.blocked_by.as_deref(),
7705                    edges,
7706                )
7707                .await?,
7708            ),
7709            None => None,
7710        };
7711
7712        let mut slot = item.slot.clone();
7713        for (key, value) in &update.metadata_set {
7714            slot.insert(key.as_str().to_owned(), value.clone());
7715        }
7716        for key in &update.metadata_remove {
7717            slot.remove(key.as_str());
7718        }
7719        if let Some(delivers) = &update.delivers {
7720            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
7721        }
7722        if let Some((_, recorded)) = &edges {
7723            record_edges(&mut slot, recorded);
7724        }
7725        let held = item.raw_body.clone().unwrap_or_default();
7726        let content = match &update.content {
7727            Some(content) => with_content(&held, content)?,
7728            None => held.clone(),
7729        };
7730        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
7731        // the body's bytes, as a metadata write compares it: a slot a person spelled with
7732        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
7733        let body = if slot == item.slot {
7734            content
7735        } else {
7736            with_slot(&content, &slot)?
7737        };
7738        // Checked before anything is sent, as a content write checks it: content ending in
7739        // what this source reads as its own slot would read back as metadata.
7740        let (visible, read) = metadata_body(Some(body.clone()))?;
7741        let wanted = update.content.as_deref().or(item.body.as_deref());
7742        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
7743            return Err(SourceError::Refused {
7744                message: format!(
7745                    "this content ends in what source {} reads as its own metadata slot \
7746                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7747                     as content; next: remove that trailing block from the content",
7748                    self.name
7749                ),
7750            });
7751        }
7752        let recorded_moves =
7753            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
7754
7755        // One `updateIssue` carries all three, because every mutation spends the secondary
7756        // limiter and the title, body and state are one mutation's inputs.
7757        let mut fields = serde_json::Map::new();
7758        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
7759            fields.insert("title".to_owned(), json!(title));
7760        }
7761        if body != held {
7762            fields.insert("body".to_owned(), json!(body));
7763        }
7764        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
7765            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
7766        }
7767        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
7768        // GitHub runs no two requests as one, and runs one document's mutation fields in order
7769        // without undoing an earlier field when a later one fails — so a body written before a
7770        // board field the board then refused would be left changed. Written after every other
7771        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
7772        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
7773        // first, together in one request — a terminal option selected before the issue
7774        // closes, as a whole write does — then the `blockedBy` difference, then the body.
7775        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
7776        let mut clears: Vec<(&BoardId, &str)> = Vec::new();
7777        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
7778            board_writes.push((
7779                &moving.board,
7780                (
7781                    moving.field.clone(),
7782                    json!({"singleSelectOptionId": moving.option}),
7783                ),
7784            ));
7785        }
7786        match &priority_move {
7787            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
7788                board,
7789                (field.clone(), json!({"singleSelectOptionId": option})),
7790            )),
7791            Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
7792            None => {}
7793        }
7794        if let Some((board, writes, _)) = &projection_move {
7795            for write in writes {
7796                let (mut values, mut cleared) = (Vec::new(), Vec::new());
7797                write.join(&mut values, &mut cleared);
7798                board_writes.extend(values.into_iter().map(|value| (board, value)));
7799                clears.extend(cleared.into_iter().map(|field| (board, field)));
7800            }
7801        }
7802        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
7803        boards.extend(clears.iter().map(|(board, _)| *board));
7804        boards.dedup_by(|one, other| one.as_str() == other.as_str());
7805        for board in boards {
7806            let writes = board_writes
7807                .iter()
7808                .filter(|(on, _)| on.as_str() == board.as_str())
7809                .map(|(_, write)| write.clone())
7810                .collect::<Vec<_>>();
7811            let cleared = clears
7812                .iter()
7813                .filter(|(on, _)| on.as_str() == board.as_str())
7814                .map(|(_, field)| *field)
7815                .collect::<Vec<_>>();
7816            self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
7817                .await?;
7818        }
7819        let mut blocked_by_moved = false;
7820        if let Some((native, _)) = &edges
7821            && item.content_kind == ContentKind::Issue
7822        {
7823            blocked_by_moved = self
7824                .reconcile_blocked_by(
7825                    &item.id,
7826                    native,
7827                    Issue::Existing(item.blocked_by.as_deref()),
7828                )
7829                .await?;
7830        }
7831        if !fields.is_empty() {
7832            self.update_content(item.content_kind, &item.id, Value::Object(fields))
7833                .await?;
7834        }
7835
7836        // The `blockedBy` this item was read with is what the write just changed, so it is no
7837        // answer any more: a later write in this command would reconcile against it — re-adding
7838        // what it kept and never removing what it dropped — and a forward-edge read would
7839        // answer with it. Left unknown, each asks GitHub instead.
7840        if blocked_by_moved {
7841            item.blocked_by = None;
7842        }
7843        if let Some(title) = &update.title {
7844            item.title.clone_from(title);
7845        }
7846        item.body = visible.filter(|value| !value.is_empty());
7847        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
7848        item.slot = slot;
7849        if let Some(delivers) = &update.delivers {
7850            item.delivers.clone_from(delivers);
7851        }
7852        if let Some(moving) = status_move {
7853            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
7854                && item.content_kind == ContentKind::Issue;
7855            item.status = moving.landed;
7856            item.option = Some(moving.name);
7857        }
7858        if let Some((_, _, priority)) = priority_move {
7859            item.priority = HeldPriority::Read(priority);
7860        }
7861        if let Some((_, _, projected)) = projection_move {
7862            item.projected = projected;
7863        }
7864        let task = item.task()?;
7865        let mut written = update.changed(&before, &task);
7866        if blocked_by_moved || recorded_moves {
7867            written.insert(UpdatedField::DependsOn);
7868        }
7869        self.remember_written(item, false)?;
7870        Ok(Some(TaskUpdateOutcome {
7871            task,
7872            written,
7873            delivers_before: before.delivers,
7874        }))
7875    }
7876
7877    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
7878    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
7879    ///
7880    /// One update of the body: the content outside the slot, and inside it that one entry,
7881    /// every other entry kept as it was. This source keeps no template answers — an issue has
7882    /// no room beside itself that is not its body, and answers written there would duplicate
7883    /// what the content already says and count against GitHub's body limit — so `answers`
7884    /// reaches nothing here. A body that would not change is not sent at all.
7885    async fn replace_rendering(
7886        &self,
7887        id: &NativeId,
7888        kind: BoardKind,
7889        content: &str,
7890        provenance: &Value,
7891        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
7892    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
7893        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
7894            return Ok(None);
7895        };
7896        let held = item.raw_body.clone().unwrap_or_default();
7897        let mut slot = item.slot.clone();
7898        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
7899        let rewritten;
7900        let content = if let Some(assets) = assets {
7901            let uploads = self
7902                .upload_assets(item.own_repository.as_ref(), assets)
7903                .await?;
7904            rewritten =
7905                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
7906            rewritten.as_str()
7907        } else {
7908            content
7909        };
7910        let body = with_slot(&with_content(&held, content)?, &slot)?;
7911        // Checked before anything is sent, as a content write checks it.
7912        let (visible, read) = metadata_body(Some(body.clone()))?;
7913        if visible.as_deref().unwrap_or_default() != content || read != slot {
7914            return Err(SourceError::Refused {
7915                message: format!(
7916                    "this content ends in what source {} reads as its own metadata slot \
7917                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7918                     as content; next: remove that trailing block from the template",
7919                    self.name
7920                ),
7921            });
7922        }
7923        if body != held {
7924            self.update_content(item.content_kind, &item.id, json!({"body": body}))
7925                .await?;
7926        }
7927        item.body = visible.filter(|value| !value.is_empty());
7928        item.raw_body = Some(body);
7929        item.slot = read;
7930        self.remember_written(item, false)?;
7931        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
7932            id: id.clone(),
7933            content: Some(content.to_owned()),
7934        }))
7935    }
7936
7937    async fn set_item_field(
7938        &self,
7939        board_id: &str,
7940        item_id: &str,
7941        field_id: &str,
7942        value: Value,
7943    ) -> Result<(), SourceError> {
7944        let data = self
7945            .graphql(
7946                graphql::UPDATE_FIELD,
7947                json!({"input":{
7948                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
7949                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
7950            )
7951            .await?;
7952        let returned = data
7953            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
7954            .ok_or_else(|| SourceError::Malformed {
7955                message: "GitHub field update returned no project item".into(),
7956            })?;
7957        if required_str(returned, "id")? != item_id {
7958            return Err(SourceError::Malformed {
7959                message: "GitHub field update returned the wrong project item".into(),
7960            });
7961        }
7962        Ok(())
7963    }
7964
7965    /// GitHub accepts one value per field mutation; aliases combine those mutations in
7966    /// one request — writes in the order given, then clears. A lone write is
7967    /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
7968    /// request's slots hold go in further requests of the same document, in order. Every
7969    /// returned item id is checked, including optional aliases.
7970    async fn set_item_fields(
7971        &self,
7972        board: &str,
7973        item: &str,
7974        fields: &[(String, Value)],
7975        clears: &[&str],
7976    ) -> Result<(), SourceError> {
7977        match (fields, clears) {
7978            ([], []) => return Ok(()),
7979            ([(field, value)], []) => {
7980                return self.set_item_field(board, item, field, value.clone()).await;
7981            }
7982            ([], [field]) => {
7983                return self
7984                    .write_priority(
7985                        board,
7986                        item,
7987                        &PriorityWrite::Clear {
7988                            field: (*field).to_owned(),
7989                        },
7990                    )
7991                    .await;
7992            }
7993            _ => {}
7994        }
7995        // Any field of the request stands in for an input a slot leaves out, which GitHub
7996        // still requires to be well formed although it never runs it.
7997        let filler = fields
7998            .first()
7999            .map_or_else(|| clears[0], |(field, _)| field.as_str());
8000        let mut writes = fields.iter();
8001        let mut cleared = clears.iter();
8002        loop {
8003            let batch: Vec<&(String, Value)> =
8004                writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
8005            let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
8006            if batch.is_empty() && batch_clears.is_empty() {
8007                return Ok(());
8008            }
8009            let mut variables = serde_json::Map::new();
8010            for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
8011                let input = match batch.get(index) {
8012                    Some((field, value)) => {
8013                        json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
8014                    }
8015                    None => json!({"projectId":board,"itemId":item,"fieldId":filler,
8016                                   "value":{"text":""}}),
8017                };
8018                variables.insert(slot.variable.to_owned(), input);
8019                variables.insert(slot.include.to_owned(), json!(index < batch.len()));
8020            }
8021            for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
8022                let field = batch_clears.get(index).map_or(filler, |field| **field);
8023                variables.insert(
8024                    slot.variable.to_owned(),
8025                    json!({"projectId":board,"itemId":item,"fieldId":field}),
8026                );
8027                variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
8028            }
8029            let data = self
8030                .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
8031                .await?;
8032            let sent = FIELD_WRITE_SLOTS[..batch.len()]
8033                .iter()
8034                .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
8035            for slot in sent {
8036                let alias = slot.alias;
8037                let returned = data
8038                    .get(alias)
8039                    .and_then(|value| value.get("projectV2Item"))
8040                    .ok_or_else(|| SourceError::Malformed {
8041                        message: format!("GitHub field update {alias} returned no project item"),
8042                    })?;
8043                if required_str(returned, "id")? != item {
8044                    return Err(SourceError::Malformed {
8045                        message: format!(
8046                            "GitHub field update {alias} returned the wrong project item"
8047                        ),
8048                    });
8049                }
8050            }
8051        }
8052    }
8053
8054    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
8055        let mut after: Option<String> = None;
8056        let mut ids = Vec::new();
8057        loop {
8058            let data = self
8059                .graphql(
8060                    graphql::ISSUE_DEPENDENCIES,
8061                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
8062                )
8063                .await?;
8064            let connection =
8065                data.pointer("/node/blockedBy")
8066                    .ok_or_else(|| SourceError::Malformed {
8067                        message: "GitHub dependency response has no blockedBy connection".into(),
8068                    })?;
8069            ids.extend(
8070                connection
8071                    .get("nodes")
8072                    .and_then(Value::as_array)
8073                    .ok_or_else(|| SourceError::Malformed {
8074                        message: "GitHub dependency response nodes is not an array".into(),
8075                    })?
8076                    .iter()
8077                    .map(|value| required_str(value, "id").map(str::to_owned))
8078                    .collect::<Result<Vec<_>, _>>()?,
8079            );
8080            let next = next_cursor(connection)?;
8081            if let Some(next) = &next {
8082                validate_cursor_progress(after.as_deref(), &next.0)?;
8083            }
8084            after = next.map(|cursor| cursor.0);
8085            if after.is_none() {
8086                return Ok(ids);
8087            }
8088        }
8089    }
8090
8091    async fn dependencies(
8092        &self,
8093        id: &NativeId,
8094        near_kind: ItemKind,
8095        direction: Direction,
8096        page: &PageRequest,
8097    ) -> Result<Page<DependencyEdge>, SourceError> {
8098        validate_page(page)?;
8099        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
8100        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
8101        let recorded = recorded_offset(cursor, direction)?;
8102        // What this issue is blocked by, when a read of it by its own id in this command
8103        // already carried the whole connection — a copy reads the item it writes before it
8104        // reads its edges — and the page asked for is the whole of it, or the recorded tail
8105        // after it. Answered from that read, in the shape the dependency read answers in;
8106        // anything else is asked of GitHub.
8107        let carried = match direction {
8108            Direction::DependsOn => self
8109                .resolved_cache()?
8110                .get(id)
8111                .filter(|item| item.content_kind == ContentKind::Issue)
8112                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
8113            Direction::DependedOnBy => None,
8114        }
8115        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
8116        // Asked for even in the recorded phase, whose page reads nothing from the
8117        // connection: `__typename` is what says whether this item has a native
8118        // relationship at all, and that is what decides which far ends the reserved key is
8119        // allowed to hold.
8120        let data = match carried {
8121            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
8122                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
8123            None => {
8124                self.graphql(
8125                    graphql::ISSUE_DEPENDENCIES,
8126                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
8127                           "after":if recorded.is_some() {None} else {cursor}}),
8128                )
8129                .await?
8130            }
8131        };
8132        let node =
8133            data.get("node")
8134                .filter(|v| !v.is_null())
8135                .ok_or_else(|| SourceError::Refused {
8136                    message: format!(
8137                        "GitHub item {} was not found or does not support dependencies",
8138                        id.0
8139                    ),
8140                })?;
8141        let connection_name = match direction {
8142            Direction::DependsOn => "blockedBy",
8143            Direction::DependedOnBy => "blocking",
8144        };
8145        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
8146        // named natively and the reserved key may hold any far end. An issue's connections
8147        // hold issues, and this source reads them at the near item's own level.
8148        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
8149        if let Some(offset) = recorded {
8150            return Ok(recorded_page(
8151                self.recorded_edges(id, near_kind, direction, natively_names, node)
8152                    .await?,
8153                offset,
8154                limit,
8155            ));
8156        }
8157        if natively_names.is_none() {
8158            return Ok(recorded_page(
8159                self.recorded_edges(id, near_kind, direction, natively_names, node)
8160                    .await?,
8161                0,
8162                limit,
8163            ));
8164        }
8165        let connection = node
8166            .get(connection_name)
8167            .ok_or_else(|| SourceError::Malformed {
8168                message: "GitHub dependency response is missing its connection".into(),
8169            })?;
8170        let nodes = connection
8171            .get("nodes")
8172            .and_then(Value::as_array)
8173            .ok_or_else(|| SourceError::Malformed {
8174                message: "GitHub dependency response nodes is not an array".into(),
8175            })?;
8176        // `from` depends on `to`, always. GitHub spells the same relationship from either
8177        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
8178        // it — so the near item is `from` in one direction and `to` in the other.
8179        let items = nodes
8180            .iter()
8181            .map(|value| {
8182                let related = NativeId(required_str(value, "id")?.into());
8183                let related_kind = related_kind(value)?;
8184                let (from, to) = match direction {
8185                    Direction::DependsOn => (
8186                        DependencyEndpoint::from_native(id.clone(), near_kind),
8187                        DependencyEndpoint::from_native(related, related_kind),
8188                    ),
8189                    Direction::DependedOnBy => (
8190                        DependencyEndpoint::from_native(related, related_kind),
8191                        DependencyEndpoint::from_native(id.clone(), near_kind),
8192                    ),
8193                };
8194                Ok(DependencyEdge {
8195                    from,
8196                    to,
8197                    kind: DependencyKind::Blocks,
8198                })
8199            })
8200            .collect::<Result<Vec<_>, SourceError>>()?;
8201        let mut next = next_cursor(connection)?;
8202        if let Some(next) = &next {
8203            validate_cursor_progress(cursor, &next.0)?;
8204        }
8205        if next.is_none()
8206            && !self
8207                .recorded_edges(id, near_kind, direction, natively_names, node)
8208                .await?
8209                .is_empty()
8210        {
8211            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
8212        }
8213        Ok(Page { items, next })
8214    }
8215
8216    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
8217    /// a far end in another source has to live: no GitHub issue relationship can name one.
8218    ///
8219    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
8220    /// source never writes one down.
8221    ///
8222    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
8223    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
8224    /// request beyond the read already made, and reading the board for them would be a
8225    /// walk of every item for one field of one. A draft has no body in that answer, because
8226    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
8227    /// listing of the board, which can be behind on the very item asked about.
8228    async fn recorded_edges(
8229        &self,
8230        id: &NativeId,
8231        near_kind: ItemKind,
8232        direction: Direction,
8233        natively_names: Option<ItemKind>,
8234        node: &Value,
8235    ) -> Result<Vec<DependencyEdge>, SourceError> {
8236        if direction != Direction::DependsOn {
8237            return Ok(Vec::new());
8238        }
8239        let slot = match node.get("body") {
8240            Some(body) if natively_names.is_some() => {
8241                metadata_body(body.as_str().map(str::to_owned))?.1
8242            }
8243            _ => {
8244                let Some(item) = self.bound_item(id).await? else {
8245                    return Ok(Vec::new());
8246                };
8247                item.slot
8248            }
8249        };
8250        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
8251            .map_err(|message| SourceError::Malformed { message })
8252    }
8253
8254    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
8255        self.repository
8256            .as_ref()
8257            .ok_or_else(|| SourceError::Refused {
8258                message: format!(
8259                    "source {} has no repository configured, and a GitHub Projects board has no \
8260                 repository of its own to create an issue in; set repository: owner/name on \
8261                 this source",
8262                    self.name
8263                ),
8264            })
8265    }
8266
8267    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
8268    /// states.
8269    ///
8270    /// The fallback is demanded first, whichever arm answers: a write without a configured
8271    /// repository is refused naming the field exactly as it was before the rule existed,
8272    /// so a source that could not write before cannot write now, rather than writing for
8273    /// the one item whose own field happens to decide it.
8274    ///
8275    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
8276    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
8277    /// entry owned by someone other than the owner of the parent issue's repository —
8278    /// GitHub accepts a sub-issue from another repository of the same owner and from no
8279    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
8280    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
8281    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
8282    /// and is visible to the token is checked where its node id is resolved, still before
8283    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
8284    /// looked up in a listing of the board, which can be minutes behind an issue its own
8285    /// `projectItems` already places on it — and that read answers first from this process's
8286    /// own record, so a project created moments ago in this command answers though GitHub
8287    /// has not caught up.
8288    async fn creation_target(
8289        &self,
8290        incoming: &Incoming<'_>,
8291    ) -> Result<RepositoryTarget, SourceError> {
8292        let fallback = self.configured_repository()?;
8293        let what = |incoming: &Incoming<'_>| {
8294            format!(
8295                "{} {:?}",
8296                incoming.written.kind().describes(),
8297                incoming.title
8298            )
8299        };
8300        let parent = match incoming.parent {
8301            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
8302                SourceError::Refused {
8303                    message: format!(
8304                        "GitHub project issue {} was not found on the board of source {}, so {} \
8305                         cannot be filed under it",
8306                        parent.0,
8307                        self.name,
8308                        what(incoming)
8309                    ),
8310                }
8311            })?),
8312            None => None,
8313        };
8314        let parents_repository = parent
8315            .as_ref()
8316            .map(|parent| {
8317                // A draft is on the board and so is found, but it has no repository to
8318                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
8319                // would refuse the task only once `createIssue` had made it.
8320                if parent.content_kind == ContentKind::DraftIssue {
8321                    return Err(SourceError::Refused {
8322                        message: format!(
8323                            "GitHub project item {} on the board of source {} is a draft, \
8324                             which cannot have sub-issues, so {} cannot be filed under it",
8325                            parent.id.0,
8326                            self.name,
8327                            what(incoming)
8328                        ),
8329                    });
8330                }
8331                // An issue's repository is where a sub-issue is placed and whose owner it
8332                // is compared against, so a parent whose repository this source cannot
8333                // spell as `owner/name` — GitHub's login grammar is wider than this
8334                // source's floor — is one nothing can be filed under.
8335                parent
8336                    .own_repository
8337                    .as_ref()
8338                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
8339                    .ok_or_else(|| SourceError::Malformed {
8340                        message: format!(
8341                            "GitHub project issue {} on the board of source {} is in {}, which \
8342                             is not a {}/owner/name repository this source can place {} in",
8343                            parent.id.0,
8344                            self.name,
8345                            parent
8346                                .own_repository
8347                                .as_ref()
8348                                .map_or("no repository", Repository::as_str),
8349                            RepositoryTarget::HOST,
8350                            what(incoming)
8351                        ),
8352                    })
8353            })
8354            .transpose()?;
8355        match incoming.repositories {
8356            [named] => {
8357                let target =
8358                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
8359                        message: format!(
8360                            "{} names repository {}, which is not a {}/owner/name repository \
8361                             source {} can create an issue in; name one that is, or name none",
8362                            what(incoming),
8363                            named.as_str(),
8364                            RepositoryTarget::HOST,
8365                            self.name
8366                        ),
8367                    })?;
8368                if let Some(parents) = &parents_repository
8369                    && parents.owner != target.owner
8370                {
8371                    return Err(SourceError::Refused {
8372                        message: format!(
8373                            "{} names repository {}, owned by {}, but its project's issue is in \
8374                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
8375                             of the same owner as its parent issue; name a repository of {}, or \
8376                             name none",
8377                            what(incoming),
8378                            target.slug(),
8379                            target.owner,
8380                            parents.slug(),
8381                            parents.owner,
8382                            parents.owner
8383                        ),
8384                    });
8385                }
8386                Ok(target)
8387            }
8388            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
8389        }
8390    }
8391
8392    /// The node id of the repository `incoming` is being created in, or the refusal naming
8393    /// the item and the repository the token cannot see.
8394    ///
8395    /// Resolved once per command per repository; see [`Self::repository_cache`].
8396    async fn repository_id(
8397        &self,
8398        repository: &RepositoryTarget,
8399        incoming: &Incoming<'_>,
8400    ) -> Result<String, SourceError> {
8401        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
8402            return Ok(id);
8403        }
8404        let data = self
8405            .graphql(
8406                graphql::REPOSITORY,
8407                json!({"owner":repository.owner,"name":repository.name}),
8408            )
8409            .await?;
8410        self.repository_read(&data, repository, incoming)
8411    }
8412
8413    /// The repository's node id out of an answer carrying the `repository` root, held for
8414    /// the rest of this command, or the refusal naming the item that cannot be created in it.
8415    fn repository_read(
8416        &self,
8417        data: &Value,
8418        repository: &RepositoryTarget,
8419        incoming: &Incoming<'_>,
8420    ) -> Result<String, SourceError> {
8421        let node = data
8422            .get("repository")
8423            .filter(|value| !value.is_null())
8424            .ok_or_else(|| SourceError::Refused {
8425                message: format!(
8426                    "GitHub repository {} was not found or is not visible to the token, so {} \
8427                     {:?} cannot be created in it",
8428                    repository.slug(),
8429                    incoming.written.kind().describes(),
8430                    incoming.title
8431                ),
8432            })?;
8433        let id = required_str(node, "id")?.to_owned();
8434        self.repository_cache()?
8435            .insert(repository.clone(), id.clone());
8436        Ok(id)
8437    }
8438
8439    fn repository_cache(
8440        &self,
8441    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
8442        self.repository_cache
8443            .lock()
8444            .map_err(|_| SourceError::Unavailable {
8445                message: "this source's record of the destination repository was left \
8446                          inconsistent by an earlier failure; next: run the command again"
8447                    .into(),
8448            })
8449    }
8450
8451    /// Create or update one board item, whichever kind it is.
8452    async fn write_item(
8453        &self,
8454        incoming: &Incoming<'_>,
8455        target: Option<&NativeId>,
8456        depends_on: &[DependencyEdge],
8457    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
8458        // Refused before anything is read or written: a task or a project titled the way
8459        // this board spells a document would land as an issue this same source reads back
8460        // as a document, so the field this destination cannot carry is named rather than
8461        // written and silently reclassified.
8462        if let Written::Work(kind, _) = incoming.written
8463            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
8464        {
8465            return Err(SourceError::Refused {
8466                message: format!(
8467                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
8468                     spells a document, so it would read back as one rather than as a {}; \
8469                     retitle it, or copy it as a document",
8470                    kind.marker(),
8471                    self.name,
8472                    kind.marker()
8473                ),
8474            });
8475        }
8476        // The destination is read by its own id, and whether this board holds it is decided
8477        // by that read — its own `projectItems` — rather than by whether a listing of the
8478        // board happens to include it yet. See the module documentation.
8479        let existing = match target {
8480            Some(target) => {
8481                Some(
8482                    self.bound_item(target)
8483                        .await?
8484                        .ok_or_else(|| SourceError::Refused {
8485                            message: format!("GitHub destination item {} was not found", target.0),
8486                        })?,
8487                )
8488            }
8489            None => None,
8490        };
8491        let existing = existing.as_ref();
8492        // An existing issue is never moved; a new one is created where the rule says — and
8493        // knowing where is what lets the board's fields and that repository's id be read
8494        // together, before anything below needs either.
8495        let creation_target = match existing {
8496            Some(_) => None,
8497            None => {
8498                let target = self.creation_target(incoming).await?;
8499                self.creation_context(&target, incoming).await?;
8500                Some(target)
8501            }
8502        };
8503        let board = self
8504            .fields_for(
8505                existing,
8506                incoming.written.status().is_some(),
8507                incoming
8508                    .priority
8509                    .is_some_and(|priority| priority != Priority::None),
8510            )
8511            .await?;
8512        let status_target = incoming
8513            .written
8514            .work_status()
8515            .map(|(kind, status)| self.resolved_target(kind, status.category))
8516            .transpose()?;
8517        let column = match (incoming.written.work_status(), status_target.as_ref()) {
8518            (Some((kind, status)), Some(target)) => {
8519                self.column_for(&board.fields, kind, status.category, target)?
8520            }
8521            _ => None,
8522        };
8523        // Resolved before anything is created, for the reason the column above is: a
8524        // priority this board has no option for is refused while nothing has been written.
8525        let priority_write = match incoming.priority {
8526            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
8527            None => None,
8528        };
8529        // And for the reason the priority is: a value no projected field can hold, or a board
8530        // that cannot hold the field, is refused while nothing has been written.
8531        let (projections, projected) = self.projection_writes(
8532            &board.fields,
8533            existing.map(|item| &item.projected),
8534            incoming.metadata,
8535            None,
8536        )?;
8537        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
8538        if content_kind == ContentKind::DraftIssue {
8539            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
8540                (status_target.as_ref(), incoming.written.status())
8541            {
8542                return Err(self.closes_a_draft(status.category));
8543            }
8544            if incoming.parent.is_some() {
8545                return Err(SourceError::Refused {
8546                    message: "GitHub draft items cannot be a project's sub-issue".into(),
8547                });
8548            }
8549        }
8550        match existing {
8551            Some(item) if content_kind == ContentKind::Issue => {
8552                if item.labels != incoming.labels {
8553                    return Err(SourceError::Refused {
8554                        message: "GitHub issue labels differ from the labels being written".into(),
8555                    });
8556                }
8557            }
8558            _ => {
8559                if !incoming.labels.is_empty() {
8560                    return Err(SourceError::Refused {
8561                        message: "GitHub items created by this destination carry no labels".into(),
8562                    });
8563                }
8564            }
8565        }
8566
8567        // The repository the issue really lives in is what the slot below is written against,
8568        // so a single entry that is where the issue is created travels as no key at all, and
8569        // the read side derives it back from the issue.
8570        let own_repository = match (existing, &creation_target) {
8571            (Some(item), _) => item.own_repository.clone(),
8572            (None, Some(target)) => Some(
8573                Repository::try_from(target.origin())
8574                    .map_err(|message| SourceError::Config { message })?,
8575            ),
8576            (None, None) => None,
8577        };
8578        let (native, fallback) = self
8579            .partition_edges(
8580                incoming.written.kind(),
8581                content_kind,
8582                existing.and_then(|item| item.blocked_by.as_deref()),
8583                depends_on,
8584            )
8585            .await?;
8586        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
8587        let content = match incoming.assets {
8588            Some(assets) => {
8589                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
8590                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
8591                    incoming.content.unwrap_or_default(),
8592                    &mut slot,
8593                    &uploads,
8594                );
8595                incoming.content.map(|_| rewritten)
8596            }
8597            None => incoming.content.map(str::to_owned),
8598        };
8599        let body = compose_body(content.as_deref(), &slot)?;
8600        // Read before anything is created, for the reason the field below is: a value
8601        // this destination cannot store has to refuse, and refusing after `createIssue`
8602        // would leave an issue behind that nothing asked for. The engine writes a
8603        // qualified id here; a caller handing this key anything else is told so rather
8604        // than having it silently stored as no origin at all.
8605        // 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.
8606        let origin = match incoming.metadata.get(ORIGIN_KEY) {
8607            None => "",
8608            Some(Value::String(origin)) => origin.as_str(),
8609            Some(other) => {
8610                return Err(SourceError::Refused {
8611                    message: format!(
8612                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
8613                         is {other}"
8614                    ),
8615                });
8616            }
8617        };
8618        // Resolved before anything is created: a board that cannot carry the copy origin
8619        // has to refuse the write, and refusing it after `createIssue` would leave an
8620        // issue behind that nothing asked for.
8621        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
8622            Some(field) => {
8623                if required_str(field, "__typename")? != "ProjectV2Field" {
8624                    return Err(SourceError::Refused {
8625                        message: format!(
8626                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
8627                        ),
8628                    });
8629                }
8630                Some(required_str(field, "id")?.to_owned())
8631            }
8632            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
8633                return Err(SourceError::Refused {
8634                    message: format!(
8635                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
8636                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
8637                         the board"
8638                    ),
8639                });
8640            }
8641            None => None,
8642        };
8643
8644        let Landed {
8645            content_id,
8646            item_id,
8647            url,
8648            number,
8649        } = match existing {
8650            // Its content is written last, below, once everything else has landed.
8651            Some(item) => Landed {
8652                content_id: item.id.clone(),
8653                item_id: item.item_id.clone(),
8654                url: item.url.clone(),
8655                number: item.number,
8656            },
8657            None => {
8658                let target = creation_target
8659                    .as_ref()
8660                    .ok_or_else(|| SourceError::Malformed {
8661                        message: "a new item was decided without a repository to create it in"
8662                            .into(),
8663                    })?;
8664                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
8665                    .await?
8666            }
8667        };
8668
8669        // A write the store's cache expects sends only what differs from the version it expects:
8670        // a priority the item already holds there is not sent, so a person's edit of it since is
8671        // left alone. Every other write sends it, as its pinned cost says.
8672        let priority_write = priority_write.filter(|_| {
8673            !existing.is_some_and(|item| {
8674                self.expected.is_primed(&item.id)
8675                    && incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
8676            })
8677        });
8678        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
8679        let column = column
8680            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
8681            .map(|(field, option, _)| (field, option));
8682        // Creating an item here is several calls — `createIssue`, which files it on the
8683        // board, then its board fields, the parent and the dependencies — and GitHub can fail
8684        // at any of them. Everything this source can refuse *before* the first of those is
8685        // already checked above, so what is left is GitHub itself failing part way. When it
8686        // does over an item this call created, the issue is taken back: a write that
8687        // refused must not leave an item behind that nobody asked for, and one that does
8688        // makes the retry create a second.
8689        // Whether the board-field write carrying a moved origin was answered as landing whole.
8690        // When it was refused, GitHub does not say which of its fields ran before the one that
8691        // failed, so the origin may or may not have moved.
8692        let mut origin_landed = false;
8693        let landed = self
8694            .finish_write(
8695                board.id.as_str(),
8696                incoming,
8697                &content_id,
8698                &item_id,
8699                content_kind,
8700                existing,
8701                origin_field.as_deref(),
8702                origin,
8703                column,
8704                status_target.as_ref(),
8705                priority_write.as_ref(),
8706                &projections,
8707                &native,
8708                &mut origin_landed,
8709            )
8710            .await;
8711        // An existing item's title, body and state go last, in one `updateIssue`, once its board
8712        // fields and its relationships have landed: a refusal of any of those then leaves its
8713        // body — and the metadata slot inside it — exactly as it stood.
8714        let landed = match (landed, existing) {
8715            (Ok(()), Some(item)) => {
8716                self.update_existing(item, incoming, &body, status_target.as_ref())
8717                    .await
8718            }
8719            (landed, _) => landed,
8720        };
8721        if let Err(error) = landed {
8722            match existing {
8723                // Best effort, and the write's own failure is what the caller is told: a
8724                // refusal naming the tidy-up would hide why the write failed at all.
8725                None => {
8726                    let _ = self.delete_issue(&content_id).await;
8727                }
8728                // The origin field is the one piece of an existing item's metadata written
8729                // before its body, so a write refused after it puts it back as it was. When
8730                // that is refused too, the write's own failure is still what the caller is
8731                // told — with what it left behind added, because the item's metadata is then
8732                // not as it stood and a caller retrying has to know which key moved.
8733                Some(item) => {
8734                    let before = item.origin.as_deref().unwrap_or("");
8735                    if let Some(field) = origin_field.as_deref()
8736                        && before != origin
8737                        && let Err(restore) = self
8738                            .set_item_field(
8739                                board.id.as_str(),
8740                                &item.item_id,
8741                                field,
8742                                json!({"text": before}),
8743                            )
8744                            .await
8745                    {
8746                        let left = if origin_landed {
8747                            format!(
8748                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
8749                                 not be put back to {before:?} ({restore}), so item {} still \
8750                                 holds {origin:?} there",
8751                                item.id.0
8752                            )
8753                        } else {
8754                            format!(
8755                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
8756                                 {origin:?}, GitHub does not say whether that part of it ran, \
8757                                 and putting it back to {before:?} was refused ({restore}), so \
8758                                 item {} holds {origin:?} or {before:?} there",
8759                                item.id.0
8760                            )
8761                        };
8762                        return Err(noting(
8763                            error,
8764                            &format!(
8765                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
8766                                 run the write again"
8767                            ),
8768                        ));
8769                    }
8770                }
8771            }
8772            return Err(error);
8773        }
8774
8775        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
8776            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
8777                self.statuses
8778                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
8779            }
8780            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
8781                self.statuses
8782                    .status(kind, written_option.as_deref(), false, None)
8783            }
8784            (Some((_, status)), _) => status.clone(),
8785            (None, _) => Status {
8786                category: StatusCategory::Unknown,
8787                name: "Open".to_owned(),
8788            },
8789        };
8790
8791        // So the rest of this command reads what it just did rather than what the board
8792        // said before it. See `remember_written` for which half takes it.
8793        let remembered = Resolved {
8794            item_id,
8795            id: content_id.clone(),
8796            content_kind,
8797            kind: incoming.written.kind(),
8798            title: incoming.title.to_owned(),
8799            // The visible half of the body this write composed, split back off it the
8800            // way a read splits it — so what this record reports is what a read of the
8801            // same issue reports, rather than the person's text with the metadata slot
8802            // still on the end of it.
8803            body: metadata_body(body.clone())?.0,
8804            raw_body: body.clone(),
8805            // A document has no status of its own; what it reads back as is whatever
8806            // the issue's own state says, which is what a re-read reports.
8807            status: written_status,
8808            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
8809            priority: match incoming.priority {
8810                Some(priority) => HeldPriority::Read(priority),
8811                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
8812                    item.priority.clone()
8813                }),
8814            },
8815            // What `state_input` asked for: closed for a terminal target, open for any other
8816            // status, and the issue's own state left as it was by a document write.
8817            closed: content_kind == ContentKind::Issue
8818                && match status_target.as_ref() {
8819                    Some(StatusTarget::Terminal(_, _)) => true,
8820                    Some(_) => false,
8821                    None => existing.is_some_and(|item| item.closed),
8822                },
8823            reason: match status_target.as_ref() {
8824                Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
8825                    Some(reason.reason().to_owned())
8826                }
8827                Some(_) => None,
8828                None => existing.and_then(|item| item.reason.clone()),
8829            },
8830            delivers: incoming.delivers.to_vec(),
8831            delivered_by: incoming.delivered_by.to_vec(),
8832            labels: incoming.labels.to_vec(),
8833            parent: incoming.parent.cloned(),
8834            origin: (!origin.is_empty()).then(|| origin.to_owned()),
8835            projected,
8836            number,
8837            // In the update path this is the item's own url, read off `existing` where the
8838            // record above was bound, so one expression serves both halves.
8839            url,
8840            created_at: existing.and_then(|item| item.created_at),
8841            updated_at: existing.and_then(|item| item.updated_at),
8842            // What this write moved is GitHub's to say, so a record of it gives no token until
8843            // it is read again.
8844            item_updated_at: None,
8845            comment_count: None,
8846            relationship_events: None,
8847            served_at: None,
8848            facets: None,
8849            own_repository,
8850            repositories: incoming.repositories.to_vec(),
8851            classification: incoming.classification,
8852            slot,
8853            board_id: Some(board.id.as_str().to_owned()),
8854            fields: board
8855                .fields
8856                .get("nodes")
8857                .and_then(Value::as_array)
8858                .cloned()
8859                .unwrap_or_default(),
8860            board_fields: Some(board.fields.clone()),
8861            // What this write left the relationship holding is known by id alone, and a
8862            // later read of its edges needs each far end's kind, so it reads them again.
8863            blocked_by: None,
8864        };
8865        self.remember_written(remembered, existing.is_none())?;
8866        Ok(onetaskgraph_plugin_api::AssetsWritten {
8867            id: content_id,
8868            content,
8869        })
8870    }
8871
8872    /// Everything a write does after the item exists: its board fields, its parent, and
8873    /// its dependencies.
8874    ///
8875    /// Split out of `write_item` so there is one place a failure past the point of no
8876    /// return is caught, rather than a tidy-up repeated at each `?` above.
8877    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
8878    // so there is one place a failure past the point of no return is caught, and its
8879    // arguments are exactly the values that tail already had in scope. Bundling them into a
8880    // struct would describe no concept — it would be "the arguments of this function" — and
8881    // would put the whole of `write_item`'s locals behind one more indirection.
8882    #[allow(clippy::too_many_arguments)]
8883    async fn finish_write(
8884        &self,
8885        board_id: &str,
8886        incoming: &Incoming<'_>,
8887        content_id: &NativeId,
8888        item_id: &str,
8889        content_kind: ContentKind,
8890        existing: Option<&Resolved>,
8891        origin_field: Option<&str>,
8892        origin: &str,
8893        column: Option<(String, String)>,
8894        status_target: Option<&StatusTarget>,
8895        priority: Option<&PriorityWrite>,
8896        projections: &[ProjectionWrite],
8897        native: &[String],
8898        origin_landed: &mut bool,
8899    ) -> Result<(), SourceError> {
8900        let mut fields = Vec::new();
8901        if let Some(field_id) = origin_field
8902            && existing.map_or(!origin.is_empty(), |item| {
8903                item.origin.as_deref().unwrap_or("") != origin
8904            })
8905        {
8906            fields.push((field_id.to_owned(), json!({"text":origin})));
8907        }
8908        if let Some((field_id, option_id)) = column {
8909            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
8910        }
8911        let mut clears = Vec::new();
8912        match priority {
8913            Some(PriorityWrite::Select { field, option }) => {
8914                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
8915            }
8916            Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
8917            None => {}
8918        }
8919        for projection in projections {
8920            projection.join(&mut fields, &mut clears);
8921        }
8922        self.set_item_fields(board_id, item_id, &fields, &clears)
8923            .await?;
8924        *origin_landed = true;
8925
8926        // An existing issue closes in the `updateIssue` its write ends with; one created just
8927        // now closes here, once its option is selected.
8928        if existing.is_none()
8929            && content_kind == ContentKind::Issue
8930            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
8931        {
8932            self.update_content(
8933                ContentKind::Issue,
8934                content_id,
8935                json!({"stateInput":state_input(status_target)}),
8936            )
8937            .await?;
8938        }
8939
8940        if content_kind == ContentKind::Issue {
8941            self.reparent(
8942                existing.and_then(|item| item.parent.clone()),
8943                content_id,
8944                incoming.parent,
8945            )
8946            .await?;
8947            // A document takes part in no dependency graph, so writing one neither reads
8948            // nor changes the issue's own `blockedBy` relationships. Reconciling them
8949            // against the empty list a document write carries would *delete* whatever
8950            // relationships a person had made on that issue, which is a write nobody
8951            // asked for.
8952            if incoming.written.kind() != BoardKind::Document {
8953                let issue = match existing {
8954                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
8955                    None => Issue::Created,
8956                };
8957                self.reconcile_blocked_by(content_id, native, issue).await?;
8958            }
8959        }
8960        Ok(())
8961    }
8962
8963    /// Delete one issue, which takes its board item with it.
8964    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
8965        let data = self
8966            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
8967            .await?;
8968        data.pointer("/deleteIssue/repository")
8969            .filter(|value| !value.is_null())
8970            .ok_or_else(|| SourceError::Malformed {
8971                message: "GitHub issue deletion returned no repository".into(),
8972            })?;
8973        self.forget(id)?;
8974        Ok(())
8975    }
8976
8977    /// Remove one item this copy created, so a copy that could not finish leaves the board
8978    /// as it found it.
8979    ///
8980    /// Deleting the issue takes its board item with it, so there is no second mutation to
8981    /// keep in step. An id the board does not hold is not an error: the item is already
8982    /// gone, which is the state this asks for. Which that is, is decided by reading the item
8983    /// by its own id — a listing of the board can still be missing an item it holds, and
8984    /// reading that as *already gone* would leave behind the very item this was asked to
8985    /// take back.
8986    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
8987        let Some(item) = self.bound_item(id).await? else {
8988            return Ok(());
8989        };
8990        if item.content_kind == ContentKind::DraftIssue {
8991            return Err(SourceError::Refused {
8992                message: format!(
8993                    "GitHub item {} is a draft, and this source removes an item by deleting \
8994                     its issue; next: remove it from the board by hand",
8995                    id.0
8996                ),
8997            });
8998        }
8999        let data = self
9000            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
9001            .await?;
9002        data.pointer("/deleteIssue/repository")
9003            .filter(|value| !value.is_null())
9004            .ok_or_else(|| SourceError::Malformed {
9005                message: "GitHub issue deletion returned no repository".into(),
9006            })?;
9007        self.forget(id)?;
9008        Ok(())
9009    }
9010
9011    /// The issue a comment call on `task` is about, or `None` when this board holds no such
9012    /// task.
9013    ///
9014    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
9015    /// read of the task cannot disagree about which ids name one: a project or a document of
9016    /// this board is not a task here either.
9017    ///
9018    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
9019    /// issues and a draft is not one. It is refused rather than answered with an empty page,
9020    /// which would read as a task nobody has commented on yet.
9021    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
9022        let cached = self.resolved_cache()?.get(task).cloned();
9023        let Some(item) = (match cached {
9024            Some(item) => Some(item),
9025            None => self.item_by_id(task).await?,
9026        })
9027        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
9028            return Ok(None);
9029        };
9030        if item.content_kind == ContentKind::DraftIssue {
9031            return Err(self.draft_has_no_comments(task));
9032        }
9033        Ok(Some(item.id))
9034    }
9035
9036    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
9037    /// issues, and a draft is not one.
9038    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
9039        SourceError::Refused {
9040            message: format!(
9041                "task {} of source {} is a draft item on the board, and GitHub keeps \
9042                 comments on issues alone, so a draft has none to read or write; next: \
9043                 convert the draft to an issue on the board, then comment on the issue it \
9044                 becomes",
9045                task.0, self.name
9046            ),
9047        }
9048    }
9049
9050    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
9051    /// request — or `None` when this board holds no task by that id.
9052    ///
9053    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
9054    /// is answered with the draft and the refusal, at the price of the draft's own read.
9055    async fn issue_detail(
9056        &self,
9057        id: &NativeId,
9058        page: &PageRequest,
9059    ) -> Result<Option<TaskDetailRead>, SourceError> {
9060        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
9061        let asked = self
9062            .graphql(
9063                graphql::ISSUE_DETAIL,
9064                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
9065                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
9066                       "duplicates":true}),
9067            )
9068            .await;
9069        let data = match asked {
9070            Ok(data) => data,
9071            Err(error) if unresolvable_node(&error) => return Ok(None),
9072            Err(error) => return Err(error),
9073        };
9074        // `node` is null for an id that names nothing, and absent only from an answer this
9075        // source cannot read — never the same thing.
9076        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
9077            message: format!("GitHub answered the read of {} with no node", id.0),
9078        })?;
9079        self.detail_of(id, node, true, after).await
9080    }
9081
9082    /// Several tasks, each with the first page of its comments when `comments` is set, read
9083    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
9084    /// order.
9085    ///
9086    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
9087    /// one item at a time, so that id is answered as missing and the others as themselves; any
9088    /// other refusal is every id of that batch's answer.
9089    async fn issue_details(
9090        &self,
9091        ids: &[NativeId],
9092        comments: Option<&PageRequest>,
9093    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9094        let mut read = Vec::with_capacity(ids.len());
9095        for batch in ids.chunks(DETAIL_BATCH) {
9096            match self
9097                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
9098                .await
9099            {
9100                Ok(data) => {
9101                    for (slot, id) in batch.iter().enumerate() {
9102                        // Every alias asked for is answered, null for an id naming nothing;
9103                        // one missing is an answer this source cannot read.
9104                        let read_one = match data.get(format!("i{slot}")) {
9105                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
9106                            None => Err(SourceError::Malformed {
9107                                message: format!(
9108                                    "GitHub answered a batch read with no item for {}",
9109                                    id.0
9110                                ),
9111                            }),
9112                        };
9113                        read.push(read_one);
9114                    }
9115                }
9116                Err(error) if unresolvable_node(&error) => {
9117                    for id in batch {
9118                        read.push(match comments {
9119                            Some(page) => self.issue_detail(id, page).await,
9120                            None => self.task_read(id).await,
9121                        });
9122                    }
9123                }
9124                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
9125            }
9126        }
9127        read
9128    }
9129
9130    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
9131    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
9132        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
9133            task,
9134            comments: None,
9135        }))
9136    }
9137
9138    /// What one node a detail read reached says: the task this board holds by `id`, with the
9139    /// page of comments the node carries when `commented` — or `None` for a node that is no
9140    /// task of this board.
9141    ///
9142    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
9143    /// and an item this process created answers from this process's own record, which a node
9144    /// read taken moments after the write can still be behind.
9145    async fn detail_of(
9146        &self,
9147        id: &NativeId,
9148        node: &Value,
9149        commented: bool,
9150        after: Option<&str>,
9151    ) -> Result<Option<TaskDetailRead>, SourceError> {
9152        if node.is_null() {
9153            return Ok(None);
9154        }
9155        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
9156        // An issue answered under one id is that id's, or the answer is not one this source
9157        // can report: reporting another issue's task and comments under the qualified id asked
9158        // for would be the one wrong answer here. A draft's own read checks the same.
9159        if !draft
9160            && optional_str(node, "__typename")? == Some("Issue")
9161            && required_str(node, "id")? != id.0
9162        {
9163            return Err(SourceError::Malformed {
9164                message: format!(
9165                    "GitHub answered the read of {} with issue {}",
9166                    id.0,
9167                    required_str(node, "id")?
9168                ),
9169            });
9170        }
9171        let item = if draft {
9172            self.draft_by_id(id).await?
9173        } else {
9174            self.resolve_issue(node).await?
9175        };
9176        // A read that carried the whole of what the issue is blocked by — a copy's — is kept
9177        // for this command, so the dependency read the copy makes next is answered from it.
9178        if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
9179            self.resolved_cache()?.insert(read.id.clone(), read.clone());
9180        }
9181        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
9182            return Ok(None);
9183        };
9184        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
9185        let task = own.unwrap_or(item).task()?;
9186        let comments = match (commented, draft) {
9187            (false, _) => None,
9188            (true, true) => Some(Err(self.draft_has_no_comments(id))),
9189            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
9190        };
9191        if commented && !draft && after.is_none() {
9192            self.expected.learn_comments(id, node);
9193        }
9194        Ok(Some(TaskDetailRead { task, comments }))
9195    }
9196
9197    /// Whether the comment `comment` is one of `issue`'s own.
9198    ///
9199    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
9200    /// comment's id and nothing else: a comment id given against the wrong task would
9201    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
9202    /// names something that is not an issue comment, is a comment this task does not have —
9203    /// which is what GitHub refusing to resolve it means too.
9204    async fn comment_is_on(
9205        &self,
9206        issue: &NativeId,
9207        comment: &NativeId,
9208    ) -> Result<bool, SourceError> {
9209        Ok(self.comment_on(issue, comment).await?.is_some())
9210    }
9211
9212    /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
9213    /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
9214    async fn comment_on(
9215        &self,
9216        issue: &NativeId,
9217        comment: &NativeId,
9218    ) -> Result<Option<String>, SourceError> {
9219        // A write the store's cache expects knows which comments its issue held, and their
9220        // bodies, at the version it expects: what this read would answer.
9221        if let Some(primed) = self.expected.primed_comment(issue, comment) {
9222            return Ok(primed);
9223        }
9224        let asked = self
9225            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
9226            .await;
9227        let data = match asked {
9228            Ok(data) => data,
9229            Err(error) if unresolvable_node(&error) => return Ok(None),
9230            Err(error) => return Err(error),
9231        };
9232        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
9233            return Ok(None);
9234        };
9235        if optional_str(node, "__typename")? != Some("IssueComment") {
9236            return Ok(None);
9237        }
9238        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
9239            message: format!("GitHub issue comment {} names no issue", comment.0),
9240        })?;
9241        if required_str(on, "id")? != issue.0 {
9242            return Ok(None);
9243        }
9244        Ok(Some(required_str(node, "body")?.to_owned()))
9245    }
9246
9247    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
9248    async fn partition_edges(
9249        &self,
9250        near_kind: BoardKind,
9251        near_content: ContentKind,
9252        carried: Option<&[Value]>,
9253        depends_on: &[DependencyEdge],
9254    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
9255        let mut native = Vec::new();
9256        let mut fallback = Vec::new();
9257        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
9258            .iter()
9259            .map(|edge| {
9260                let same_source = edge
9261                    .to
9262                    .source()
9263                    .is_none_or(|source| source == self.name.as_str());
9264                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
9265                // `DependencyEndpoint::source` both read it that way — and a native id may hold
9266                // colons of its own, so the far end is everything after that one separator.
9267                // Splitting at the last would truncate `work:urn:task:7` to `7`.
9268                let far_id = if edge.to.is_qualified() {
9269                    edge.to
9270                        .id()
9271                        .split_once(':')
9272                        .map_or(edge.to.id(), |(_, native)| native)
9273                } else {
9274                    edge.to.id()
9275                };
9276                // One that already blocks the near issue was answered by that issue's own
9277                // read, which carried each of its blockers' kinds — an issue every one — so it
9278                // is not read again.
9279                let blocking = carried.and_then(|nodes| {
9280                    nodes
9281                        .iter()
9282                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
9283                });
9284                (edge, far_id, same_source, blocking)
9285            })
9286            .collect();
9287        // Every other same-source far end is read by its own id, exactly as the item it is a
9288        // far end of is: whether this board holds it is that read's answer, never a listing's.
9289        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
9290        let mut unread: Vec<NativeId> = Vec::new();
9291        for (_, far_id, same_source, blocking) in &far_ends {
9292            let id = NativeId((*far_id).to_owned());
9293            if *same_source && blocking.is_none() && !unread.contains(&id) {
9294                unread.push(id);
9295            }
9296        }
9297        let read: BTreeMap<NativeId, Option<Resolved>> = unread
9298            .iter()
9299            .cloned()
9300            .zip(self.items_by_ids(&unread).await?)
9301            .collect();
9302        for (edge, far_id, same_source, blocking) in far_ends {
9303            let far = match (same_source, blocking) {
9304                (false, _) => None,
9305                (true, Some(node)) => Some(FarEnd {
9306                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9307                        BoardKind::Document
9308                    } else {
9309                        BoardKind::Work(related_kind(node)?)
9310                    },
9311                    content_kind: ContentKind::Issue,
9312                }),
9313                (true, None) => {
9314                    let read = read
9315                        .get(&NativeId(far_id.to_owned()))
9316                        .cloned()
9317                        .flatten()
9318                        .ok_or_else(|| SourceError::Refused {
9319                            message: format!("GitHub dependency item {far_id} was not found"),
9320                        })?;
9321                    Some(FarEnd {
9322                        kind: read.kind,
9323                        content_kind: read.content_kind,
9324                    })
9325                }
9326            };
9327            let far = far.as_ref();
9328            // The caller says which kind the far end is, and this board holds the far end
9329            // itself, so a disagreement is settled here rather than stored: recorded, the
9330            // wrong kind would read back as a cross-level edge that never existed; written
9331            // natively, it would name a relationship of a different level than the caller
9332            // asked for.
9333            //
9334            // A far end this board holds as a *document* fails the same comparison and is
9335            // refused by the same sentence: `ItemKind` has no document variant because
9336            // nothing may point at one, so no caller can name it correctly and the refusal
9337            // is the only honest answer.
9338            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
9339                return Err(SourceError::Refused {
9340                    message: format!(
9341                        "GitHub dependency item {far_id} is a {} of this board, and this item \
9342                         names it as a {}; record the kind it is",
9343                        disagreeing.kind.describes(),
9344                        edge.to.kind.marker()
9345                    ),
9346                });
9347            }
9348            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
9349            // however the far end is spelled — and one classified native here would be
9350            // written nowhere at all, because a draft's native reconciliation never runs.
9351            let native_here = near_content == ContentKind::Issue
9352                && far.is_some_and(|far| {
9353                    far.content_kind == ContentKind::Issue
9354                        && BoardKind::Work(edge.to.kind) == near_kind
9355                });
9356            if native_here {
9357                native.push(far_id.to_owned());
9358            } else {
9359                fallback.push(edge.clone());
9360            }
9361        }
9362        Ok((native, fallback))
9363    }
9364
9365    async fn update_existing(
9366        &self,
9367        item: &Resolved,
9368        incoming: &Incoming<'_>,
9369        body: &Option<String>,
9370        status_target: Option<&StatusTarget>,
9371    ) -> Result<(), SourceError> {
9372        let title = incoming.written_title();
9373        // A terminal status closes the issue here, in the same mutation as its body: its board
9374        // option was selected before this, so a close never lands on an item whose board cannot
9375        // show it.
9376        let mut fields = match item.content_kind {
9377            ContentKind::DraftIssue => json!({"title":title,"body":body}),
9378            ContentKind::Issue => json!({"title":title,"body":body,
9379                                         "stateInput":state_input(status_target)}),
9380        };
9381        // A write the store's cache expects sends only the members whose value differs from the
9382        // version it expects, and nothing at all when none does.
9383        if self.expected.is_primed(&item.id)
9384            && let Some(members) = fields.as_object_mut()
9385        {
9386            if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
9387                members.remove("title");
9388            }
9389            if members.get("body").and_then(Value::as_str)
9390                == Some(item.raw_body.as_deref().unwrap_or_default())
9391            {
9392                members.remove("body");
9393            }
9394            let terminal = matches!(status_target, Some(StatusTarget::Terminal(_, _)));
9395            if members.contains_key("stateInput") && terminal == item.closed {
9396                members.remove("stateInput");
9397            }
9398            if members.is_empty() {
9399                return Ok(());
9400            }
9401        }
9402        self.update_content(item.content_kind, &item.id, fields)
9403            .await
9404    }
9405
9406    /// Update one board item's content with exactly `fields` beside its id, through the
9407    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
9408    /// a draft.
9409    ///
9410    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
9411    /// is what lets a narrow write carry the one thing it changes and nothing else.
9412    async fn update_content(
9413        &self,
9414        kind: ContentKind,
9415        id: &NativeId,
9416        fields: Value,
9417    ) -> Result<(), SourceError> {
9418        let (operation, id_key, pointer) = match kind {
9419            ContentKind::DraftIssue => (
9420                graphql::UPDATE_DRAFT,
9421                "draftIssueId",
9422                "/updateProjectV2DraftIssue/draftIssue",
9423            ),
9424            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
9425        };
9426        let mut input = fields;
9427        input[id_key] = json!(id.0);
9428        let data = self.graphql(operation, json!({"input":input})).await?;
9429        let returned = data
9430            .pointer(pointer)
9431            .ok_or_else(|| SourceError::Malformed {
9432                message: "GitHub item update returned no item".into(),
9433            })?;
9434        if required_str(returned, "id")? != id.0 {
9435            return Err(SourceError::Malformed {
9436                message: "GitHub item update returned the wrong item".into(),
9437            });
9438        }
9439        Ok(())
9440    }
9441
9442    /// Creates one issue, files it on the board, and reports what a read of it would say:
9443    /// its content id, its board item id, and the web address GitHub gave it.
9444    ///
9445    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
9446    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
9447    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
9448    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
9449    /// "Content already exists in this project". A terminal status is not written here:
9450    /// `finish_write` selects its option first and closes the issue after, so a close never
9451    /// lands on an item whose board cannot show it.
9452    ///
9453    /// The address and the number come back here because this is the only place either is
9454    /// known before GitHub's own board read catches up — an item this run created answers
9455    /// the reads that follow it out of the record below, and one remembered without them
9456    /// would report no location and no key for the rest of the run.
9457    async fn create_and_file_issue(
9458        &self,
9459        board_id: &str,
9460        repository: &RepositoryTarget,
9461        incoming: &Incoming<'_>,
9462        body: &Option<String>,
9463    ) -> Result<Landed, SourceError> {
9464        let repository_id = self.repository_id(repository, incoming).await?;
9465        let data = self
9466            .graphql(
9467                graphql::CREATE_ISSUE,
9468                json!({"input":{
9469                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
9470                }}),
9471            )
9472            .await?;
9473        let created = data
9474            .pointer("/createIssue/issue")
9475            .filter(|value| !value.is_null())
9476            .ok_or_else(|| SourceError::Malformed {
9477                message: "GitHub issue creation returned no issue".into(),
9478            })?;
9479        let content_id = NativeId(required_str(created, "id")?.to_owned());
9480        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
9481        // a response without it is not worth failing a landed write over — the item simply
9482        // reports no location until the board read catches up, which is what it did before.
9483        let url = optional_str(created, "url")?.map(str::to_owned);
9484        // The issue exists from here on, so an unreadable number and a refused board
9485        // filing below each try, best effort, to take it back: an issue in the repository
9486        // that is on no board is an item nobody asked for and nothing here would find again.
9487        //
9488        // Its number is optional on the same terms its address is — a landed write is not
9489        // worth failing over a member that came back missing, and such an item reports no
9490        // handle until a board read catches up. A number that is *present* and is not an
9491        // unsigned integer is still a response this source cannot read.
9492        let number = match created_issue_number(created) {
9493            Ok(number) => number,
9494            Err(error) => {
9495                let _ = self.delete_issue(&content_id).await;
9496                return Err(error);
9497            }
9498        };
9499        let added = match self
9500            .graphql(
9501                graphql::ADD_TO_BOARD,
9502                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
9503            )
9504            .await
9505        {
9506            Ok(added) => added,
9507            Err(error) => {
9508                let _ = self.delete_issue(&content_id).await;
9509                return Err(error);
9510            }
9511        };
9512        let item = added
9513            .pointer("/addProjectV2ItemById/item")
9514            .filter(|value| !value.is_null())
9515            .ok_or_else(|| SourceError::Malformed {
9516                message: "GitHub board addition returned no project item".into(),
9517            })?;
9518        Ok(Landed {
9519            content_id,
9520            item_id: required_str(item, "id")?.to_owned(),
9521            url,
9522            number,
9523        })
9524    }
9525
9526    /// Move one issue under the project it now belongs to, or out of the one it left.
9527    async fn reparent(
9528        &self,
9529        held: Option<NativeId>,
9530        child: &NativeId,
9531        wanted: Option<&NativeId>,
9532    ) -> Result<(), SourceError> {
9533        if held.as_ref() == wanted {
9534            return Ok(());
9535        }
9536        if let Some(held) = &held {
9537            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
9538                .await?;
9539        }
9540        if let Some(wanted) = wanted {
9541            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
9542                .await?;
9543        }
9544        Ok(())
9545    }
9546
9547    async fn sub_issue(
9548        &self,
9549        operation: &str,
9550        parent: &NativeId,
9551        child: &NativeId,
9552        root: &str,
9553    ) -> Result<(), SourceError> {
9554        let data = self
9555            .graphql(
9556                operation,
9557                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
9558            )
9559            .await?;
9560        let issue =
9561            data.pointer(&format!("/{root}/issue"))
9562                .ok_or_else(|| SourceError::Malformed {
9563                    message: "GitHub sub-issue update returned no issue".into(),
9564                })?;
9565        let sub =
9566            data.pointer(&format!("/{root}/subIssue"))
9567                .ok_or_else(|| SourceError::Malformed {
9568                    message: "GitHub sub-issue update returned no sub-issue".into(),
9569                })?;
9570        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
9571            return Err(SourceError::Malformed {
9572                message: "GitHub sub-issue update returned the wrong issues".into(),
9573            });
9574        }
9575        Ok(())
9576    }
9577
9578    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
9579    /// whether there was one.
9580    ///
9581    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
9582    /// relationships are not read: there is nothing a read of them could find.
9583    async fn reconcile_blocked_by(
9584        &self,
9585        content_id: &NativeId,
9586        native: &[String],
9587        issue: Issue<'_>,
9588    ) -> Result<bool, SourceError> {
9589        let current = match issue {
9590            Issue::Created => Vec::new(),
9591            Issue::Existing(Some(held)) => held
9592                .iter()
9593                .map(|far| required_str(far, "id").map(str::to_owned))
9594                .collect::<Result<Vec<_>, _>>()?,
9595            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
9596        };
9597        let mut changed = false;
9598        for (operation, far_id) in current
9599            .iter()
9600            .filter(|id| !native.contains(id))
9601            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
9602            .chain(
9603                native
9604                    .iter()
9605                    .filter(|id| !current.contains(id))
9606                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
9607            )
9608        {
9609            let data = self
9610                .graphql(
9611                    operation,
9612                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
9613                )
9614                .await?;
9615            let root = if operation == graphql::ADD_BLOCKED_BY {
9616                "addBlockedBy"
9617            } else {
9618                "removeBlockedBy"
9619            };
9620            let issue =
9621                data.pointer(&format!("/{root}/issue"))
9622                    .ok_or_else(|| SourceError::Malformed {
9623                        message: "GitHub dependency update returned no issue".into(),
9624                    })?;
9625            let blocker = data
9626                .pointer(&format!("/{root}/blockingIssue"))
9627                .ok_or_else(|| SourceError::Malformed {
9628                    message: "GitHub dependency update returned no blocking issue".into(),
9629                })?;
9630            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
9631            {
9632                return Err(SourceError::Malformed {
9633                    message: "GitHub dependency update returned the wrong issues".into(),
9634                });
9635            }
9636            changed = true;
9637        }
9638        Ok(changed)
9639    }
9640}
9641
9642/// What a write needs to know of one far end it names: which kind of item it is, and whether
9643/// it is an issue a native relationship can name.
9644struct FarEnd {
9645    kind: BoardKind,
9646    content_kind: ContentKind,
9647}
9648
9649/// Whether the issue one write reconciles was created by that write or was already there.
9650#[derive(Clone, Copy, PartialEq, Eq)]
9651enum Issue<'a> {
9652    /// Created by this write, so it holds no relationships yet.
9653    Created,
9654    /// On the board before this write, holding whatever relationships it holds — the far
9655    /// ends of its whole `blockedBy`, when the read that reached it carried them.
9656    Existing(Option<&'a [Value]>),
9657}
9658
9659/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
9660enum Reached {
9661    /// An issue this board holds, resolved into everything this source reports about it.
9662    Held(Box<Resolved>),
9663    /// Nothing this board holds: no such node, or a node on some other board.
9664    Nothing,
9665    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
9666    /// again by [`GitHubProjectsSource::draft_by_id`].
9667    Draft,
9668}
9669
9670/// What GitHub says when a string is not a node id it can resolve.
9671///
9672/// Matched because it is the ordinary answer to a project selector naming a project by its
9673/// *name*, and reporting that as a failure would make naming one impossible. It is read
9674/// off the refusal GitHub sent, never guessed from the shape of the string: this source
9675/// does not define the syntax of a GitHub node id and would be wrong about it.
9676const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
9677
9678/// `error` with `note` added to the end of what it says, its kind and every other member
9679/// unchanged — so a caller still branches on the failure that happened, and reads beside it
9680/// what that failure left behind.
9681fn noting(error: SourceError, note: &str) -> SourceError {
9682    match error {
9683        SourceError::Config { message } => SourceError::Config {
9684            message: message + note,
9685        },
9686        SourceError::Auth { message } => SourceError::Auth {
9687            message: message + note,
9688        },
9689        SourceError::Refused { message } => SourceError::Refused {
9690            message: message + note,
9691        },
9692        SourceError::RateLimited {
9693            retry_after_seconds,
9694            message,
9695        } => SourceError::RateLimited {
9696            retry_after_seconds,
9697            message: Some(message.unwrap_or_default() + note),
9698        },
9699        SourceError::Unavailable { message } => SourceError::Unavailable {
9700            message: message + note,
9701        },
9702        SourceError::Malformed { message } => SourceError::Malformed {
9703            message: message + note,
9704        },
9705    }
9706}
9707
9708/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
9709/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
9710fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest) -> Value {
9711    let mut variables = serde_json::Map::new();
9712    for slot in 0..COPY_DETAIL_BATCH {
9713        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9714        variables.insert(format!("id{slot}"), json!(id));
9715    }
9716    variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
9717    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9718    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9719    variables.insert("duplicates".to_owned(), json!(true));
9720    Value::Object(variables)
9721}
9722
9723/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
9724/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
9725/// for them.
9726///
9727/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
9728/// is read again at no added price.
9729fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
9730    let mut variables = serde_json::Map::new();
9731    for slot in 0..DETAIL_BATCH {
9732        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9733        variables.insert(format!("id{slot}"), json!(id));
9734    }
9735    variables.insert(
9736        "first".to_owned(),
9737        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
9738    );
9739    variables.insert("comments".to_owned(), json!(comments.is_some()));
9740    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9741    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9742    variables.insert("duplicates".to_owned(), json!(true));
9743    Value::Object(variables)
9744}
9745
9746/// Whether this refusal is GitHub saying the id names no node at all.
9747fn unresolvable_node(error: &SourceError) -> bool {
9748    matches!(error, SourceError::Refused { message }
9749        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
9750}
9751
9752/// One project name, as a search qualifier which filters on it at the server.
9753///
9754/// Quoted so the whole title is one phrase rather than a bag of words, with the two
9755/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
9756/// the way it documents. A title matched here is still compared for equality afterwards:
9757/// the qualifier narrows what the server sends, and this source decides what it names.
9758fn title_qualifier(name: &str) -> String {
9759    format!("in:title {}", quoted(name))
9760}
9761
9762/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
9763/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
9764/// it documents — so a value holding a qualifier's spelling is searched for rather than
9765/// obeyed.
9766fn quoted(value: &str) -> String {
9767    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
9768    format!("\"{escaped}\"")
9769}
9770
9771/// The search qualifier for the issues updated at or after `since`.
9772///
9773/// Written to the second, rounded down, which can only widen what the search returns.
9774fn updated_qualifier(since: DateTime<Utc>) -> String {
9775    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
9776}
9777
9778/// The search terms that narrow a board-scoped issue search to a task query's text and
9779/// metadata predicates, or `None` when it carries neither.
9780///
9781/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
9782/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
9783/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
9784/// matches each in any field the `in:` qualifier names, so a query naming a title search and
9785/// a metadata value searches both fields for both — wider than asked, never narrower, and
9786/// every candidate is confirmed in process afterwards.
9787///
9788/// **This narrows a text search, and that is this source's declared semantics.** GitHub
9789/// matches whole tokens where a substring rule would match inside a word, so an item holding
9790/// the text only inside a longer word is not returned. A text of nothing but whitespace
9791/// matches every item, so it narrows nothing and is not sent.
9792fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
9793    let text = query
9794        .text
9795        .as_ref()
9796        .filter(|text| !text.terms.trim().is_empty());
9797    if text.is_none() && query.metadata.is_empty() {
9798        return None;
9799    }
9800    let (title, body) = match text.map(|text| text.fields) {
9801        None => (false, true),
9802        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
9803        Some(TextFields::Content) => (false, true),
9804        Some(TextFields::TitleOrContent) => (true, true),
9805    };
9806    let fields = match (title, body) {
9807        (true, true) => "in:title,body",
9808        (true, false) => "in:title",
9809        _ => "in:body",
9810    };
9811    let phrases = text
9812        .map(|text| text.terms.clone())
9813        .into_iter()
9814        .chain(
9815            query
9816                .metadata
9817                .iter()
9818                .map(|wanted| as_stored(wanted.value())),
9819        )
9820        .map(|phrase| quoted(&phrase))
9821        .collect::<Vec<_>>();
9822    Some(format!("{fields} {}", phrases.join(" ")))
9823}
9824
9825/// The search terms that narrow a board-scoped issue search to a project or document query's
9826/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
9827/// carrying that text alone is sent as by [`narrowing_qualifiers`].
9828fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
9829    narrowing_qualifiers(&TaskQuery {
9830        text: text.cloned(),
9831        ..TaskQuery::default()
9832    })
9833}
9834
9835/// Refuses a project or document query's text GitHub's issue search cannot find, before
9836/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
9837/// query's.
9838fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
9839    refuse_unsearchable(&TaskQuery {
9840        text: text.cloned(),
9841        ..TaskQuery::default()
9842    })
9843}
9844
9845/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
9846/// before anything is asked of GitHub.
9847///
9848/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
9849/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
9850/// left out, the search is every issue of the board. So this source says it cannot answer
9851/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
9852/// nothing GitHub could search for, and keeps the board read it always had.
9853fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
9854    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
9855                       letter or digit with a bounded query";
9856    if let Some(text) = &query.text
9857        && !text.terms.trim().is_empty()
9858        && !has_words(&text.terms)
9859    {
9860        return Err(SourceError::Refused {
9861            message: format!(
9862                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
9863                text.terms
9864            ),
9865        });
9866    }
9867    if let Some(wanted) = query
9868        .metadata
9869        .iter()
9870        .find(|wanted| !has_words(wanted.value()))
9871    {
9872        return Err(SourceError::Refused {
9873            message: format!(
9874                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
9875                wanted.value(),
9876                std::iter::once(wanted.key())
9877                    .chain(wanted.path().iter().map(String::as_str))
9878                    .collect::<Vec<_>>()
9879                    .join("/"),
9880            ),
9881        });
9882    }
9883    Ok(())
9884}
9885
9886/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
9887fn has_words(phrase: &str) -> bool {
9888    phrase.chars().any(char::is_alphanumeric)
9889}
9890
9891/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
9892///
9893/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
9894/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
9895/// which GitHub's word match would read as different words.
9896fn as_stored(value: &str) -> String {
9897    let encoded = Value::String(value.to_owned()).to_string();
9898    encoded[1..encoded.len() - 1].to_owned()
9899}
9900
9901/// The one narrower question a task query carrying a text, metadata or origin predicate is
9902/// sent as.
9903enum Narrowing {
9904    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
9905    Origin(String),
9906    /// The board-scoped issue search narrowed by these qualifiers.
9907    Search(String),
9908}
9909
9910impl Narrowing {
9911    /// What this question is remembered under for the length of one command.
9912    fn key(&self) -> String {
9913        match self {
9914            Self::Origin(origin) => format!("origin {origin}"),
9915            Self::Search(also) => format!("search {also}"),
9916        }
9917    }
9918}
9919
9920/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
9921enum Resumed {
9922    /// It reported another page, which starts after this cursor.
9923    More(String),
9924    /// It has ended. Sending this cursor again — the page's own end when it had one, and
9925    /// otherwise the cursor it was reached from — answers an empty page, so the one document
9926    /// can go on walking the other connection.
9927    Ended(Option<String>),
9928}
9929
9930impl Resumed {
9931    /// Whether the connection has another page.
9932    const fn has_more(&self) -> bool {
9933        matches!(self, Self::More(_))
9934    }
9935
9936    /// The cursor to send this connection next.
9937    fn cursor(self) -> Option<String> {
9938        match self {
9939            Self::More(next) => Some(next),
9940            Self::Ended(last) => last,
9941        }
9942    }
9943}
9944
9945/// Where `connection`, reached from `after`, resumes — refused when it reports another page
9946/// with no cursor to it, or from a cursor that does not advance.
9947fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
9948    let info = connection
9949        .get("pageInfo")
9950        .ok_or_else(|| SourceError::Malformed {
9951            message: "GitHub connection has no pageInfo".into(),
9952        })?;
9953    let end = optional_str(info, "endCursor")?;
9954    if required_bool(info, "hasNextPage")? {
9955        let next = end.ok_or_else(|| SourceError::Malformed {
9956            message: "GitHub connection reports another page and no endCursor".into(),
9957        })?;
9958        validate_cursor_progress(after, next)?;
9959        return Ok(Resumed::More(next.to_owned()));
9960    }
9961    Ok(Resumed::Ended(
9962        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
9963    ))
9964}
9965
9966/// The board, and every item on it this source reports.
9967#[derive(Clone)]
9968struct Board {
9969    id: String,
9970    fields: Value,
9971    items: Vec<Resolved>,
9972}
9973
9974/// What a write needs of the board and nothing more: its node id and its field
9975/// definitions, in the shape a read of the board's own `fields` gives them.
9976///
9977/// Deliberately no items. A write decides which item it writes, which parent it files
9978/// under and which far ends it names by reading each of them by its own id; this is the
9979/// half of the board those reads cannot carry, and holding no item is what keeps it from
9980/// ever being asked whether an item is there.
9981#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9982struct BoardFields {
9983    id: BoardId,
9984    fields: Value,
9985}
9986
9987/// A board's node id: what a field write and `addProjectV2ItemById` address.
9988///
9989/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
9990/// refused where it is read, and one an item names blank is read as not named at all.
9991#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9992#[serde(try_from = "String")]
9993struct BoardId(String);
9994
9995impl TryFrom<String> for BoardId {
9996    type Error = String;
9997
9998    /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
9999    fn try_from(id: String) -> Result<Self, Self::Error> {
10000        Self::parse(&id).map_err(|error| error.to_string())
10001    }
10002}
10003
10004/// Where one write left its item, for the record the rest of the command reads it out of.
10005///
10006/// A named record rather than a tuple because the update arm and the create arm each fill
10007/// all four, and two `Option`s of different meaning side by side in a tuple are two
10008/// positions a reader has to count.
10009struct Landed {
10010    /// The issue's own node id, which is the [`NativeId`] this source reports.
10011    content_id: NativeId,
10012    /// The board item's id, which is what a field write addresses.
10013    // 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.
10014    item_id: String,
10015    /// The web address GitHub gave the issue, when it gave one.
10016    // 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.
10017    url: Option<String>,
10018    /// The issue's number on its repository, when GitHub reported one.
10019    number: Option<u64>,
10020}
10021
10022impl BoardId {
10023    fn parse(id: &str) -> Result<Self, SourceError> {
10024        if id.trim().is_empty() {
10025            return Err(SourceError::Malformed {
10026                message: "GitHub named a board with a blank node id".into(),
10027            });
10028        }
10029        Ok(Self(id.to_owned()))
10030    }
10031
10032    fn as_str(&self) -> &str {
10033        &self.0
10034    }
10035}
10036
10037impl Board {
10038    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
10039        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
10040        let nodes = fields
10041            .get("nodes")
10042            .and_then(Value::as_array)
10043            .ok_or_else(|| SourceError::Malformed {
10044                message: "GitHub project fields.nodes is not an array".into(),
10045            })?;
10046        Ok(nodes
10047            .iter()
10048            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
10049    }
10050}
10051
10052/// One board item, resolved into everything this source reports about it.
10053///
10054/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
10055/// [`TaskSource::prime_write`] takes back — see [`expected`].
10056#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
10057struct Resolved {
10058    item_id: String,
10059    id: NativeId,
10060    content_kind: ContentKind,
10061    kind: BoardKind,
10062    title: String,
10063    body: Option<String>,
10064    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
10065    /// that changes the slot alone has to keep byte for byte outside it.
10066    raw_body: Option<String>,
10067    status: Status,
10068    /// The name of the board `Status` option this item sits in, as the board spells it.
10069    option: Option<String>,
10070    /// What its `Priority` field says, read through this instance's mapping.
10071    priority: HeldPriority,
10072    /// Whether this item's issue is closed. A draft has no such state and is never closed.
10073    closed: bool,
10074    /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
10075    /// issue, a draft, and an issue closed before GitHub recorded reasons.
10076    #[serde(default)]
10077    reason: Option<String>,
10078    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
10079    delivers: Vec<TaskRef>,
10080    /// Every task that delivers this one, read out of its slot. Empty for anything not a
10081    /// task.
10082    delivered_by: Vec<TaskRef>,
10083    labels: Vec<Label>,
10084    parent: Option<NativeId>,
10085    // 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.
10086    origin: Option<String>,
10087    /// What each board text field this instance projects metadata onto holds for this item,
10088    /// by field name — only the fields holding a value, an empty text included. What a write compares the
10089    /// item's metadata against, so a field already holding its value is not written again;
10090    /// never read back as metadata, which the slot alone reports.
10091    projected: BTreeMap<String, String>,
10092    /// The issue's own number on its repository, as GitHub reports it.
10093    ///
10094    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
10095    /// declares none, and a draft is not filed in a repository to be numbered by one — and
10096    /// an issue this run created whose creating mutation answered without one, which is a
10097    /// response GitHub's own schema says cannot happen and which a landed write is not
10098    /// worth failing over. An `Issue` read off the board always has one.
10099    number: Option<u64>,
10100    url: Option<String>,
10101    created_at: Option<DateTime<Utc>>,
10102    updated_at: Option<DateTime<Utc>>,
10103    /// When the board item itself last changed — what a board field write moves — when the
10104    /// read that reached it said.
10105    item_updated_at: Option<DateTime<Utc>>,
10106    /// How many comments the issue holds, when the read that reached it said.
10107    comment_count: Option<u64>,
10108    /// How many relationship events the issue's timeline holds — every dependency, as
10109    /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
10110    /// removed — when the read that reached it said.
10111    relationship_events: Option<u64>,
10112    /// The server's clock on the response that read it — its `Date` header — when the
10113    /// response carried one.
10114    served_at: Option<DateTime<Utc>>,
10115    /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
10116    /// its labels — when the read that reached it carried all three whole.
10117    #[serde(default)]
10118    facets: Option<change_token::FacetsDigest>,
10119    own_repository: Option<Repository>,
10120    repositories: Vec<Repository>,
10121    classification: Classification,
10122    slot: BTreeMap<String, Value>,
10123    /// The node id of the board this item sits on, when the read that reached it said.
10124    board_id: Option<String>,
10125    /// The definition of every board field this item holds a value of, in the shape a read
10126    /// of the board's own `fields` gives one.
10127    ///
10128    /// Only the fields this item has a value in: a field it holds nothing of is not here,
10129    /// which says nothing about whether the board has it.
10130    fields: Vec<Value>,
10131    /// Every field the board this item sits on defines, as its own read of the board's
10132    /// `fields` gives them — when the read that reached the item carried them, which a read
10133    /// of it by its own id does. What a write of it needs of the board, then, needs no read
10134    /// of the board.
10135    board_fields: Option<Value>,
10136    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
10137    /// selects one — when the read that reached it carried the connection to its end, which a
10138    /// read of it by its own id does for any issue blocked by no more than a page. What a
10139    /// write reconciles that relationship against, and what a read of its forward edges in
10140    /// the same command answers with.
10141    blocked_by: Option<Vec<Value>>,
10142}
10143
10144impl Resolved {
10145    /// The board this item's own read names it on, when that read named one this source can
10146    /// address.
10147    fn named_board(&self) -> Option<BoardId> {
10148        self.board_id
10149            .as_deref()
10150            .and_then(|id| BoardId::parse(id).ok())
10151    }
10152
10153    /// The board's id and every field it defines, when the read that reached this item
10154    /// carried both — which a read of it by its own id does.
10155    fn carried_board(&self) -> Option<BoardFields> {
10156        Some(BoardFields {
10157            id: self.named_board()?,
10158            fields: self.board_fields.clone()?,
10159        })
10160    }
10161
10162    /// Whether this item holds a value of the board field called `name`, and so carries
10163    /// that field's definition. `false` says nothing about whether the board has the field.
10164    fn defines(&self, name: &str) -> bool {
10165        self.fields
10166            .iter()
10167            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
10168    }
10169
10170    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
10171    /// in a field of its own, and none of the five keys that are only an encoding.
10172    ///
10173    /// The two delivery keys are left out for every kind, not only for a task: they are
10174    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
10175    /// document carrying one holds nothing a caller's own metadata could mean by it.
10176    fn metadata(&self) -> BTreeMap<String, Value> {
10177        let mut metadata = self.slot.clone();
10178        metadata.remove(Repository::METADATA_KEY);
10179        metadata.remove(DependencyEdge::RECORDED_KEY);
10180        metadata.remove(ItemKind::METADATA_KEY);
10181        metadata.remove(TaskRef::DELIVERS_KEY);
10182        metadata.remove(TaskRef::DELIVERED_BY_KEY);
10183        metadata.remove(Classification::METADATA_KEY);
10184        // The board field is the origin, and the body's copy of it is only a mirror for the
10185        // issue search to find: an item whose field holds none has none, whatever its body
10186        // says, so no reader ever sees two answers.
10187        metadata.remove(ORIGIN_KEY);
10188        if let Some(origin) = &self.origin {
10189            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
10190        }
10191        metadata
10192    }
10193
10194    /// Where this item is, as a link a reader can open.
10195    ///
10196    /// A board is a hosted place and every issue on it has a web address, so that address
10197    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
10198    /// of place it is, so a reader knows to open it rather than to read a file out. It
10199    /// does not replace or derive from `url`: the field goes on reporting exactly what it
10200    /// reported before, and this says what that address *is*.
10201    ///
10202    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
10203    /// rather than a third variant, which is the contract's "the source did not say". An
10204    /// issue this run created is not one of those: its address comes back from the
10205    /// creating mutation, so it is somewhere a reader can open from the moment it exists
10206    /// rather than from whenever the board read catches up.
10207    fn location(&self) -> Option<Location> {
10208        self.url.clone().map(Location::Url)
10209    }
10210
10211    /// The short handle this board's backend shows people for a task: the issue's number
10212    /// alone, as a decimal string.
10213    ///
10214    /// The number alone rather than `owner/repo#1043`, because that is the contract's
10215    /// value for this backend. A draft has no number and so no handle, which is the
10216    /// contract's *absent* rather than a handle of some other shape — and the native
10217    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
10218    /// derives from.
10219    fn key(&self) -> Option<String> {
10220        self.number.map(|number| number.to_string())
10221    }
10222
10223    /// This item's change token — see [`change_token`] — or `None` when the read that
10224    /// reached it did not carry all six of its parts, which a draft never does, did not say
10225    /// when GitHub served it, or found it changed too recently to have settled.
10226    fn version(&self) -> Option<String> {
10227        match self.content_kind {
10228            ContentKind::Issue => {
10229                let (issue, item) = (self.updated_at?, self.item_updated_at?);
10230                let parts = change_token::Parts {
10231                    issue,
10232                    item,
10233                    comments: self.comment_count?,
10234                    relationships: self.relationship_events?,
10235                    facets: self.facets.clone()?,
10236                    repository: self.own_repository.clone()?,
10237                };
10238                change_token::settled(issue, item, self.served_at?)
10239                    .then(|| change_token::spell(&parts))
10240            }
10241            ContentKind::DraftIssue => None,
10242        }
10243    }
10244
10245    /// Whether its `Priority` field holds a value at all, mapped or not.
10246    fn holds_priority(&self) -> bool {
10247        self.priority != HeldPriority::Read(Priority::None)
10248    }
10249
10250    /// The task this item is.
10251    ///
10252    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
10253    /// reading that as a level would be a guess, and reading it as `none` would let the next
10254    /// copy clear a priority a person set.
10255    fn task(&self) -> Result<Task, SourceError> {
10256        let priority = match &self.priority {
10257            HeldPriority::Read(priority) => *priority,
10258            HeldPriority::Unmapped(option) => {
10259                return Err(SourceError::Malformed {
10260                    message: format!(
10261                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
10262                         this source's priority_mapping does not name, so its priority cannot be \
10263                         read; next: name {option:?} under priority_mapping, or move the item to \
10264                         a mapped option",
10265                        self.id,
10266                        self.number
10267                            .map(|number| format!(" (#{number})"))
10268                            .unwrap_or_default()
10269                    ),
10270                });
10271            }
10272        };
10273        Ok(Task {
10274            version: self.version(),
10275            id: self.id.clone(),
10276            key: self.key(),
10277            title: self.title.clone(),
10278            content: self.body.clone(),
10279            status: self.status.clone(),
10280            priority,
10281            labels: self.labels.clone(),
10282            project: self.parent.clone(),
10283            url: self.url.clone(),
10284            location: self.location(),
10285            created_at: self.created_at,
10286            updated_at: self.updated_at,
10287            metadata: self.metadata(),
10288            repositories: self.repositories.clone(),
10289            delivers: self.delivers.clone(),
10290            delivered_by: self.delivered_by.clone(),
10291            classification: self.classification,
10292        })
10293    }
10294
10295    fn project(&self) -> Project {
10296        Project {
10297            version: self.version(),
10298            id: self.id.clone(),
10299            title: self.title.clone(),
10300            content: self.body.clone(),
10301            status: self.status.clone(),
10302            labels: self.labels.clone(),
10303            url: self.url.clone(),
10304            location: self.location(),
10305            created_at: self.created_at,
10306            updated_at: self.updated_at,
10307            metadata: self.metadata(),
10308            repositories: self.repositories.clone(),
10309            classification: self.classification,
10310        }
10311    }
10312
10313    /// The same issue as a document: the project it is filed under, and no status and no
10314    /// dependencies, because a document is not work.
10315    fn document(&self) -> Document {
10316        Document {
10317            version: self.version(),
10318            id: self.id.clone(),
10319            title: self.title.clone(),
10320            content: self.body.clone(),
10321            project: self.parent.clone(),
10322            labels: self.labels.clone(),
10323            url: self.url.clone(),
10324            location: self.location(),
10325            created_at: self.created_at,
10326            updated_at: self.updated_at,
10327            metadata: self.metadata(),
10328            repositories: self.repositories.clone(),
10329            classification: self.classification,
10330        }
10331    }
10332}
10333
10334/// Where one targeted update moves an item's status, and which of its two halves move.
10335struct StatusMove {
10336    /// The board the item's `Status` field is on.
10337    board: BoardId,
10338    /// The `Status` field's id.
10339    field: String,
10340    /// The option's id.
10341    option: String,
10342    /// The option's name, as the board spells it.
10343    name: String,
10344    /// What the status asks of the issue's state.
10345    target: StatusTarget,
10346    /// The status the item reads as once it is there.
10347    landed: Status,
10348    /// Which of the status's two halves differ from what the item holds.
10349    moves: Moves,
10350}
10351
10352/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
10353/// closed state of its issue, or both. A status neither half of which differs is no move at all,
10354/// and is not a value of this type.
10355#[derive(Clone, Copy, PartialEq, Eq)]
10356enum Moves {
10357    /// The option alone.
10358    Option,
10359    /// The issue's state alone: open, closed, or closed with another reason.
10360    State,
10361    /// Both.
10362    Both,
10363}
10364
10365impl Moves {
10366    /// What differs, or `None` when nothing does.
10367    const fn of(option: bool, state: bool) -> Option<Self> {
10368        match (option, state) {
10369            (true, true) => Some(Self::Both),
10370            (true, false) => Some(Self::Option),
10371            (false, true) => Some(Self::State),
10372            (false, false) => None,
10373        }
10374    }
10375
10376    /// Whether the option moves.
10377    const fn option(self) -> bool {
10378        matches!(self, Self::Option | Self::Both)
10379    }
10380
10381    /// Whether the issue's state moves.
10382    const fn state(self) -> bool {
10383        matches!(self, Self::State | Self::Both)
10384    }
10385}
10386
10387/// What one write is, and the status that comes with being it.
10388///
10389/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
10390/// status and a task or a project always has one, so "a document carrying a status" and
10391/// "a task carrying none" are states a write cannot be in rather than states every use
10392/// site below has to defend against.
10393enum Written<'a> {
10394    /// A document, which is not work and so has no status at all.
10395    Document,
10396    /// A task or a project, and the status it is being written with.
10397    Work(ItemKind, &'a Status),
10398}
10399
10400impl Written<'_> {
10401    /// Which of the board's three kinds this write is.
10402    const fn kind(&self) -> BoardKind {
10403        match self {
10404            Self::Document => BoardKind::Document,
10405            Self::Work(kind, _) => BoardKind::Work(*kind),
10406        }
10407    }
10408
10409    /// The status this write carries. A document carries none, so a write of one says
10410    /// nothing about the issue's open or closed state and selects no board `Status`
10411    /// option.
10412    const fn status(&self) -> Option<&Status> {
10413        match self {
10414            Self::Document => None,
10415            Self::Work(_, status) => Some(status),
10416        }
10417    }
10418
10419    /// The status this write carries with the kind whose half of `status_mapping` it is
10420    /// written through.
10421    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
10422        match self {
10423            Self::Document => None,
10424            Self::Work(kind, status) => Some((*kind, status)),
10425        }
10426    }
10427}
10428
10429/// The item being written, in the one shape all three write methods reach.
10430struct Incoming<'a> {
10431    written: Written<'a>,
10432    /// The title a person wrote. A document's goes onto the issue with
10433    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
10434    title: &'a str,
10435    content: Option<&'a str>,
10436    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
10437    labels: &'a [Label],
10438    metadata: &'a BTreeMap<String, Value>,
10439    repositories: &'a [Repository],
10440    /// Recorded in the slot while private, and nowhere while public.
10441    classification: Classification,
10442    parent: Option<&'a NativeId>,
10443    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
10444    /// what keeps either key out of their slot.
10445    delivers: &'a [TaskRef],
10446    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
10447    delivered_by: &'a [TaskRef],
10448    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
10449    /// project, a document, and every write to an instance with no `priority_mapping` —
10450    /// which is what keeps such a write's requests exactly what they were before.
10451    priority: Option<Priority>,
10452}
10453
10454/// What one write does to one projected metadata text field, by the field's id.
10455#[derive(Debug, Clone)]
10456enum ProjectionWrite {
10457    /// Write this text.
10458    Set {
10459        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.
10460        text: String,
10461    },
10462    /// Clear the field's value.
10463    Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
10464}
10465
10466impl ProjectionWrite {
10467    /// Add this write to a field write's list of values and list of clears.
10468    fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
10469        match self {
10470            Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
10471            Self::Clear { field } => clears.push(field),
10472        }
10473    }
10474}
10475
10476/// What one write does to an item's `Priority` field.
10477enum PriorityWrite {
10478    /// Select this option of this field.
10479    Select {
10480        /// The `Priority` field's id.
10481        field: String,
10482        /// The mapped option's id.
10483        option: String,
10484    },
10485    /// Clear the field's value, which is what `none` is.
10486    Clear {
10487        /// The `Priority` field's id.
10488        field: String,
10489    },
10490}
10491
10492impl Incoming<'_> {
10493    /// The title this write puts on the issue.
10494    fn written_title(&self) -> String {
10495        match self.written {
10496            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
10497            Written::Work(..) => self.title.to_owned(),
10498        }
10499    }
10500}
10501
10502#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10503enum ContentKind {
10504    DraftIssue,
10505    Issue,
10506}
10507
10508/// What one board issue is: a document, or the work an [`ItemKind`] names.
10509///
10510/// A type of this source's own rather than an `ItemKind` with a third variant, because
10511/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
10512/// document — the contract keeps a document out of that enum deliberately. Holding the
10513/// board's three answers in one value is what makes every place that asks "which is this?"
10514/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
10515/// two thirds of the board.
10516#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10517enum BoardKind {
10518    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
10519    Document,
10520    /// Every other issue, and every draft.
10521    Work(ItemKind),
10522}
10523
10524impl BoardKind {
10525    /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
10526    /// sub-issues and carrying the kind marker `marked` in its slot.
10527    ///
10528    /// The design prefix is read *first*, before either of the two rules that separate a
10529    /// project from a task. A document is not work whatever sub-issues it has and whatever
10530    /// marker it carries, and reading the prefix later would make a design issue with none of
10531    /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
10532    /// overrides it: an issue filed under a project is that project's task even when it has
10533    /// sub-issues of its own.
10534    fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
10535        if title.starts_with(DESIGN_TITLE_PREFIX) {
10536            Self::Document
10537        } else if filed {
10538            Self::Work(ItemKind::Task)
10539        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
10540            Self::Work(ItemKind::Project)
10541        } else {
10542            Self::Work(ItemKind::Task)
10543        }
10544    }
10545
10546    /// What the change token's facets call this kind.
10547    const fn facet(self) -> change_token::FacetKind {
10548        match self {
10549            Self::Document => change_token::FacetKind::Document,
10550            Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
10551            Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
10552        }
10553    }
10554
10555    /// Whose half of `status_mapping` an item of this kind reads its status through. A
10556    /// document has no status of its own, so the task half stands in for whatever the issue
10557    /// holds; nothing reports it.
10558    const fn status_kind(self) -> ItemKind {
10559        match self {
10560            Self::Document => ItemKind::Task,
10561            Self::Work(kind) => kind,
10562        }
10563    }
10564
10565    /// How a refusal names this kind to the person reading it.
10566    const fn describes(self) -> &'static str {
10567        match self {
10568            Self::Document => "document",
10569            Self::Work(kind) => kind.marker(),
10570        }
10571    }
10572}
10573
10574/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
10575///
10576/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
10577/// the shared cross-source journeys assert one answer to one question, so two sources
10578/// that disagree about what "carries the label bug" means fail them.
10579fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
10580    let holds = |name: &String| {
10581        labels
10582            .iter()
10583            .any(|label| label.name.eq_ignore_ascii_case(name))
10584    };
10585    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
10586        && filter.all_of.iter().all(holds)
10587        && !filter.none_of.iter().any(holds)
10588}
10589
10590/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
10591/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
10592fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
10593    statuses.is_empty() || statuses.contains(&category)
10594}
10595
10596/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
10597///
10598/// `content` is the item's own prose — the body with this source's trailing metadata
10599/// comment already taken off — so a search never matches an encoding the author of the
10600/// issue never wrote.
10601fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
10602    let terms = query.terms.to_lowercase();
10603    let in_title = title.to_lowercase().contains(&terms);
10604    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
10605    match query.fields {
10606        TextFields::Title => in_title,
10607        TextFields::Content => in_content,
10608        TextFields::TitleOrContent => in_title || in_content,
10609    }
10610}
10611
10612/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
10613///
10614/// The project predicate is passed separately because a read narrowed to one project has
10615/// already answered it by asking *that project* for its own items — and re-applying it
10616/// there would compare the caller's selector, which may be a project's **name**, against
10617/// the id of the project that name resolved to, and keep nothing. Every other read passes
10618/// `query.project` and applies it here, which is what keeps `projects` a predicate this
10619/// source really does apply.
10620fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
10621    labels_match(&task.labels, &query.labels)
10622        && status_matches(task.status.category, &query.statuses)
10623        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
10624        && match project {
10625            ProjectFilter::Any => true,
10626            ProjectFilter::Orphans => task.project.is_none(),
10627            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
10628        }
10629        && query
10630            .text
10631            .as_ref()
10632            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
10633        // Against the parsed metadata slot, and against the origin field, which is where
10634        // `Resolved::metadata` reads each of them from.
10635        && query.metadata_matches(&task.metadata)
10636        && query.origin_matches(&task.metadata)
10637}
10638
10639fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
10640    labels_match(&project.labels, &query.labels)
10641        && status_matches(project.status.category, &query.statuses)
10642        && query
10643            .text
10644            .as_ref()
10645            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
10646}
10647
10648/// The same three predicates a task query carries, minus the status filter.
10649///
10650/// A document is not work, so it has no status for one to compare against and the query
10651/// type carries none. The project predicate is the same one — a design issue filed under a
10652/// project issue is in that project, and one filed under nothing is in none — so it is
10653/// spelled the same way here rather than answered differently.
10654fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
10655    labels_match(&document.labels, &query.labels)
10656        && match project {
10657            ProjectFilter::Any => true,
10658            ProjectFilter::Orphans => document.project.is_none(),
10659            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
10660        }
10661        && query
10662            .text
10663            .as_ref()
10664            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
10665}
10666
10667/// Why a GitHub item's change token does not vouch for its forward edges.
10668///
10669/// A forward edge names its far end and that far end's kind, and the near item's token moves
10670/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
10671/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
10672/// nothing the near token reads, so an edge kept at the near version could name a far end that
10673/// is no longer what it said.
10674const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
10675     whether it became a document, or its deletion, so its forward edges are always read from \
10676     github";
10677
10678/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
10679/// categories an open issue reads as narrows a board-scoped search with.
10680const OPEN_QUALIFIER: &str = "is:open";
10681
10682/// The board filter `task status reconcile` reads the board through: closed items alone.
10683const CLOSED_FILTER: &str = "is:closed";
10684
10685/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
10686/// one reference, and a slot the batch has none for names its last one again.
10687fn reference_batch(batch: &[IssueRef]) -> Value {
10688    let mut variables = serde_json::Map::new();
10689    for slot in 0..REFERENCE_BATCH {
10690        let issue = batch.get(slot).or(batch.last());
10691        variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
10692        variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
10693        variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
10694    }
10695    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
10696    variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
10697    variables.insert("duplicates".to_owned(), json!(true));
10698    Value::Object(variables)
10699}
10700
10701/// One issue an issue read by reference answered, in the shape every other read of an issue
10702/// hands its resolver: each board membership's `Status` value, read by name, as the one field
10703/// value that membership carries.
10704///
10705/// A membership's `status` is `null` when its card sits at no option — or on a board with no
10706/// field of that name — and otherwise an option value naming its option; anything else is not
10707/// an answer to this read, and is refused rather than read as a card at no option.
10708fn held_issue(issue: &Value) -> Result<Value, SourceError> {
10709    let malformed = |what: &str| SourceError::Malformed {
10710        message: format!("GitHub answered an issue read by reference with {what}"),
10711    };
10712    let mut issue = issue.clone();
10713    let nodes = issue
10714        .pointer_mut("/projectItems/nodes")
10715        .and_then(Value::as_array_mut)
10716        .ok_or_else(|| malformed("no list of board memberships"))?;
10717    for node in nodes {
10718        let node = node
10719            .as_object_mut()
10720            .ok_or_else(|| malformed("a board membership that is not an object"))?;
10721        let status = match node.get("status") {
10722            None => return Err(malformed("a board membership with no status member")),
10723            Some(Value::Null) => None,
10724            Some(status) if status.get("name").is_some_and(Value::is_string) => {
10725                Some(status.clone())
10726            }
10727            // A single-select value of another field type answers `{}`; only an option value
10728            // carries a name.
10729            Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
10730            Some(_) => return Err(malformed("a membership status that names no option")),
10731        };
10732        node.insert(
10733            "fieldValues".to_owned(),
10734            json!({"nodes": status.into_iter().collect::<Vec<_>>(),
10735                   "pageInfo": {"hasNextPage": false}}),
10736        );
10737    }
10738    Ok(issue)
10739}
10740
10741/// The issue one slot of a read by reference answered, `None` when GitHub answered that
10742/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
10743/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
10744/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
10745/// than a reference nothing holds.
10746fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
10747    let alias = format!("r{slot}");
10748    let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
10749        message: format!("GitHub answered a read by reference with no {alias}"),
10750    })?;
10751    if repository.is_null() {
10752        return Ok(None);
10753    }
10754    let issue = repository
10755        .get("issue")
10756        .ok_or_else(|| SourceError::Malformed {
10757            message: format!("GitHub answered {alias} of a read by reference with no issue"),
10758        })?;
10759    if issue.is_null() {
10760        return Ok(None);
10761    }
10762    // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
10763    if optional_str(issue, "__typename")? != Some("Issue") {
10764        return Err(SourceError::Malformed {
10765            message: format!(
10766                "GitHub answered {alias} of a read by reference with an issue whose __typename \
10767                 is not Issue"
10768            ),
10769        });
10770    }
10771    Ok(Some(issue))
10772}
10773
10774/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
10775fn spoken_reason(reason: &str) -> String {
10776    reason.to_ascii_lowercase().replace('_', " ")
10777}
10778
10779impl GitHubProjectsSource {
10780    /// Every task and project of this board whose card contradicts its issue's closure, each
10781    /// judged against its own kind's half of `status_mapping`, read through the board's own
10782    /// `is:closed` filter, page by page — never the board's unfiltered `items`.
10783    ///
10784    /// Each item the filter answers is confirmed from its own node: an open one, one whose
10785    /// closure reads `unknown`, and one whose card is already in line are left out whatever
10786    /// the filter let through. The board's fields ride along and are held for the option
10787    /// writes that follow, so those need no read of the board.
10788    async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
10789        let mut after: Option<String> = None;
10790        let mut found = Vec::new();
10791        loop {
10792            let data = self
10793                .graphql(
10794                    graphql::CLOSED_ITEMS,
10795                    json!({"owner":self.owner,"number":self.project_number,
10796                           "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
10797                           "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
10798                )
10799                .await?;
10800            let board = data
10801                .pointer("/closedItems/projectV2")
10802                .filter(|value| !value.is_null())
10803                .ok_or_else(|| SourceError::Refused {
10804                    message: format!(
10805                        "GitHub project {}/{} was not found or is not visible to the token",
10806                        self.owner, self.project_number
10807                    ),
10808                })?;
10809            *self.fields_cache()? = Some(BoardFields {
10810                id: BoardId::parse(required_str(board, "id")?)?,
10811                fields: board.get("fields").cloned().unwrap_or(Value::Null),
10812            });
10813            let items = board.get("items").ok_or_else(|| SourceError::Malformed {
10814                message: "GitHub project closed items are missing".into(),
10815            })?;
10816            for item in items
10817                .get("nodes")
10818                .and_then(Value::as_array)
10819                .ok_or_else(|| SourceError::Malformed {
10820                    message: "GitHub project items.nodes is not an array".into(),
10821                })?
10822            {
10823                let Some(resolved) = self.resolve(item)? else {
10824                    continue;
10825                };
10826                let BoardKind::Work(kind) = resolved.kind else {
10827                    continue;
10828                };
10829                let Some((closed_as, name)) = self.statuses.contradicted(
10830                    kind,
10831                    resolved.option.as_deref(),
10832                    resolved.closed,
10833                    resolved.reason.as_deref(),
10834                ) else {
10835                    continue;
10836                };
10837                found.push(StatusContradiction {
10838                    closed_as,
10839                    to: name.to_owned(),
10840                    option: resolved.option.clone(),
10841                    item: match kind {
10842                        ItemKind::Task => ContradictedItem::Task(resolved.task()?),
10843                        ItemKind::Project => ContradictedItem::Project(resolved.project()),
10844                    },
10845                });
10846            }
10847            let info = items
10848                .get("pageInfo")
10849                .ok_or_else(|| SourceError::Malformed {
10850                    message: "GitHub project items have no pageInfo".into(),
10851                })?;
10852            if !required_bool(info, "hasNextPage")? {
10853                return Ok(found);
10854            }
10855            let next = required_str(info, "endCursor")?;
10856            validate_cursor_progress(after.as_deref(), next)?;
10857            after = Some(next.to_owned());
10858        }
10859    }
10860
10861    /// What this board holds for each issue in `issues`, once each in the order given, read
10862    /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
10863    ///
10864    /// An issue GitHub cannot find for this source's credential, and one with no item on this
10865    /// board, are both absent. A closure that already says the work will not be done here —
10866    /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
10867    /// completed whose card sits at its kind's `done` option is unchanged, and every other item
10868    /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
10869    /// tasks as sub-issues — is held under exactly these rules, judged against the project
10870    /// kind's half of `status_mapping`.
10871    async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
10872        let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
10873        for issue in issues {
10874            if !distinct.contains(issue) {
10875                distinct.push(issue.clone());
10876            }
10877        }
10878        let done = |kind: ItemKind| {
10879            self.statuses
10880                .target(kind, StatusCategory::Done)
10881                .option()
10882                .unwrap_or("Done")
10883                .to_owned()
10884        };
10885        // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
10886        // its range names no issue any repository can hold: it is answered absent and never sent,
10887        // where binding it would have GitHub refuse the whole batch.
10888        let readable: Vec<IssueRef> = distinct
10889            .iter()
10890            .filter(|issue| i32::try_from(issue.number).is_ok())
10891            .cloned()
10892            .collect();
10893        let mut holdings = Vec::with_capacity(readable.len());
10894        for batch in readable.chunks(REFERENCE_BATCH) {
10895            let data = self
10896                .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
10897                .await?;
10898            for (slot, issue) in batch.iter().enumerate() {
10899                let node = referenced_issue(&data, slot)?;
10900                let resolved = match node {
10901                    Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
10902                    None => None,
10903                };
10904                // A task and a project are both held; a document is not work, so nothing is.
10905                let Some((item, kind)) = resolved.and_then(|item| match item.kind {
10906                    BoardKind::Work(kind) => Some((item, kind)),
10907                    BoardKind::Document => None,
10908                }) else {
10909                    holdings.push(IssueHolding {
10910                        issue: issue.clone(),
10911                        completion: Completion::Absent,
10912                    });
10913                    continue;
10914                };
10915                let task = match kind {
10916                    ItemKind::Task => {
10917                        let held = item.task()?;
10918                        HeldTask {
10919                            id: held.id,
10920                            kind,
10921                            option: item.option.clone(),
10922                            classification: held.classification,
10923                            repositories: held.repositories,
10924                            delivers: held.delivers,
10925                        }
10926                    }
10927                    // A project delivers no task, so what its write keeps in step is nothing.
10928                    ItemKind::Project => {
10929                        let held = item.project();
10930                        HeldTask {
10931                            id: held.id,
10932                            kind,
10933                            option: item.option.clone(),
10934                            classification: held.classification,
10935                            repositories: held.repositories,
10936                            delivers: Vec::new(),
10937                        }
10938                    }
10939                };
10940                let done = done(kind);
10941                let completion = match (item.closed, item.reason.as_deref()) {
10942                    (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
10943                        task,
10944                        reason: format!("closed as {}", spoken_reason(reason)),
10945                    },
10946                    (true, None | Some("COMPLETED"))
10947                        if item
10948                            .option
10949                            .as_deref()
10950                            .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
10951                    {
10952                        Completion::Unchanged { task }
10953                    }
10954                    _ => {
10955                        let kept = item.clone();
10956                        self.held_cache
10957                            .lock()
10958                            .map_err(|_| SourceError::Unavailable {
10959                                message: "items read by issue reference were left \
10960                                          inconsistent; run the command again"
10961                                    .into(),
10962                            })?
10963                            .insert(kept.id.clone(), kept);
10964                        Completion::Due {
10965                            task,
10966                            to: done.clone(),
10967                        }
10968                    }
10969                };
10970                holdings.push(IssueHolding {
10971                    issue: issue.clone(),
10972                    completion,
10973                });
10974            }
10975        }
10976        let mut read = holdings.into_iter();
10977        let mut answered = Vec::with_capacity(distinct.len());
10978        for issue in distinct {
10979            if i32::try_from(issue.number).is_ok() {
10980                answered.push(read.next().ok_or_else(|| SourceError::Malformed {
10981                    message: format!("no holding was read for {issue}"),
10982                })?);
10983            } else {
10984                answered.push(IssueHolding {
10985                    issue,
10986                    completion: Completion::Absent,
10987                });
10988            }
10989        }
10990        Ok(answered)
10991    }
10992}
10993
10994#[async_trait::async_trait]
10995impl TaskSource for GitHubProjectsSource {
10996    fn kind(&self) -> &'static str {
10997        KIND
10998    }
10999    fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
11000        change_token::order(a, b)
11001    }
11002    /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
11003    /// member, the change each part of the token moves for. Not its forward edges — see
11004    /// [`FAR_ENDS_UNCOVERED`].
11005    fn version_coverage(&self) -> VersionCoverage {
11006        VersionCoverage {
11007            forward_edges: Coverage::Uncovered {
11008                reason: FAR_ENDS_UNCOVERED.to_owned(),
11009            },
11010            ..VersionCoverage::whole()
11011        }
11012    }
11013    /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
11014    /// and for a far end deleted, so the token sees every change to a task's edges but one — a
11015    /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
11016    /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
11017    /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
11018    // 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.
11019    fn copy_version_coverage(&self) -> VersionCoverage {
11020        VersionCoverage::whole()
11021    }
11022    /// A project's fields, and not its forward edges, for the same reason a task's are not.
11023    fn project_version_coverage(&self) -> ProjectVersionCoverage {
11024        ProjectVersionCoverage {
11025            fields: Coverage::Covered,
11026            forward_edges: Coverage::Uncovered {
11027                reason: FAR_ENDS_UNCOVERED.to_owned(),
11028            },
11029        }
11030    }
11031    /// A document's fields and the manifest of its image assets, which its metadata slot
11032    /// records: both are the issue's body, whose every edit moves the token's issue time.
11033    fn document_version_coverage(&self) -> DocumentVersionCoverage {
11034        DocumentVersionCoverage::whole()
11035    }
11036    fn write_context(&self, id: &NativeId) -> Option<Value> {
11037        self.context_of(id)
11038    }
11039    fn board_context(&self) -> Option<Value> {
11040        self.board_context_held()
11041    }
11042    fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
11043        self.prime(id, context, board)
11044    }
11045    fn unprime(&self, id: &NativeId) {
11046        self.unprime_held(id);
11047    }
11048    fn begin_journal(&self) {
11049        self.expected.begin();
11050    }
11051    fn end_journal(&self) -> Option<WriteJournal> {
11052        Some(self.expected.end())
11053    }
11054    fn capabilities(&self) -> Capabilities {
11055        Capabilities {
11056            projects: Support::Native,
11057            documents: Support::Native,
11058            comments: Support::Native,
11059            assets: Support::Native,
11060            priority: if self.priorities.is_some() {
11061                Support::Native
11062            } else {
11063                Support::Unsupported
11064            },
11065            filter_by_priority: Support::Native,
11066            filter_by_comment_activity: Support::Native,
11067            filter_by_metadata: Support::Native,
11068            filter_by_origin: Support::Native,
11069            reconcile_status: Support::Native,
11070            complete_by_issue: Support::Native,
11071            orphan_tasks: Support::Native,
11072            filter_by_label: Support::Native,
11073            filter_by_status: Support::Native,
11074            search_title: Support::Native,
11075            search_content: Support::Native,
11076            task_dependencies: DependencySupport::BothDirections,
11077            project_dependencies: DependencySupport::BothDirections,
11078            max_page_size: MAX_PAGE_SIZE,
11079        }
11080    }
11081    async fn visibility(
11082        &self,
11083        target: &onetaskgraph_plugin_api::WriteTarget<'_>,
11084    ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
11085        self.write_visibility(target).await
11086    }
11087    async fn health(&self) -> Result<Health, SourceError> {
11088        let board = self.board_page(None, 1).await?;
11089        Ok(Health {
11090            reachable: true,
11091            detail: Some(format!(
11092                "reading GitHub project {}/{} ({})",
11093                self.owner,
11094                self.project_number,
11095                required_str(&board, "title")?
11096            )),
11097        })
11098    }
11099    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
11100        self.item_by_id(id)
11101            .await?
11102            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
11103            .map(|item| item.task())
11104            .transpose()
11105    }
11106    async fn task_assets(
11107        &self,
11108        id: &NativeId,
11109    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
11110        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
11111    }
11112    async fn task_asset(
11113        &self,
11114        id: &NativeId,
11115        name: &onetaskgraph_plugin_api::AssetName,
11116    ) -> Result<Option<Vec<u8>>, SourceError> {
11117        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
11118            .await
11119    }
11120    async fn set_task_rendering_with_assets(
11121        &self,
11122        id: &NativeId,
11123        content: &str,
11124        provenance: &Value,
11125        _answers: &BTreeMap<String, Value>,
11126        assets: &onetaskgraph_plugin_api::AssetWrite,
11127    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
11128        self.replace_rendering(
11129            id,
11130            BoardKind::Work(ItemKind::Task),
11131            content,
11132            provenance,
11133            Some(assets),
11134        )
11135        .await
11136    }
11137    async fn document_assets(
11138        &self,
11139        id: &NativeId,
11140    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
11141        self.held_assets(id, BoardKind::Document).await
11142    }
11143    async fn document_asset(
11144        &self,
11145        id: &NativeId,
11146        name: &onetaskgraph_plugin_api::AssetName,
11147    ) -> Result<Option<Vec<u8>>, SourceError> {
11148        self.held_asset(id, BoardKind::Document, name).await
11149    }
11150    async fn set_document_rendering_with_assets(
11151        &self,
11152        id: &NativeId,
11153        content: &str,
11154        provenance: &Value,
11155        _answers: &BTreeMap<String, Value>,
11156        assets: &onetaskgraph_plugin_api::AssetWrite,
11157    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
11158        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
11159            .await
11160    }
11161    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
11162        Ok(self
11163            .item_by_id(id)
11164            .await?
11165            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
11166            .map(|item| item.project()))
11167    }
11168    async fn query_tasks(
11169        &self,
11170        query: &TaskQuery,
11171        page: &PageRequest,
11172    ) -> Result<Page<Task>, SourceError> {
11173        validate_page(page)?;
11174        refuse_unsearchable(query)?;
11175        // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
11176        // category — `unknown`; every other category reads only off an open issue or a draft. So
11177        // a status filter naming none of those three asks every issue search it sends for open
11178        // issues alone; each candidate's category is still confirmed from its own item below.
11179        let open_only = !query.statuses.is_empty()
11180            && query.statuses.iter().all(|category| {
11181                !matches!(
11182                    category,
11183                    StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
11184                )
11185            });
11186        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
11187            let mut qualifiers: Vec<String> = [
11188                query.commented_since.map(updated_qualifier),
11189                narrowing_qualifiers(query),
11190            ]
11191            .into_iter()
11192            .flatten()
11193            .collect();
11194            // A narrowed search never answers a draft, which is no issue, so narrowing it to
11195            // open issues too leaves out nothing such a search could have answered.
11196            if !qualifiers.is_empty() {
11197                if open_only {
11198                    qualifiers.push(OPEN_QUALIFIER.to_owned());
11199                }
11200                return self.search_tasks(query, page, &qualifiers.join(" ")).await;
11201            }
11202        }
11203
11204        // A read narrowed to one project asks that project for its own tasks, so nothing
11205        // about it costs what the rest of the board holds. A read carrying a text, metadata
11206        // or origin predicate asks GitHub the narrower question those predicates are, and a
11207        // read narrowed to comment activity alone asks the board's own issue search for the
11208        // issues updated since, which is every issue a comment could have been written or
11209        // edited on since. Every other task read is a question about the whole board and is
11210        // answered by reading it.
11211        let (held, membership) = match (&query.project, query.commented_since) {
11212            (ProjectFilter::Is(project), _) => (
11213                self.project_children(project).await?,
11214                // Answered by where these items came from; see `task_matches`.
11215                &ProjectFilter::Any,
11216            ),
11217            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
11218                match (self.narrowed(query).await?, since) {
11219                    (Some(narrowed), _) => (narrowed, &query.project),
11220                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
11221                    // The board's own items list every draft; only the search half, which
11222                    // completes them with issues they lag behind on, is narrowed — a closed
11223                    // issue that half leaves out is at no category the filter keeps.
11224                    (None, None) if open_only => (
11225                        self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
11226                        &query.project,
11227                    ),
11228                    (None, None) => (self.board().await?.items, &query.project),
11229                }
11230            }
11231        };
11232        // Filtered before paged: a page of a filtered result is a page of the survivors,
11233        // never the survivors of a page.
11234        let mut tasks = Vec::new();
11235        for item in held
11236            .iter()
11237            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
11238        {
11239            let task = item.task()?;
11240            if task_matches(&task, query, membership)
11241                && self.commented_since(item, query.commented_since).await?
11242            {
11243                tasks.push(task);
11244            }
11245        }
11246        Ok(offset_page(
11247            tasks,
11248            numeric_cursor(page.cursor.as_ref())?,
11249            page.limit.min(MAX_PAGE_SIZE) as usize,
11250        ))
11251    }
11252    async fn query_projects(
11253        &self,
11254        query: &ProjectQuery,
11255        page: &PageRequest,
11256    ) -> Result<Page<Project>, SourceError> {
11257        validate_page(page)?;
11258        refuse_unsearchable_text(query.text.as_ref())?;
11259        // The projects a board holds are found by an issue search scoped to that board,
11260        // never by walking the board's own item connection: what tells a project from a
11261        // task is the `parent` each issue carries, which costs nothing to read. A query
11262        // carrying a text asks that search for the text too, so it reads the issues that
11263        // hold it rather than every issue of the board.
11264        let held = match self.text_searched(query.text.as_ref()).await? {
11265            Some(searched) => searched,
11266            None => self.board_issues().await?,
11267        };
11268        let projects = held
11269            .iter()
11270            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
11271            .map(Resolved::project)
11272            .filter(|project| project_matches(project, query))
11273            .collect();
11274        Ok(offset_page(
11275            projects,
11276            numeric_cursor(page.cursor.as_ref())?,
11277            page.limit.min(MAX_PAGE_SIZE) as usize,
11278        ))
11279    }
11280    async fn query_projects_by_origin(
11281        &self,
11282        origin: &str,
11283        page: &PageRequest,
11284    ) -> Result<Page<Project>, SourceError> {
11285        validate_page(page)?;
11286        let projects = self
11287            .copied_from(origin, BoardKind::Work(ItemKind::Project))
11288            .await?
11289            .iter()
11290            .map(Resolved::project)
11291            .collect();
11292        Ok(offset_page(
11293            projects,
11294            numeric_cursor(page.cursor.as_ref())?,
11295            page.limit.min(MAX_PAGE_SIZE) as usize,
11296        ))
11297    }
11298    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
11299        Ok(self
11300            .item_by_id(id)
11301            .await?
11302            .filter(|item| item.kind == BoardKind::Document)
11303            .map(|item| item.document()))
11304    }
11305    async fn query_documents_by_origin(
11306        &self,
11307        origin: &str,
11308        page: &PageRequest,
11309    ) -> Result<Page<Document>, SourceError> {
11310        validate_page(page)?;
11311        let documents = self
11312            .copied_from(origin, BoardKind::Document)
11313            .await?
11314            .iter()
11315            .map(Resolved::document)
11316            .collect();
11317        Ok(offset_page(
11318            documents,
11319            numeric_cursor(page.cursor.as_ref())?,
11320            page.limit.min(MAX_PAGE_SIZE) as usize,
11321        ))
11322    }
11323    async fn query_documents(
11324        &self,
11325        query: &DocumentQuery,
11326        page: &PageRequest,
11327    ) -> Result<Page<Document>, SourceError> {
11328        validate_page(page)?;
11329        // Narrowed to one project, this is the same sub-issue read a task list scoped to
11330        // that project makes — a document filed under a project is a sub-issue of it too,
11331        // and which of them come back is the kind this caller asked for. Unscoped, a query
11332        // carrying a text asks the board-scoped issue search for it, as a task query does,
11333        // and only one carrying none reads the board.
11334        let (held, membership) = match &query.project {
11335            ProjectFilter::Is(project) => (
11336                self.project_children(project).await?,
11337                // Answered by where these items came from; see `task_matches`.
11338                &ProjectFilter::Any,
11339            ),
11340            ProjectFilter::Any | ProjectFilter::Orphans => {
11341                refuse_unsearchable_text(query.text.as_ref())?;
11342                match self.text_searched(query.text.as_ref()).await? {
11343                    Some(searched) => (searched, &query.project),
11344                    None => (self.board().await?.items, &query.project),
11345                }
11346            }
11347        };
11348        // Filtered before paged, exactly as a task read is: a page of a filtered result is
11349        // a page of the survivors, never the survivors of a page.
11350        let documents = held
11351            .iter()
11352            .filter(|item| item.kind == BoardKind::Document)
11353            .map(Resolved::document)
11354            .filter(|document| document_matches(document, query, membership))
11355            .collect();
11356        Ok(offset_page(
11357            documents,
11358            numeric_cursor(page.cursor.as_ref())?,
11359            page.limit.min(MAX_PAGE_SIZE) as usize,
11360        ))
11361    }
11362    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
11363        validate_page(page)?;
11364        let offset = numeric_cursor(page.cursor.as_ref())?;
11365        let mut labels = self
11366            .board()
11367            .await?
11368            .items
11369            .into_iter()
11370            .flat_map(|item| item.labels)
11371            .fold(Vec::new(), |mut all, label| {
11372                if !all.iter().any(|x: &Label| x.id == label.id) {
11373                    all.push(label);
11374                }
11375                all
11376            });
11377        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
11378        Ok(offset_page(
11379            labels,
11380            offset,
11381            page.limit.min(MAX_PAGE_SIZE) as usize,
11382        ))
11383    }
11384    async fn task_dependencies(
11385        &self,
11386        id: &NativeId,
11387        direction: Direction,
11388        page: &PageRequest,
11389    ) -> Result<Page<DependencyEdge>, SourceError> {
11390        self.dependencies(id, ItemKind::Task, direction, page).await
11391    }
11392    async fn project_dependencies(
11393        &self,
11394        id: &NativeId,
11395        direction: Direction,
11396        page: &PageRequest,
11397    ) -> Result<Page<DependencyEdge>, SourceError> {
11398        self.dependencies(id, ItemKind::Project, direction, page)
11399            .await
11400    }
11401
11402    fn writes(&self) -> WriteSupport {
11403        WriteSupport::Supported
11404    }
11405
11406    /// Create or update one task.
11407    ///
11408    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
11409    /// neither may name the task itself or name one task twice — and land in the body's
11410    /// metadata slot under their reserved keys, in place of any caller metadata of those
11411    /// names.
11412    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
11413        self.write_task_assets(write, None)
11414            .await
11415            .map(|written| written.id)
11416    }
11417
11418    async fn write_task_with_assets(
11419        &self,
11420        write: &ItemWrite<Task>,
11421        _answers: Option<&BTreeMap<String, Value>>,
11422        assets: &onetaskgraph_plugin_api::AssetWrite,
11423    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11424        self.write_task_assets(write, Some(assets)).await
11425    }
11426
11427    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
11428        self.write_item(
11429            &Incoming {
11430                written: Written::Work(ItemKind::Project, &write.item.status),
11431                title: &write.item.title,
11432                content: write.item.content.as_deref(),
11433                assets: None,
11434                labels: &write.item.labels,
11435                metadata: &write.item.metadata,
11436                repositories: &write.item.repositories,
11437                classification: write.item.classification,
11438                parent: None,
11439                delivers: &[],
11440                delivered_by: &[],
11441                priority: None,
11442            },
11443            write.target.as_ref(),
11444            &write.depends_on,
11445        )
11446        .await
11447        .map(|written| written.id)
11448    }
11449
11450    /// Create or update one document, which is one issue titled the way this board spells
11451    /// a document.
11452    ///
11453    /// Everything else is exactly a task write: caller metadata goes to the same canonical
11454    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
11455    /// or a field this board cannot carry is refused by name rather than dropped, a target
11456    /// naming an issue this board does not hold is refused rather than created, and an
11457    /// issue this call created is taken back when the rest of the write fails.
11458    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
11459        self.write_document_assets(write, None)
11460            .await
11461            .map(|written| written.id)
11462    }
11463
11464    async fn write_document_with_assets(
11465        &self,
11466        write: &ItemWrite<Document>,
11467        _answers: Option<&BTreeMap<String, Value>>,
11468        assets: &onetaskgraph_plugin_api::AssetWrite,
11469    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11470        self.write_document_assets(write, Some(assets)).await
11471    }
11472
11473    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
11474    /// which reads nothing; then the board's `Status` option. Over an existing item that is
11475    /// read off the item, as the write reads it, and the item is held among this command's
11476    /// resolved records so the write that follows reuses that read rather than repeating it;
11477    /// an item that does not carry the field takes the board's fields, which are held once
11478    /// read. A create is checked against the board's fields only when this command already
11479    /// holds them, because a create reads them together with its repository, in one request,
11480    /// and refuses a missing option before it writes anything.
11481    async fn check_status_write(
11482        &self,
11483        kind: ItemKind,
11484        category: StatusCategory,
11485        target: Option<&NativeId>,
11486    ) -> Result<(), SourceError> {
11487        let status = self.resolved_target(kind, category)?;
11488        if status.option().is_none() {
11489            return Ok(());
11490        }
11491        let fields = match target {
11492            Some(target) => {
11493                // A target this board does not hold is the write's own refusal to make.
11494                let Some(item) = self.bound_item(target).await? else {
11495                    return Ok(());
11496                };
11497                self.resolved_cache()?.insert(target.clone(), item.clone());
11498                self.fields_for(Some(&item), true, false).await?.fields
11499            }
11500            None => {
11501                let held = self
11502                    .board_cache()?
11503                    .as_ref()
11504                    .map(|board| board.fields.clone());
11505                match held.or_else(|| {
11506                    self.fields_cache()
11507                        .ok()
11508                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
11509                }) {
11510                    Some(fields) => fields,
11511                    None => return Ok(()),
11512                }
11513            }
11514        };
11515        self.column_for(&fields, kind, category, &status)
11516            .map(|_| ())
11517    }
11518
11519    /// Set one task's status alone.
11520    ///
11521    /// An open target reopens a closed issue with an `updateIssue` carrying only its
11522    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
11523    /// terminal target selects its mapped option, then closes with its fixed reason. No
11524    /// request carries a title, a body or a label. The status
11525    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
11526    /// what a re-read reports.
11527    async fn set_task_status(
11528        &self,
11529        id: &NativeId,
11530        category: StatusCategory,
11531    ) -> Result<Option<Status>, SourceError> {
11532        self.set_status(id, ItemKind::Task, category).await
11533    }
11534
11535    /// Set one project's status alone, by exactly the paired write a task's status takes, for
11536    /// the project kind: a terminal category selects the project kind's mapped option and then
11537    /// closes the issue with the category's fixed reason — an issue already closed with a
11538    /// reason that reads that category is left as it is — and an open category reopens a closed
11539    /// issue and then selects its option. A board draft is refused a terminal category as a
11540    /// task's is.
11541    async fn set_project_status(
11542        &self,
11543        id: &NativeId,
11544        category: StatusCategory,
11545    ) -> Result<Option<Status>, SourceError> {
11546        self.set_status(id, ItemKind::Project, category).await
11547    }
11548
11549    /// Every task and project whose card contradicts its issue's closure, read through the
11550    /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
11551    async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
11552        self.contradictions().await
11553    }
11554
11555    /// What this board holds for each issue reference, one request per fifty of them; see
11556    /// [`TaskSource::issue_holdings`].
11557    async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
11558        self.holdings(issues).await
11559    }
11560
11561    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
11562    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
11563    /// for `none`. Refused by an instance with no `priority_mapping`.
11564    async fn set_task_priority(
11565        &self,
11566        id: &NativeId,
11567        priority: Priority,
11568    ) -> Result<Option<Priority>, SourceError> {
11569        self.set_priority(id, priority).await
11570    }
11571
11572    /// Replace one task's content with a single body update that keeps the metadata slot
11573    /// byte for byte.
11574    async fn set_task_content(
11575        &self,
11576        id: &NativeId,
11577        content: &str,
11578    ) -> Result<Option<()>, SourceError> {
11579        self.replace_content(id, content).await
11580    }
11581
11582    /// Replace one task issue's content and its provenance slot entry with a single body
11583    /// update. The answers are not kept: see `replace_rendering`.
11584    async fn set_task_rendering(
11585        &self,
11586        id: &NativeId,
11587        content: &str,
11588        provenance: &Value,
11589        _answers: &BTreeMap<String, Value>,
11590    ) -> Result<Option<()>, SourceError> {
11591        self.replace_rendering(
11592            id,
11593            BoardKind::Work(ItemKind::Task),
11594            content,
11595            provenance,
11596            None,
11597        )
11598        .await
11599        .map(|written| written.map(|_| ()))
11600    }
11601
11602    /// Replace one design-document issue's content and its provenance slot entry, on exactly
11603    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
11604    async fn set_document_rendering(
11605        &self,
11606        id: &NativeId,
11607        content: &str,
11608        provenance: &Value,
11609        _answers: &BTreeMap<String, Value>,
11610    ) -> Result<Option<()>, SourceError> {
11611        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
11612            .await
11613            .map(|written| written.map(|_| ()))
11614    }
11615
11616    /// Replace one project issue's content and its provenance slot entry, on exactly the
11617    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
11618    async fn set_project_rendering(
11619        &self,
11620        id: &NativeId,
11621        content: &str,
11622        provenance: &Value,
11623        _answers: &BTreeMap<String, Value>,
11624    ) -> Result<Option<()>, SourceError> {
11625        self.replace_rendering(
11626            id,
11627            BoardKind::Work(ItemKind::Project),
11628            content,
11629            provenance,
11630            None,
11631        )
11632        .await
11633        .map(|written| written.map(|_| ()))
11634    }
11635
11636    /// Apply a targeted update with one read of the item and a write only for what differs:
11637    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
11638    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
11639    async fn update_task(
11640        &self,
11641        id: &NativeId,
11642        update: &TaskUpdate,
11643    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
11644        self.targeted_update(id, update).await
11645    }
11646
11647    /// Replace one task's `delivered_by` with a single body update that changes the
11648    /// metadata slot and nothing outside it.
11649    async fn set_delivered_by(
11650        &self,
11651        id: &NativeId,
11652        delivered_by: &[TaskRef],
11653    ) -> Result<Option<()>, SourceError> {
11654        self.replace_delivered_by(id, delivered_by).await
11655    }
11656
11657    /// Set one key of one task issue's metadata with a single body update that changes the
11658    /// metadata slot and nothing outside it — no title, label, state or board field request —
11659    /// and sends nothing when the task already holds that value under the key.
11660    async fn set_task_metadata(
11661        &self,
11662        id: &NativeId,
11663        key: &MetadataKey,
11664        value: &Value,
11665    ) -> Result<Option<Task>, SourceError> {
11666        Ok(self
11667            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
11668            .await?
11669            .map(|item| item.task())
11670            .transpose()?)
11671    }
11672
11673    /// Set one key of one project issue's metadata, on exactly the terms of
11674    /// [`set_task_metadata`](TaskSource::set_task_metadata).
11675    async fn set_project_metadata(
11676        &self,
11677        id: &NativeId,
11678        key: &MetadataKey,
11679        value: &Value,
11680    ) -> Result<Option<Project>, SourceError> {
11681        Ok(self
11682            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
11683            .await?
11684            .map(|item| item.project()))
11685    }
11686
11687    /// Set one key of one design-document issue's metadata, on exactly the terms of
11688    /// [`set_task_metadata`](TaskSource::set_task_metadata).
11689    async fn set_document_metadata(
11690        &self,
11691        id: &NativeId,
11692        key: &MetadataKey,
11693        value: &Value,
11694    ) -> Result<Option<Document>, SourceError> {
11695        Ok(self
11696            .set_slot_key(id, BoardKind::Document, key, value)
11697            .await?
11698            .map(|item| item.document()))
11699    }
11700
11701    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
11702        self.delete_item(id).await
11703    }
11704
11705    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
11706        self.delete_item(id).await
11707    }
11708
11709    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
11710        self.delete_item(id).await
11711    }
11712
11713    /// One page of the task issue's own comments, walked by GitHub's own cursor.
11714    ///
11715    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
11716    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
11717    ///
11718    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
11719    /// board is the read of its comments. A draft this process already resolved is refused
11720    /// without one.
11721    async fn task_comments(
11722        &self,
11723        task: &NativeId,
11724        page: &PageRequest,
11725    ) -> Result<Option<Page<Comment>>, SourceError> {
11726        validate_page(page)?;
11727        let cached = self.resolved_cache()?.get(task).cloned();
11728        if let Some(item) = cached {
11729            if item.kind != BoardKind::Work(ItemKind::Task) {
11730                return Ok(None);
11731            }
11732            if item.content_kind == ContentKind::DraftIssue {
11733                return Err(self.draft_has_no_comments(task));
11734            }
11735        }
11736        match self.issue_detail(task, page).await? {
11737            Some(TaskDetailRead {
11738                comments: Some(comments),
11739                ..
11740            }) => comments,
11741            _ => Ok(None),
11742        }
11743    }
11744
11745    /// Every id's task, with the first page of its comments when `comments` names it:
11746    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
11747    /// comments in one [`graphql::ISSUE_DETAIL`] request.
11748    async fn get_task_details(
11749        &self,
11750        ids: &[NativeId],
11751        comments: Option<&PageRequest>,
11752    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11753        if let Some(page) = comments
11754            && let Err(error) = validate_page(page)
11755        {
11756            return ids.iter().map(|_| Err(error.clone())).collect();
11757        }
11758        match (ids, comments) {
11759            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
11760            ([id], None) => vec![self.task_read(id).await],
11761            _ => self.issue_details(ids, comments).await,
11762        }
11763    }
11764
11765    /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
11766    /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
11767    /// request, so the dependency read a copy makes of each next is answered from this one.
11768    async fn get_task_details_for_copy(
11769        &self,
11770        ids: &[NativeId],
11771        comments: &PageRequest,
11772    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11773        // A page past the first is a page of one task's comments, which the detail read of it
11774        // alone answers.
11775        if comments.cursor.is_some() {
11776            return self.get_task_details(ids, Some(comments)).await;
11777        }
11778        if let Err(error) = validate_page(comments) {
11779            return ids.iter().map(|_| Err(error.clone())).collect();
11780        }
11781        let mut read = Vec::with_capacity(ids.len());
11782        for batch in ids.chunks(COPY_DETAIL_BATCH) {
11783            match self
11784                .graphql(
11785                    graphql::ISSUE_COPY_DETAILS,
11786                    copy_detail_batch(batch, comments),
11787                )
11788                .await
11789            {
11790                Ok(data) => {
11791                    for (slot, id) in batch.iter().enumerate() {
11792                        let read_one = match data.get(format!("i{slot}")) {
11793                            Some(node) => self.detail_of(id, node, true, None).await,
11794                            None => Err(SourceError::Malformed {
11795                                message: format!(
11796                                    "GitHub answered a batch read with no item for {}",
11797                                    id.0
11798                                ),
11799                            }),
11800                        };
11801                        read.push(read_one);
11802                    }
11803                }
11804                Err(error) if unresolvable_node(&error) => {
11805                    for id in batch {
11806                        read.push(self.issue_detail(id, comments).await);
11807                    }
11808                }
11809                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
11810            }
11811        }
11812        read
11813    }
11814
11815    /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
11816    /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
11817    /// and held for the next read of each by id; see `read_ahead`.
11818    ///
11819    /// What this run wrote is never read: its own record answers, as it does for a read by id.
11820    /// Nor is an item a listing of its project carried whole earlier in this command — see
11821    /// `carried` — which is held in place of reading it.
11822    /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
11823    /// so each of its ids is read on its own and answered as that read answers; a draft, and an
11824    /// answer naming another issue than the one asked for, are left to their own reads too.
11825    async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
11826        let mut unread: Vec<NativeId> = Vec::new();
11827        // Read whole by a listing of its project in this command, which a mutation naming it
11828        // would have dropped: held as a batch would have held it, once every batch is answered.
11829        let mut listed: Vec<NativeId> = Vec::new();
11830        {
11831            let created = self.created()?;
11832            let held = self.read_ahead()?;
11833            let carried = self.carried()?;
11834            for id in ids {
11835                if unread.contains(id)
11836                    || listed.contains(id)
11837                    || held.contains_key(id)
11838                    || created.iter().any(|own| own.id == *id)
11839                {
11840                    continue;
11841                }
11842                if carried.contains_key(id) {
11843                    listed.push(id.clone());
11844                } else {
11845                    unread.push(id.clone());
11846                }
11847            }
11848        }
11849        // Nothing of an item's comments is wanted here: the smallest page keeps the read to
11850        // what the item itself costs.
11851        let comments = PageRequest {
11852            cursor: None,
11853            limit: 1,
11854        };
11855        // Held only once every batch has been answered, so a read-ahead that fails part way
11856        // holds nothing — what a listing carried included, which stays carried — and every
11857        // item is read on its own, as the method promises.
11858        let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
11859        for batch in unread.chunks(COPY_DETAIL_BATCH) {
11860            let data = match self
11861                .graphql(
11862                    graphql::ISSUE_COPY_DETAILS,
11863                    copy_detail_batch(batch, &comments),
11864                )
11865                .await
11866            {
11867                Ok(data) => data,
11868                Err(error) if unresolvable_node(&error) => continue,
11869                Err(error) => return Err(error),
11870            };
11871            for (slot, id) in batch.iter().enumerate() {
11872                let Some(node) = data.get(format!("i{slot}")) else {
11873                    continue;
11874                };
11875                let held = if node.is_null() {
11876                    None
11877                } else {
11878                    match optional_str(node, "__typename")? {
11879                        Some("DraftIssue") => continue,
11880                        Some("Issue") if required_str(node, "id")? != id.0 => continue,
11881                        _ => {}
11882                    }
11883                    // Remembered below with the rest, so a later batch refused leaves no
11884                    // dependency read answered from this one either.
11885                    self.resolve_issue_as(node, false).await?
11886                };
11887                read.push((id.clone(), held));
11888            }
11889        }
11890        let mut resolved = self.resolved_cache()?;
11891        let mut ahead = self.read_ahead()?;
11892        let mut carried = self.carried()?;
11893        for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
11894            resolved.insert(item.id.clone(), item.clone());
11895        }
11896        // Taken only if still carried: a mutation naming it while the batches ran dropped it,
11897        // and its next read is then its own.
11898        for id in &listed {
11899            let Some(item) = carried.remove(id) else {
11900                continue;
11901            };
11902            // The whole of what it is blocked by came with it, so the dependency read a copy
11903            // makes next is answered from this one, as a copy's detail read is.
11904            if item.blocked_by.is_some() {
11905                resolved.insert(item.id.clone(), item.clone());
11906            }
11907            read.push((id.clone(), Some(item)));
11908        }
11909        for (id, held) in read {
11910            ahead.insert(id, held);
11911        }
11912        Ok(())
11913    }
11914
11915    /// Add one comment to the task's issue, as the account the token belongs to.
11916    ///
11917    /// The author is refused before anything is sent — not even the task is read — because
11918    /// no answer GitHub could give would make posting under another name than the one asked
11919    /// for the right outcome.
11920    async fn add_comment(
11921        &self,
11922        task: &NativeId,
11923        comment: &NewComment,
11924    ) -> Result<Option<Comment>, SourceError> {
11925        if let Some(author) = &comment.author {
11926            return Err(SourceError::Refused {
11927                message: format!(
11928                    "source {} cannot post a comment as {author:?}: GitHub records the account \
11929                     the token signs in as the author of every comment; next: leave --author \
11930                     out, and the comment is posted as that account",
11931                    self.name
11932                ),
11933            });
11934        }
11935        let Some(issue) = self.commented_issue(task).await? else {
11936            return Ok(None);
11937        };
11938        let data = self
11939            .graphql(
11940                graphql::ADD_COMMENT,
11941                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
11942            )
11943            .await?;
11944        let subject = data
11945            .pointer("/addComment/subject")
11946            .filter(|value| !value.is_null())
11947            .ok_or_else(|| SourceError::Malformed {
11948                message: "GitHub comment addition returned no subject".into(),
11949            })?;
11950        if required_str(subject, "id")? != issue.0 {
11951            return Err(SourceError::Malformed {
11952                message: "GitHub comment addition answered about another issue".into(),
11953            });
11954        }
11955        let added = data
11956            .pointer("/addComment/commentEdge/node")
11957            .filter(|value| !value.is_null())
11958            .ok_or_else(|| SourceError::Malformed {
11959                message: "GitHub comment addition returned no comment".into(),
11960            })?;
11961        let added = comment_from(added)?;
11962        self.remember_commented(&issue)?;
11963        Ok(Some(added))
11964    }
11965
11966    async fn edit_comment(
11967        &self,
11968        task: &NativeId,
11969        comment: &NativeId,
11970        body: &CommentBody,
11971    ) -> Result<Option<Comment>, SourceError> {
11972        let Some(issue) = self.commented_issue(task).await? else {
11973            return Ok(None);
11974        };
11975        let Some(held) = self.comment_on(&issue, comment).await? else {
11976            return Ok(None);
11977        };
11978        // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
11979        let body = CommentOrigin::kept(&held, body)?;
11980        let data = self
11981            .graphql(
11982                graphql::UPDATE_COMMENT,
11983                json!({"input":{"id":comment.0,"body":body.as_str()}}),
11984            )
11985            .await?;
11986        let edited = data
11987            .pointer("/updateIssueComment/issueComment")
11988            .filter(|value| !value.is_null())
11989            .ok_or_else(|| SourceError::Malformed {
11990                message: "GitHub comment update returned no comment".into(),
11991            })?;
11992        let edited = comment_from(edited)?;
11993        if edited.id != *comment {
11994            return Err(SourceError::Malformed {
11995                message: "GitHub comment update returned the wrong comment".into(),
11996            });
11997        }
11998        self.remember_commented(&issue)?;
11999        Ok(Some(edited))
12000    }
12001
12002    async fn delete_comment(
12003        &self,
12004        task: &NativeId,
12005        comment: &NativeId,
12006    ) -> Result<Option<NativeId>, SourceError> {
12007        let Some(issue) = self.commented_issue(task).await? else {
12008            return Ok(None);
12009        };
12010        if !self.comment_is_on(&issue, comment).await? {
12011            return Ok(None);
12012        }
12013        let data = self
12014            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
12015            .await?;
12016        // The payload says nothing about the comment it removed, so what is checked is that
12017        // GitHub answered the mutation at all rather than leaving it unanswered.
12018        data.get("deleteIssueComment")
12019            .filter(|value| !value.is_null())
12020            .ok_or_else(|| SourceError::Malformed {
12021                message: "GitHub comment deletion returned no payload".into(),
12022            })?;
12023        Ok(Some(comment.clone()))
12024    }
12025
12026    /// Every request this source has recorded, and what each of GitHub's two budgets was
12027    /// attributed — read off the same accounting the session report is rendered from, so
12028    /// the two cannot count one request two ways.
12029    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
12030        Ok(Some(self.ledger.snapshot().metering()))
12031    }
12032
12033    /// Drop every item, search answer and board read this source holds, so the next command
12034    /// reads the board as a person has since left it.
12035    ///
12036    /// Every one of those is held on the assumption that nothing but this source writes the
12037    /// board while a command runs, which stops being true the moment the command is over: a
12038    /// body a person edited would be overwritten from the record held here, and a card they
12039    /// moved would be read as still where this source left it. The board's own field
12040    /// definitions go too, because a person can add or delete a `Status` option and a write
12041    /// resolved against the held list would not re-read on a miss. What stays is what stays
12042    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
12043    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
12044    /// accounting [`metering`](TaskSource::metering) answers from.
12045    ///
12046    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
12047    /// refused, because clearing it is what puts it right.
12048    async fn end_command(&self) -> Result<(), SourceError> {
12049        fn clear<T: Default>(held: &Mutex<T>) {
12050            *held
12051                .lock()
12052                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
12053            held.clear_poison();
12054        }
12055        clear(&self.created);
12056        clear(&self.updated);
12057        clear(&self.commented);
12058        clear(&self.board_cache);
12059        clear(&self.search_cache);
12060        clear(&self.narrowed_cache);
12061        clear(&self.search_next);
12062        clear(&self.resolved_cache);
12063        clear(&self.read_ahead);
12064        clear(&self.carried);
12065        clear(&self.held_cache);
12066        clear(&self.children_cache);
12067        clear(&self.fields_cache);
12068        self.expected.end_command();
12069        Ok(())
12070    }
12071}
12072
12073/// Each project's sub-issues as one command read them, keyed by the selector they were asked
12074/// for under, beside the project that selector named; see `children_cache`.
12075type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
12076
12077/// One issue comment as the contract carries it.
12078///
12079/// `author` is absent both when GitHub answers `null` for an account that no longer exists
12080/// and when it answers an actor with no login, because either way the source did not say who
12081/// wrote it — which is what an absent author means, rather than an author called nothing.
12082///
12083/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
12084/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
12085/// and is read back with the marker taken out and reported as its origin.
12086fn comment_from(value: &Value) -> Result<Comment, SourceError> {
12087    Ok(Comment {
12088        origin: None,
12089        id: NativeId(required_str(value, "id")?.to_owned()),
12090        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
12091            .map(str::to_owned),
12092        created_at: optional_time(value, "createdAt")?,
12093        updated_at: optional_time(value, "updatedAt")?,
12094        body: required_str(value, "body")?.to_owned(),
12095        url: optional_str(value, "url")?.map(str::to_owned),
12096    }
12097    .unmarked())
12098}
12099
12100/// The page of comments one issue node carries, resumed from `after`.
12101fn comment_page(
12102    node: &Value,
12103    issue: &str,
12104    after: Option<&str>,
12105) -> Result<Page<Comment>, SourceError> {
12106    let connection = node
12107        .get("comments")
12108        .filter(|value| !value.is_null())
12109        .ok_or_else(|| SourceError::Malformed {
12110            message: format!("GitHub issue {issue} answered with no comments connection"),
12111        })?;
12112    let items = optional_nodes(Some(connection), "issue comments")?
12113        .into_iter()
12114        .flatten()
12115        .map(comment_from)
12116        .collect::<Result<Vec<_>, _>>()?;
12117    let next = next_cursor(connection)?;
12118    if let Some(next) = &next {
12119        validate_cursor_progress(after, &next.0)?;
12120    }
12121    Ok(Page { items, next })
12122}
12123
12124/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
12125/// end — `None` when it carried none, or a page with more past it.
12126fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
12127    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
12128        return Ok(None);
12129    };
12130    if next_cursor(connection)?.is_some() {
12131        return Ok(None);
12132    }
12133    Ok(Some(
12134        optional_nodes(Some(connection), "blocked-by issues")?
12135            .into_iter()
12136            .flatten()
12137            .cloned()
12138            .collect(),
12139    ))
12140}
12141
12142/// Where the recorded tail of a dependency walk resumes; see
12143/// [`GitHubProjectsSource::recorded_edges`].
12144const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
12145
12146/// The board text field this source keeps a copy's origin in.
12147///
12148/// Named after the key it holds, and held to that name by the guard below rather than by
12149/// a reader noticing.
12150const ORIGIN_FIELD: &str = "onetaskgraph.origin";
12151
12152/// The metadata key that field holds.
12153///
12154/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
12155/// constructs or interprets the qualified id it carries. This source names it only to
12156/// route it — a short, typed value belongs in a typed field rather than in the body slot
12157/// a caller's own prose shares.
12158///
12159/// Restated rather than imported, because no plugin crate may depend on the engine. What
12160/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
12161/// target in `check`: it reads the engine's own literal and fails naming the file and the
12162/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
12163/// that creates a second item every run instead of finding the one it wrote — and that is
12164/// too late to learn it.
12165const ORIGIN_KEY: &str = "onetaskgraph.origin";
12166
12167/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
12168///
12169/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
12170/// is derived from the far end, never written down on the near item — so only a forward
12171/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
12172/// it did not come from, and it is told so rather than answered with an empty page that
12173/// reads as a walk which ended.
12174fn recorded_offset(
12175    cursor: Option<&str>,
12176    direction: Direction,
12177) -> Result<Option<usize>, SourceError> {
12178    cursor
12179        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
12180        .map(|offset| {
12181            if direction != Direction::DependsOn {
12182                return Err(SourceError::Config {
12183                    message: format!(
12184                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
12185                         reverse dependency read never issues; resume it in the direction \
12186                         that reported it"
12187                    ),
12188                });
12189            }
12190            offset.parse().map_err(|_| SourceError::Config {
12191                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
12192            })
12193        })
12194        .transpose()
12195}
12196
12197fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
12198    let mut page = offset_page(edges, offset, limit.max(1));
12199    page.next = page
12200        .next
12201        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
12202    page
12203}
12204
12205/// The kind of one issue reached through a dependency connection.
12206///
12207/// The same questions the board scan asks, over the fields the dependency document
12208/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
12209/// then anything with sub-issues or the marker is a project.
12210///
12211/// # Errors
12212///
12213/// A far end this board holds as a document is refused rather than reported. The two
12214/// answers that are not refusals would both be wrong: reporting it as a task names an id
12215/// no task read of this source can find, and reporting it as a project names one no
12216/// project read can. There is no third value to return — `ItemKind` has no document
12217/// variant, because nothing may point at a document — so the relationship itself is what
12218/// the person is told about.
12219fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
12220    let id = required_str(value, "id")?;
12221    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
12222        return Err(SourceError::Refused {
12223            message: format!(
12224                "GitHub issue {id} is a document of this board — its title begins \
12225                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
12226                 on by one; next: remove that issue's blocking relationship on this board"
12227            ),
12228        });
12229    }
12230    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
12231    if parent.is_some() {
12232        return Ok(ItemKind::Task);
12233    }
12234    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
12235    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
12236        message: format!("GitHub issue {id}: {message}"),
12237    })?;
12238    let sub_issues = sub_issue_total(value)?;
12239    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
12240        ItemKind::Project
12241    } else {
12242        ItemKind::Task
12243    })
12244}
12245
12246/// The `IssueStateUpdateInput` one status target asks for.
12247///
12248/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
12249/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
12250/// a currently-closed issue: without that the item would read back `Unknown` and a copy
12251/// would report a change forever. A document has no status at all, and asks for neither.
12252fn state_input(target: Option<&StatusTarget>) -> Value {
12253    match target {
12254        Some(StatusTarget::Terminal(_, reason)) => {
12255            json!({"value":"CLOSED","stateReason":reason.reason()})
12256        }
12257        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
12258        // A document has no status, so a write of one says nothing about the issue's open
12259        // or closed state rather than forcing it open: `stateInput` is what carries that
12260        // instruction, and an explicit null asks for no change to it.
12261        None => Value::Null,
12262    }
12263}
12264
12265/// The metadata one write stores in the item's body slot.
12266///
12267/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
12268/// rather than carried: the kind marker so an empty project stays readable, the
12269/// repository list only when it is not exactly the issue's own repository, and the far
12270/// ends no relationship here can name.
12271///
12272/// The copy origin is the one typed field that is also mirrored here, and only as a
12273/// mirror: it lands in the board's origin field as well, which stays the one every reader
12274/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
12275/// and catches up with a write in seconds rather than minutes — can find the item by it.
12276/// A reader of the release before this one drops the slot's copy and reads the field, so an
12277/// item written here still reads with exactly one origin there.
12278fn slot_metadata(
12279    incoming: &Incoming<'_>,
12280    own_repository: Option<&Repository>,
12281    fallback: &[DependencyEdge],
12282) -> BTreeMap<String, Value> {
12283    let mut metadata = incoming.metadata.clone();
12284    match metadata.remove(ORIGIN_KEY) {
12285        Some(Value::String(origin)) if !origin.is_empty() => {
12286            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
12287        }
12288        _ => {}
12289    }
12290    match incoming.written.kind() {
12291        BoardKind::Work(kind) => metadata.insert(
12292            ItemKind::METADATA_KEY.to_owned(),
12293            Value::String(kind.marker().to_owned()),
12294        ),
12295        // A document is told by its title, so it carries no kind marker: that key names
12296        // what a dependency endpoint points at, and nothing may point at a document.
12297        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
12298    };
12299    incoming.classification.record(&mut metadata);
12300    let derivable = own_repository
12301        .map(|own| incoming.repositories == [own.clone()])
12302        .unwrap_or(incoming.repositories.is_empty());
12303    if derivable {
12304        metadata.remove(Repository::METADATA_KEY);
12305    } else {
12306        metadata.insert(
12307            Repository::METADATA_KEY.to_owned(),
12308            Value::Array(
12309                incoming
12310                    .repositories
12311                    .iter()
12312                    .map(|repository| Value::String(repository.as_str().to_owned()))
12313                    .collect(),
12314            ),
12315        );
12316    }
12317    // The typed lists are what land, whatever the caller's own metadata held under their
12318    // keys: a key of either name travelling beside the field would otherwise be a second
12319    // answer to the same question, and the field is the one the contract names.
12320    for (key, entries) in [
12321        (TaskRef::DELIVERS_KEY, incoming.delivers),
12322        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
12323    ] {
12324        set_task_list(&mut metadata, key, entries);
12325    }
12326    record_edges(&mut metadata, fallback);
12327    metadata
12328}
12329
12330/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
12331/// one slot's metadata, or no such key when there are none.
12332fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
12333    if fallback.is_empty() {
12334        metadata.remove(DependencyEdge::RECORDED_KEY);
12335    } else {
12336        metadata.insert(
12337            DependencyEdge::RECORDED_KEY.to_owned(),
12338            Value::Array(
12339                fallback
12340                    .iter()
12341                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
12342                    .collect(),
12343            ),
12344        );
12345    }
12346}
12347
12348/// Every label one item carries, from its content's own connection and nowhere else.
12349///
12350/// There is no second place to read one from: no document this source sends selects the
12351/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
12352/// cannot carry one at all. The module documentation records the three schema facts that
12353/// settle it.
12354fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
12355    optional_nodes(content.get("labels"), "content labels")?
12356        .into_iter()
12357        .flatten()
12358        .map(|v| {
12359            Ok(Label {
12360                id: NativeId(required_str(v, "id")?.to_owned()),
12361                name: required_str(v, "name")?.to_owned(),
12362                color: optional_str(v, "color")?.map(str::to_owned),
12363            })
12364        })
12365        .collect()
12366}
12367
12368/// Each board field value one item holds, as the change token's facets name it: the field's
12369/// name, with the single-select option's name or the text. A value of a type no read here
12370/// selects, and one naming no field, says nothing a person can rename and is left out.
12371fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
12372    field_values
12373        .iter()
12374        .filter_map(|value| {
12375            let field = value.pointer("/field/name")?.as_str()?;
12376            let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
12377            Some((field.to_owned(), held.to_owned()))
12378        })
12379        .collect()
12380}
12381
12382/// The digest of the facets of the issue `content`, whose board item's values are
12383/// `field_values` — or `None` when the response did not carry, whole, every member they are read
12384/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
12385/// label, and every field value.
12386///
12387/// What a write's own response spells its token from, by exactly the rules a read takes them by.
12388fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
12389    let ended = |connection: &Value| {
12390        connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
12391    };
12392    let labels = content.get("labels").filter(|labels| ended(labels))?;
12393    if !ended(field_values) {
12394        return None;
12395    }
12396    let parent = content.get("parent")?;
12397    let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
12398    let kind = BoardKind::of(
12399        content.get("title")?.as_str()?,
12400        !parent.is_null(),
12401        sub_issue_total(content).ok()?,
12402        ItemKind::from_metadata(&slot).ok()?,
12403    );
12404    let labels = self::labels(&json!({"labels": labels})).ok()?;
12405    let nodes = field_values.get("nodes")?.as_array()?;
12406    Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
12407}
12408
12409/// The definition of each board field one item's values are values of, in the shape a read
12410/// of the board's own `fields` gives one.
12411///
12412/// A value names its field through a fragment on that field's own type, so the type is
12413/// known from which kind of value it is: a single-select value's field is a
12414/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
12415/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
12416fn field_definitions(field_values: &[Value]) -> Vec<Value> {
12417    field_values
12418        .iter()
12419        .filter_map(|value| {
12420            let field = value.get("field")?.as_object()?;
12421            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
12422            let typename = if value.get("text").is_some() {
12423                "ProjectV2Field"
12424            } else if value.get("name").is_some() {
12425                "ProjectV2SingleSelectField"
12426            } else {
12427                return None;
12428            };
12429            let mut defined = field.clone();
12430            defined.insert("__typename".to_owned(), json!(typename));
12431            // Only a text field holds a text value, so the value says the field's type too.
12432            if typename == "ProjectV2Field" {
12433                defined.insert("dataType".to_owned(), json!("TEXT"));
12434            }
12435            Some(Value::Object(defined))
12436        })
12437        .collect()
12438}
12439
12440fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
12441    let Some(node) = field_values
12442        .iter()
12443        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
12444    else {
12445        return Ok(None);
12446    };
12447    Ok(optional_str(node, "text")?.map(str::to_owned))
12448}
12449
12450fn valid_github_owner(owner: &str) -> bool {
12451    !owner.is_empty()
12452        && owner.len() <= 39
12453        && !owner.starts_with('-')
12454        && !owner.ends_with('-')
12455        && !owner.contains("--")
12456        && owner
12457            .bytes()
12458            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
12459}
12460
12461/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
12462/// neither of the two names a path segment already means.
12463fn valid_github_repository_name(name: &str) -> bool {
12464    !name.is_empty()
12465        && name.len() <= 100
12466        && name != "."
12467        && name != ".."
12468        && name
12469            .bytes()
12470            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
12471}
12472
12473fn valid_environment_name(name: &str) -> bool {
12474    let mut bytes = name.bytes();
12475    bytes
12476        .next()
12477        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
12478        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
12479}
12480
12481/// How many sub-issues one issue has.
12482///
12483/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
12484/// absent or non-integer one is a response this source cannot read — and reading it as
12485/// zero would classify a project as a task, which is exactly the mistake the marker
12486/// exists to keep from happening quietly.
12487fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
12488    let summary = issue
12489        .get("subIssuesSummary")
12490        .ok_or_else(|| SourceError::Malformed {
12491            message: "GitHub issue is missing subIssuesSummary".into(),
12492        })?;
12493    summary
12494        .get("total")
12495        .and_then(Value::as_u64)
12496        .ok_or_else(|| SourceError::Malformed {
12497            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
12498        })
12499}
12500
12501/// One issue's own `number`.
12502///
12503/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
12504/// an issue in this module asks for it. So a read of one that comes back without it, or
12505/// with something that is not an unsigned integer, is a response this source cannot read —
12506/// absence here is **not** "this issue has no number". A draft is the content that has
12507/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
12508/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
12509fn issue_number(issue: &Value) -> Result<u64, SourceError> {
12510    issue
12511        .get("number")
12512        .and_then(Value::as_u64)
12513        .ok_or_else(|| SourceError::Malformed {
12514            message: "GitHub issue number is missing or is not an unsigned integer".into(),
12515        })
12516}
12517
12518/// The `number` a creating mutation answered with, and `None` when it answered without one;
12519/// why a missing one is tolerated is at the call in `create_and_file_issue`.
12520fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
12521    match created.get("number") {
12522        None | Some(Value::Null) => Ok(None),
12523        Some(value) => value
12524            .as_u64()
12525            .map(Some)
12526            .ok_or_else(|| SourceError::Malformed {
12527                message: "GitHub created issue number is not an unsigned integer".into(),
12528            }),
12529    }
12530}
12531
12532fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
12533    value
12534        .get(field)
12535        .and_then(Value::as_str)
12536        .ok_or_else(|| SourceError::Malformed {
12537            message: format!("GitHub response is missing string field {field}"),
12538        })
12539}
12540
12541fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
12542    let found = required_str(value, field)?;
12543    if found.trim().is_empty() {
12544        return Err(SourceError::Malformed {
12545            message: format!("GitHub response has blank string field {field}"),
12546        });
12547    }
12548    Ok(found)
12549}
12550
12551/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
12552/// needs one — Linear spells them too, in its own description field.
12553///
12554/// Restated rather than shared, because a plugin crate depends on the contract crate and
12555/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
12556/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
12557/// source round-trips its own writes perfectly well under its own spelling.
12558const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
12559const METADATA_CLOSE: &str = "\n-->";
12560
12561/// What the composer puts between a non-empty visible body and the slot, and the one thing
12562/// the parser takes off the visible body when it takes the slot off — exactly once, so every
12563/// other trailing byte of the body comes back as it was written.
12564// 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.
12565const METADATA_SEPARATOR: &str = "\n\n";
12566
12567/// The visible body and the metadata slot at the end of it.
12568///
12569/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
12570/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
12571/// own content and is left alone. The visible body is everything before the slot less the
12572/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
12573fn metadata_body(
12574    body: Option<String>,
12575) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
12576    let Some(body) = body else {
12577        return Ok((None, BTreeMap::new()));
12578    };
12579    let Some(slot) = slot_span(&body)? else {
12580        return Ok((Some(body), BTreeMap::new()));
12581    };
12582    let metadata =
12583        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
12584            SourceError::Malformed {
12585                message: format!(
12586                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
12587                ),
12588            }
12589        })?;
12590    let before = &body[..slot.start];
12591    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
12592    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
12593}
12594
12595/// Where the metadata slot sits in one body, as byte offsets into it.
12596struct SlotSpan {
12597    /// Where [`METADATA_OPEN`] begins.
12598    start: usize,
12599    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
12600    encoded_start: usize,
12601    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
12602    encoded_end: usize,
12603    /// Just past [`METADATA_CLOSE`].
12604    end: usize,
12605}
12606
12607/// The slot at the very end of `body`, or `None` when it has none.
12608///
12609/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
12610/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
12611/// slot.
12612fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
12613    let Some(start) = body.rfind(METADATA_OPEN) else {
12614        return Ok(None);
12615    };
12616    let encoded_start = start + METADATA_OPEN.len();
12617    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
12618        return Err(SourceError::Malformed {
12619            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
12620        });
12621    };
12622    let encoded_end = encoded_start + relative_end;
12623    let end = encoded_end + METADATA_CLOSE.len();
12624    if !body[end..].trim().is_empty() {
12625        return Ok(None);
12626    }
12627    Ok(Some(SlotSpan {
12628        start,
12629        encoded_start,
12630        encoded_end,
12631        end,
12632    }))
12633}
12634
12635/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
12636/// slot as it was.
12637///
12638/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
12639/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
12640/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
12641/// or alone in an empty body — and a body with no slot that is given no metadata is
12642/// returned as it is.
12643fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
12644    let encoded = if metadata.is_empty() {
12645        None
12646    } else {
12647        Some(
12648            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
12649                message: error.to_string(),
12650            })?,
12651        )
12652    };
12653    Ok(match (slot_span(body)?, encoded) {
12654        (Some(slot), Some(encoded)) => format!(
12655            "{}{encoded}{}",
12656            &body[..slot.encoded_start],
12657            &body[slot.encoded_end..]
12658        ),
12659        (Some(slot), None) => {
12660            let before = &body[..slot.start];
12661            format!(
12662                "{}{}",
12663                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
12664                &body[slot.end..]
12665            )
12666        }
12667        (None, None) => body.to_owned(),
12668        (None, Some(encoded)) if body.is_empty() => {
12669            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12670        }
12671        (None, Some(encoded)) => {
12672            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12673        }
12674    })
12675}
12676
12677/// `body` with everything before its metadata slot replaced by `content`, and the slot
12678/// itself kept byte for byte.
12679///
12680/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
12681/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
12682/// `content` is empty — so a read of the result reports `content` as the visible body and
12683/// the slot's metadata exactly as it was.
12684fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
12685    let Some(slot) = slot_span(body)? else {
12686        return Ok(content.to_owned());
12687    };
12688    let kept = &body[slot.start..];
12689    Ok(if content.is_empty() {
12690        kept.to_owned()
12691    } else {
12692        format!("{content}{METADATA_SEPARATOR}{kept}")
12693    })
12694}
12695
12696/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
12697fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
12698    if entries.is_empty() {
12699        metadata.remove(key);
12700    } else {
12701        metadata.insert(
12702            key.to_owned(),
12703            Value::Array(
12704                entries
12705                    .iter()
12706                    .map(|entry| Value::String(entry.as_str().to_owned()))
12707                    .collect(),
12708            ),
12709        );
12710    }
12711}
12712
12713fn compose_body(
12714    content: Option<&str>,
12715    metadata: &BTreeMap<String, Value>,
12716) -> Result<Option<String>, SourceError> {
12717    let visible = content.unwrap_or_default();
12718    if metadata.is_empty() {
12719        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
12720    }
12721    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
12722        message: error.to_string(),
12723    })?;
12724    Ok(Some(if visible.is_empty() {
12725        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12726    } else {
12727        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12728    }))
12729}
12730
12731fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
12732    value
12733        .get(field)
12734        .and_then(Value::as_bool)
12735        .ok_or_else(|| SourceError::Malformed {
12736            message: format!("GitHub response is missing boolean field {field}"),
12737        })
12738}
12739fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
12740    match value.get(field) {
12741        None | Some(Value::Null) => Ok(None),
12742        Some(value) => value
12743            .as_str()
12744            .map(Some)
12745            .ok_or_else(|| SourceError::Malformed {
12746                message: format!("GitHub response field {field} is not a string or null"),
12747            }),
12748    }
12749}
12750fn optional_nodes<'a>(
12751    connection: Option<&'a Value>,
12752    name: &str,
12753) -> Result<Option<&'a Vec<Value>>, SourceError> {
12754    match connection {
12755        None | Some(Value::Null) => Ok(None),
12756        Some(value) => value
12757            .get("nodes")
12758            .and_then(Value::as_array)
12759            .map(Some)
12760            .ok_or_else(|| SourceError::Malformed {
12761                message: format!("GitHub {name}.nodes is not an array"),
12762            }),
12763    }
12764}
12765fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
12766    let page_info = connection
12767        .get("pageInfo")
12768        .ok_or_else(|| SourceError::Malformed {
12769            message: format!("GitHub {name} has no pageInfo"),
12770        })?;
12771    if required_bool(page_info, "hasNextPage")? {
12772        return Err(SourceError::Malformed {
12773            message: format!(
12774                "GitHub {name} exceeds the supported nested connection size of {size}"
12775            ),
12776        });
12777    }
12778    Ok(())
12779}
12780fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
12781    optional_str(value, field)?
12782        .map(|timestamp| {
12783            timestamp.parse().map_err(|error| SourceError::Malformed {
12784                message: format!("GitHub response field {field} is not a timestamp: {error}"),
12785            })
12786        })
12787        .transpose()
12788}
12789fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
12790    if page.limit == 0 {
12791        Err(SourceError::Config {
12792            message: "page limit must be at least 1".into(),
12793        })
12794    } else {
12795        Ok(())
12796    }
12797}
12798fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
12799    let page = connection
12800        .get("pageInfo")
12801        .filter(|value| value.is_object())
12802        .ok_or_else(|| SourceError::Malformed {
12803            message: "GitHub connection is missing pageInfo".into(),
12804        })?;
12805    if required_bool(page, "hasNextPage")? {
12806        let cursor = required_str(page, "endCursor")?;
12807        validate_cursor_progress(None, cursor)?;
12808        Ok(Some(Cursor(cursor.into())))
12809    } else {
12810        Ok(None)
12811    }
12812}
12813fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
12814    if next.is_empty() || previous == Some(next) {
12815        Err(SourceError::Malformed {
12816            message: "GitHub pagination cursor is empty or did not advance".into(),
12817        })
12818    } else {
12819        Ok(())
12820    }
12821}
12822/// The version of this plugin's opaque narrowing-search cursor.
12823pub const SEARCH_CURSOR_VERSION: u32 = 4;
12824
12825#[derive(Serialize, Deserialize)]
12826#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
12827enum SearchConnection {
12828    Initial {},
12829    Continuing { after: Cursor },
12830    Exhausted {},
12831}
12832impl SearchConnection {
12833    fn after(&self) -> Option<&str> {
12834        match self {
12835            Self::Continuing { after } => Some(&after.0),
12836            _ => None,
12837        }
12838    }
12839    fn exhausted(&self) -> bool {
12840        matches!(self, Self::Exhausted { .. })
12841    }
12842    /// Whether a cursor naming this position, `offset` rows into its page, is one this
12843    /// plugin could have handed out: a page is resumed only part of the way through it — an
12844    /// offset of a whole page or more would skip rows nobody was given — an initial page
12845    /// only once some of it was handed out, and an exhausted connection has no page to be
12846    /// part of the way through.
12847    fn valid_resume(&self, offset: usize) -> bool {
12848        let within = offset < SEARCH_PAGE_SIZE as usize;
12849        match self {
12850            Self::Initial { .. } => offset > 0 && within,
12851            Self::Continuing { after } => !after.0.is_empty() && within,
12852            Self::Exhausted { .. } => offset == 0,
12853        }
12854    }
12855}
12856
12857/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
12858#[derive(Serialize, Deserialize)]
12859#[serde(deny_unknown_fields)]
12860struct SearchPosition {
12861    version: u32,
12862    connection: SearchConnection,
12863    /// How many rows of the page `connection` starts were already handed out.
12864    #[serde(default, skip_serializing_if = "is_zero")]
12865    offset: usize,
12866    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12867    seen: Vec<NativeId>,
12868    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12869    own: Vec<NativeId>,
12870}
12871impl Default for SearchPosition {
12872    fn default() -> Self {
12873        Self {
12874            version: SEARCH_CURSOR_VERSION,
12875            connection: SearchConnection::Initial {},
12876            offset: 0,
12877            seen: Vec::new(),
12878            own: Vec::new(),
12879        }
12880    }
12881}
12882
12883fn is_zero(offset: &usize) -> bool {
12884    *offset == 0
12885}
12886
12887fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
12888    cursor.map_or(Ok(0), |c| {
12889        c.0.parse().map_err(|_| SourceError::Config {
12890            message: "page cursor is invalid".into(),
12891        })
12892    })
12893}
12894fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
12895    if offset > items.len() {
12896        return Page::last(vec![]);
12897    }
12898    let tail = items.split_off(offset);
12899    let mut selected = tail;
12900    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
12901    selected.truncate(limit);
12902    Page {
12903        items: selected,
12904        next,
12905    }
12906}
12907
12908impl GitHubProjectsSource {
12909    async fn write_task_assets(
12910        &self,
12911        write: &ItemWrite<Task>,
12912        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
12913    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
12914        let near = write.target.as_ref().unwrap_or(&write.item.id);
12915        for (key, entries) in [
12916            (TaskRef::DELIVERS_KEY, &write.item.delivers),
12917            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
12918        ] {
12919            TaskRef::listed(key, near, Some(&self.name), entries.clone())
12920                .map_err(|message| SourceError::Refused { message })?;
12921        }
12922        if self.priorities.is_none() && write.item.priority != Priority::None {
12923            return Err(self.holds_no_priority());
12924        }
12925        self.write_item(
12926            &Incoming {
12927                written: Written::Work(ItemKind::Task, &write.item.status),
12928                title: &write.item.title,
12929                content: write.item.content.as_deref(),
12930                assets,
12931                labels: &write.item.labels,
12932                metadata: &write.item.metadata,
12933                repositories: &write.item.repositories,
12934                classification: write.item.classification,
12935                parent: write.item.project.as_ref(),
12936                delivers: &write.item.delivers,
12937                delivered_by: &write.item.delivered_by,
12938                priority: self.priorities.as_ref().map(|_| write.item.priority),
12939            },
12940            write.target.as_ref(),
12941            &write.depends_on,
12942        )
12943        .await
12944    }
12945    async fn write_document_assets(
12946        &self,
12947        write: &ItemWrite<Document>,
12948        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
12949    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
12950        // A document takes part in no dependency graph, so there is no far end to write
12951        // natively and none to record: a caller naming one is told so rather than having it
12952        // stored under the reserved key, where a later read would report an edge the
12953        // contract says cannot exist.
12954        if !write.depends_on.is_empty() {
12955            return Err(SourceError::Refused {
12956                message: format!(
12957                    "this write names {} dependencies for a document, and a document takes \
12958                     part in no dependency graph; next: put the dependency on the task or \
12959                     project the document is about",
12960                    write.depends_on.len()
12961                ),
12962            });
12963        }
12964        self.write_item(
12965            &Incoming {
12966                written: Written::Document,
12967                title: &write.item.title,
12968                content: write.item.content.as_deref(),
12969                assets,
12970                labels: &write.item.labels,
12971                metadata: &write.item.metadata,
12972                repositories: &write.item.repositories,
12973                classification: write.item.classification,
12974                parent: write.item.project.as_ref(),
12975                delivers: &[],
12976                delivered_by: &[],
12977                priority: None,
12978            },
12979            write.target.as_ref(),
12980            &[],
12981        )
12982        .await
12983    }
12984}
12985
12986/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
12987/// itself, for the two things no journey can observe.
12988///
12989/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
12990/// with and without the call, that a settlement, a board listing and a metadata search each
12991/// read afresh after it — the resolved records, the written-item overlay, the board and its
12992/// search, and the narrowed searches. What they cannot reach is the held field definitions,
12993/// because a status write naming an option a person deleted is refused the same whether or
12994/// not the list is held, and a poisoned lock, because nothing outside the source can panic
12995/// while one of its locks is held. So these assert those directly, and every other holder
12996/// beside them so a holder added later without a clear in the call fails here.
12997#[cfg(test)]
12998mod end_command_tests {
12999    use super::*;
13000
13001    struct Token;
13002
13003    impl SecretResolver for Token {
13004        fn get(&self, var: &str) -> Option<SecretString> {
13005            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
13006        }
13007    }
13008
13009    fn source() -> GitHubProjectsSource {
13010        let config = serde_json::from_value(json!({
13011            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
13012            // Nothing here is sent: the source is only built and its state inspected.
13013            "endpoint": "http://127.0.0.1:9/graphql",
13014        }))
13015        .expect("a usable configuration");
13016        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
13017            .expect("the source builds")
13018    }
13019
13020    /// One issue as a board read answers it.
13021    fn resolved(source: &GitHubProjectsSource) -> Resolved {
13022        source
13023            .resolve(&json!({
13024                "id": "ITEM-1",
13025                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
13026                            "body": "what a person may since have edited", "state": "OPEN",
13027                            "stateReason": null, "url": null, "number": 1,
13028                            "subIssuesSummary": {"total": 0},
13029                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
13030                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
13031            }))
13032            .expect("the item reads")
13033            .expect("an issue")
13034    }
13035
13036    /// Hold something in every holder the call clears, and the repository id it keeps.
13037    fn fill(source: &GitHubProjectsSource) {
13038        let item = resolved(source);
13039        source.created.lock().unwrap().push(item.clone());
13040        source.updated.lock().unwrap().push(item.clone());
13041        *source.board_cache.lock().unwrap() = Some(Board {
13042            id: "PVT-board".into(),
13043            fields: json!({"nodes": []}),
13044            items: vec![item.clone()],
13045        });
13046        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
13047        source
13048            .narrowed_cache
13049            .lock()
13050            .unwrap()
13051            .insert("status:todo".into(), vec![item.clone()]);
13052        source.children_cache.lock().unwrap().insert(
13053            NativeId("P-1".into()),
13054            (NativeId("P-1".into()), vec![item.clone()]),
13055        );
13056        source
13057            .search_next
13058            .lock()
13059            .unwrap()
13060            .insert("status:todo".into(), Some("cursor".into()));
13061        source
13062            .resolved_cache
13063            .lock()
13064            .unwrap()
13065            .insert(item.id.clone(), item);
13066        *source.fields_cache.lock().unwrap() = Some(BoardFields {
13067            id: BoardId::parse("PVT-board").unwrap(),
13068            fields: json!({"nodes": []}),
13069        });
13070        source
13071            .repository_cache
13072            .lock()
13073            .unwrap()
13074            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
13075    }
13076
13077    fn assert_dropped(source: &GitHubProjectsSource) {
13078        assert!(source.created().unwrap().is_empty(), "created");
13079        assert!(source.updated().unwrap().is_empty(), "updated");
13080        assert!(source.board_cache().unwrap().is_none(), "board");
13081        assert!(source.search_cache.lock().unwrap().is_none(), "search");
13082        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
13083        assert!(
13084            source.children_cache.lock().unwrap().is_empty(),
13085            "project children"
13086        );
13087        assert!(
13088            source.search_next.lock().unwrap().is_empty(),
13089            "search paging"
13090        );
13091        assert!(
13092            source.resolved_cache().unwrap().is_empty(),
13093            "resolved records"
13094        );
13095        assert!(source.fields_cache().unwrap().is_none(), "board fields");
13096        assert_eq!(
13097            source.repository_cache().unwrap().len(),
13098            1,
13099            "a repository's node id stays valid and is kept"
13100        );
13101    }
13102
13103    fn end(source: &GitHubProjectsSource) {
13104        tokio::runtime::Builder::new_current_thread()
13105            .build()
13106            .unwrap()
13107            .block_on(source.end_command())
13108            .expect("the command ends");
13109    }
13110
13111    #[test]
13112    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
13113        let source = source();
13114        fill(&source);
13115        end(&source);
13116        assert_dropped(&source);
13117    }
13118
13119    #[test]
13120    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
13121        fn poison<T: Send>(held: &Mutex<T>) {
13122            std::thread::scope(|scope| {
13123                let _ = scope
13124                    .spawn(|| {
13125                        let _guard = held.lock().unwrap();
13126                        panic!("a failure while the lock is held");
13127                    })
13128                    .join();
13129            });
13130            assert!(held.is_poisoned());
13131        }
13132        let source = source();
13133        fill(&source);
13134        poison(&source.created);
13135        poison(&source.updated);
13136        poison(&source.board_cache);
13137        poison(&source.search_cache);
13138        poison(&source.narrowed_cache);
13139        poison(&source.search_next);
13140        poison(&source.resolved_cache);
13141        poison(&source.fields_cache);
13142        assert!(
13143            source.resolved_cache().is_err(),
13144            "a poisoned lock is refused before the call"
13145        );
13146        end(&source);
13147        assert_dropped(&source);
13148    }
13149}
13150
13151#[cfg(test)]
13152mod unprojectable_fields_tests {
13153    use super::*;
13154
13155    /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
13156    fn documented() -> std::collections::BTreeSet<String> {
13157        let document = std::fs::read_to_string(
13158            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
13159        )
13160        .expect("docs/metadata.md is readable");
13161        let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
13162        let lead = "a `field` equal, ignoring case, to ";
13163        let start = document.find(lead).expect("the refused-field sentence") + lead.len();
13164        let sentence = &document[start..];
13165        let sentence = &sentence[..sentence
13166            .find("; and two entries naming one field")
13167            .expect("the refused-field sentence ends at the duplicate rule")];
13168        let (own, github) = sentence
13169            .split_once(" or a field GitHub owns on every board (")
13170            .expect("the sentence names GitHub's own fields");
13171        own.split('`')
13172            .skip(1)
13173            .step_by(2)
13174            .chain(
13175                github
13176                    .strip_suffix(')')
13177                    .expect("GitHub's own fields close the sentence")
13178                    .split(", "),
13179            )
13180            .map(str::to_owned)
13181            .collect()
13182    }
13183
13184    #[test]
13185    fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
13186        let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
13187            .iter()
13188            .map(|&name| name.to_owned())
13189            .collect();
13190        assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
13191        let instance = SourceName::new("work").unwrap();
13192        for name in UNPROJECTABLE_FIELDS {
13193            for spelled in [name.to_owned(), name.to_uppercase()] {
13194                let entry = MetadataFieldConfig {
13195                    field: spelled.clone(),
13196                    key: "team.machine".to_owned(),
13197                    path: Vec::new(),
13198                };
13199                let refused = MetadataField::resolve(vec![entry], &instance)
13200                    .expect_err("an unprojectable field is refused");
13201                assert!(
13202                    refused.to_string().contains(&format!("{name:?}")),
13203                    "{spelled}: {refused}"
13204                );
13205            }
13206        }
13207    }
13208}