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// 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.
84//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
85//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
86//! option of the board's one `Status` field for a task and for a project: a bare name is
87//! the option for both kinds, `null` disables the category for both, and `{task, project}`
88//! names it per kind. A category the mapping does not mention keeps its shipped default for
89//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
90//! gets the shipped default for the kind it leaves out. Two categories one kind would read
91//! back from one option are refused as the configuration is read, ignoring case, while one
92//! option may stand for different categories of the two kinds. Writes go by the kind of the
93//! item written: a status that kind has no option for, or whose option the board lacks, is
94//! refused before any mutation, naming the source, the kind, the category and the key
95//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
96//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
97//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
98//! before selecting its option. Reads give a closed issue's reason precedence over its
99//! option, while an open issue's option decides its category through its own kind's
100//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
101//! The guarded [`GitHubProjectsSource::status_options`] and
102//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
103//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
104//! existing option id and verify the field and item assignments immediately afterwards.
105//! They ask for both kinds' options, counting a terminal category's mapped option as
106//! configured because a terminal write refuses without it. No ordinary source read or
107//! write calls that mutation, whose
108//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
109//! and a mistake destroys every item's status. A status this board cannot represent is a
110//! refusal naming the status and the instance instead.
111//!
112//! `unknown` has no shipped option because this source cannot preserve an open-ended
113//! status word: it writes an existing board option and never
114//! creates an option. An operator may map `unknown` to one existing option, in which case
115//! every unknown word lands on that option and reads back as `unknown` under the option's
116//! name. This differs from `local-md`, which writes and reads the original word itself.
117//!
118//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
119//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
120//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
121//! finished tasks were only moved to a "Done" column would read 0% complete forever.
122// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
123//!
124//! # What this source declares, field by field
125//!
126//! One verdict per field of [`Capabilities`], and what `Native` means when this source
127//! says it. *Proven* means a shared journey drives it against the real
128//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
129//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
130//! [`capabilities`](TaskSource::capabilities) from parting.
131//!
132//! | Field | Verdict |
133//! | --- | --- |
134//! | `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. |
135//! | `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. |
136//! | `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. |
137//! | `assets` | **Unsupported — unimplemented.** A copy of a record carrying an image asset into a board is refused, naming the source, the record and the asset, before anything is written for that record. Storing the bytes where the issue renders them is tracked in `docs/follow-ups.md`. |
138//! | `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. |
139//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
140//! | `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. |
141//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
142//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
143//! | `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. |
144//! | `filter_by_metadata` | **Supported, and asked of GitHub.** 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. |
145//! | `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. |
146//! | `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. |
147//! | `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. |
148//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
149//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
150//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
151//!
152//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
153//! source has documents and that its tasks have comments, both of which hold — and the three
154//! facts behind the uniform `Native` on the
155//! predicates beside it are recorded below rather than re-derived, because a reader who
156//! takes `Native` to mean *the remote service filters* will read that uniformity as a
157//! lie.
158//!
159//! First, the plugin contract defines `Support::Native` as *the source applies this
160//! predicate itself*, and says nothing about where it applies it. What the declaration
161//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
162//! — so that the engine may push it down and apply nothing of its own.
163//!
164//! Second, this source can keep that promise for every predicate at no additional API
165//! cost, because whichever of the reads below answers a query has already read every
166//! candidate that query will return before it filters anything. Filtering those items is
167//! in-process work over data already in hand.
168//!
169//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
170//! applied in process over what that question returned. A project filter has a relationship — a
171//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
172//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
173//! a search for metadata values, is the board-scoped issue search carrying the text and each
174//! value as quoted phrases; an origin is the board's own field filter over its origin field
175//! beside the same search for the id. **The text search narrows, and that is this source's
176//! declared semantics:** GitHub matches whole words where the substring rule this source and
177//! the local Markdown source confirm with would match inside one, so an item holding the text
178//! only inside a longer word is never a candidate. Every item returned does contain the text.
179//! A project query's text, and a document query's scoped to no project, is that same search
180//! and narrows on the same terms, its candidates confirmed by their kind as well.
181//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
182//! so those three are applied in process over the candidates, and a query carrying none of
183//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
184//! engine compensate for work this source has already done, and declaring `projects` native
185//! while ignoring the filter (which this source once did) silently returns another project's
186//! tasks, because the engine trusts the declaration and applies nothing locally.
187//!
188//! # The three ways this source reaches an item, and what each costs
189//!
190//! A board read is charged for what its *nested* connections could return rather than for
191//! what was asked, so one whole-board read costs the same whether the question was about
192//! one project or about all of them. That is why a question about one project is never
193//! answered by reading the board:
194//!
195//! | The question | What is sent | What it costs |
196//! | --- | --- | --- |
197//! | 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 |
198//! | 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 |
199//! | 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 |
200//! | 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 |
201//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
202//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
203//! | 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 |
204//! | 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 |
205//! | 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 |
206//! | 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 |
207//! | 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 |
208//! | 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 |
209//!
210//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
211//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
212//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
213//! equal to declared points equal to the row. They include the origin lookup and the
214//! field/repository discovery a create needs. A bound re-copy changes status, priority,
215//! content and metadata; comment recount means a subsequent detail read. Each request here
216//! costs one declared point. A membership beyond the embedded page can additionally require
217//! the one-point membership recovery described above. A bound re-copy of a task filed under a
218//! project adds one read, the engine confirming that project's link by its own id once per
219//! command; and the same-source far ends a write newly names — those that do not already block
220//! the item, whose own read answered for them — are read together by their own ids,
221//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
222//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
223//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
224//!
225//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
226//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
227//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
228//! holds both halves.
229//!
230//! **An existing item is written body last.** A bound re-copy and a `task update` send its
231//! board fields first — the `Status` option and the `Priority` together, in one request — then
232//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
233//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
234//! undoing an earlier field when a later one fails, so that order is what makes a write
235//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
236//! the one piece of metadata written before the body, an origin a copy re-points, is put back
237//! when a later write is refused — and when putting it back is refused too, the write's own
238//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
239//! of those writes in turn, whole and as one aliased field failing after the one before it.
240//!
241//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
242//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
243//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
244//!
245//! - **A board is accepted at creation but its item is not answered, so a create still files
246//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
247//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
248//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
249//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
250//!   against a real board on 2026-10-01 with a create sending the board there and reading the
251//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
252//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
253//!   refused "Content already exists in this project" — GitHub had filed the issue after
254//!   answering, and refuses a second filing rather than answering with the item it holds. A
255//!   create therefore sends no `projectV2Ids` and files the issue with
256//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
257//!   real is the read before it: the board's fields and the repository's id together, in
258//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
259//! - **A comment still reads its target first, so a comment is 2 requests.**
260//!   `AddCommentInput.subjectId: ID!` is declared there with
261//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
262//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
263//!   project's issue, a document's issue, an issue on no board of this source and a pull
264//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
265//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
266//!   refuses those by name.
267//!
268//! | Verb | Requests / points | Documents |
269//! | --- | --- | --- |
270//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
271//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
272//! | 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 |
273//! | 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 |
274//! | 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 |
275//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
276//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
277//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
278//! | recount | 1 | ISSUE_DETAIL |
279//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
280//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
281//! | content | 2 | ISSUE, UPDATE_ISSUE |
282//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
283//! | 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 |
284//! | record only | 1 | ISSUE |
285//!
286//! <!-- github-search-paging:start -->
287//! Board-scoped text, metadata, project-name and comment-activity searches send every
288//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
289//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
290//! needs rows. A page is never resized to the rows still needed: GitHub orders one
291//! search differently at different page sizes, so one fixed size makes a paged walk
292//! send exactly the requests one whole read sends, and the answer's order is the order
293//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
294//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
295//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
296//! returned and fetched pages: a limit is sliced from the pages it needs, and local
297//! confirmation can require more candidates than matching rows. Walking all pages
298//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
299//! cursor and how far into that page the last answer stopped, and resumes in the same
300//! process or a new one, without duplicates or gaps. It carries no rows: one process
301//! sends each page's search once, and a new process re-reads only the page it resumes
302//! in, then sends a further page once, never as a re-read, only when its limit still
303//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
304//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
305//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
306//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
307//! not required to include the original process's writes still omitted by the index.
308//! <!-- github-search-paging:end -->
309//!
310//! The board half of an issue — its board item's id, its `Status` option and this
311//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
312//! an item reached any of those ways resolves through the same
313//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
314//! same status, the same labels and the same qualified id. That connection comes back a
315//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
316//! on the page in hand and — only if that page reports more of the connection — in the
317//! last row's read of that one issue's memberships, resumed from the page's own cursor and
318//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
319//! report, which is what keeps an id naming another repository's issue from being answered
320//! as an item of this board; and because the page is where the search starts rather than
321//! where it ends, that answer is one about a connection read to exhaustion and never about
322//! an unread page. Nothing costs the extra read but an issue on more boards than a page
323//! holds: an issue this board really does not hold reports no next page, so its
324//! memberships are already exhausted where they arrived.
325//!
326//! **No document here selects the board's own `Labels` field, and nothing is lost by
327//! that.** An item's labels are read from its content alone, wherever that content is
328//! reached: the three documents above select `Issue.labels` on the fragment, and
329//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
330//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
331//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
332//! create one, and `ProjectV2FieldValue` — the whole of what
333//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
334//! it from the content, for every content type it exists on, and there is nothing it can
335//! hold that the content does not already say: for an `Issue` it *is* that issue's own
336//! labels, so selecting it beside them unions a set with itself.
337//!
338//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
339//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
340//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
341//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
342//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
343//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
344//! label set, and that set is the fixture issue's own, by
345//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
346//! item whose content is a draft reports an empty set, by
347//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
348//! selection is held over [`graphql::DOCUMENTS`] by
349//! `no_document_selects_the_boards_own_labels_field`.
350//!
351//! The whole-board row is still the board's own item connection, and deliberately: a
352//! **draft** board item is not an issue, so no search can list one, and the reads that have
353//! to answer for the whole board are the ones whose cost is the board's size anyway.
354//!
355//! **A question about one item this source already names by id never lists the board.**
356//! Whether that item is on this board, and what its board fields are, is answered by reading
357//! that item — its own `Issue.projectItems`, walked to exhaustion by
358//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
359//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
360//! holds. That covers a write's destination, the project a new item is filed under, a
361//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
362//! and the delete that takes back an item a copy made. What such a write needs of the board
363//! and the item does not carry — the board's id, the `Status` and origin field definitions —
364//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
365//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
366//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
367//! minutes. Scanning this host's 842-item board has refused a document copy and an update
368//! even though the items' own reads named that board. A scan there gives the wrong answer
369//! as well as paying for every page. So a `board.items` lookup does not belong on any of
370//! those paths.
371//!
372//! **What a read may return is capped too, and that cap is on the document rather than on
373//! the board.** GitHub limits the number of nodes **one query may return** to
374//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
375//! an error naming the connection the count crossed at, not a slow or a partial result.
376//! Every board this source reads is refused the same way, so no board is too big for these
377//! documents and none is small enough to save one that is over.
378//!
379//! The count is arithmetic over the document's own text: each connection contributes the
380//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
381//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
382//! restate them — `github-graphql-node-count` implements them, and
383//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
384//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
385//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
386//! same text on every run and fails naming any that reaches the limit — so a connection
387//! added to a shared fragment is caught there rather than by GitHub.
388//!
389//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
390//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
391//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
392//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
393//! effectively squared there, which is why it is the one the limit is most sensitive to.
394//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
395//! page of memberships misses is recovered by one further read rather than refused, so it
396//! buys a bound every read pays for at the price of a request only a multi-board issue
397//! pays.
398//!
399//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
400//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
401//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
402//! `cost` is rate-limit points, metered per hour across everything one credential does; it
403//! is what the two limiters [`Limiter`] tells apart meter, and a document under
404//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
405//! that second number, and `tests/point_cost.rs` pins every document in
406//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
407//! one under, the pin itself is the check. The credentialed lane reconciles both figures
408//! against GitHub's own, off a probe it already sends.
409//!
410//! **What is pinned that way is a per-document price and never a session's.** The record in
411//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
412//! **requests** and **worst-case nodes** — and neither is points. What one whole session
413//! consumes of the hourly point allowance is observable only from a credentialed run's own
414//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
415//! and what `tests/live.rs` prints at the end of every run.
416//!
417//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
418//!
419//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
420//! enumerations of a board can supply one alone.** Resolving a node id is strongly
421//! consistent, so a read by id and a project's own sub-issues are already current. The
422//! other two are not, and they are behind by different amounts and in different directions:
423//!
424//! - GitHub's **issue search** is an index and answers a write made moments ago with the
425//!   value from before it — usually for a second or two.
426//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
427//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
428//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
429//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
430//!
431//! That second one is a measurement rather than a caution. This repository's own
432//! credentialed journey writes a project and waits for the board to report it, then writes a
433//! task and waits for the same thing seconds later on the same board: the project wait is
434//! answered through the search and converged in two or three attempts in each of three runs,
435//! and the task wait is answered through `ProjectV2.items` and converged in none of them
436//! inside thirty. Separately, an item added to a second and larger board was read back by
437//! `Issue.projectItems` on that board's own id while every one of that connection's nine
438//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
439//! through the lagging one alone is what had a board read deny an issue that had certainly
440//! landed on it.
441//!
442//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
443//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
444//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
445//! search reports what the projection is behind on. What closes the last
446//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
447//! source answers is completed with what this process itself wrote, so an item created
448//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
449//! nothing is written down, and the record dies with the process. **A wait that has to
450//! observe GitHub's own data cannot be answered from that record** — which is why the
451//! credentialed journey asks through a source built afresh, and why the union above rather
452//! than a longer wait is what makes such a wait converge.
453//!
454//! **A narrowed read is the same bargain, stated for each of the three predicates it
455//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
456//! than walking the board, and every such answer is completed with what this process wrote —
457//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
458//! each filtered by the same predicates as the rest — so an item this command wrote a moment
459//! ago is returned by a query that matches it whether or not the index has caught up. An item
460//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
461//! consistent. What is left is stated rather than papered over:
462//!
463//! | Read | Finds | Behind by |
464//! | --- | --- | --- |
465//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
466//! | 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 |
467//! | 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 |
468//! | origin, third read | this process's own writes | nothing |
469//!
470//! So an origin carrier another process added within the last second or two, before either
471//! index has it, can be missing from an origin query, and one written by the release before
472//! this one — its origin in the field alone — can be missing for as long as the board's own
473//! item connection is behind on it. A copy that must not duplicate its own earlier write
474//! relies on the link it records, not on either index. **A board draft is not an issue**, so
475//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
476//! lists one, the origin lookup drops any the board's own field filter names, and one this
477//! process wrote is not added back either.
478//!
479//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
480//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
481//! body's metadata slot, so the issue search can find it in seconds. The field is
482//! authoritative: this source reads an item's origin from the field alone, so a slot that
483//! disagrees with it, or holds one where the field holds none, is never read as a second
484//! origin — and the release before this one reads the slot, drops that key's copy for the
485//! field's, and sees the same one origin.
486//!
487//! Filtering happens before paging, so a page of a filtered result is a page of the
488//! survivors rather than the survivors of a page. Label matching and the substring rule a
489//! text candidate is confirmed by answer the same question the same way the local Markdown
490//! source's do; which candidates a text search has to confirm is GitHub's word match, which
491//! is the one place the two sources can answer the same text differently.
492//!
493//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
494//! has one source, `capabilities`, and the note above is the reasoning behind it rather
495//! than a second copy of it: without the three facts recorded here a reader takes the
496//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
497//! crate's own capabilities test, which pins every field of it against a fully spelled-out
498//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
499//! fails to compile there rather than going unasserted. -->
500//! The fixture-server tests above run wherever this crate is selected; the credentialed
501//! lane runs in the same required check, beside them, and can fail it — it verifies the
502//! 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,
503//! one filed under neither, a label on one of the three and a closed status on another —
504//! because that shape is what tells an honoured predicate from an ignored one: a board
505//! holding a single project answers a project filter the same way whether or not this
506//! source applies it, which is exactly how the defect above went unseen.
507//!
508//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
509//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
510//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
511//! session or request, independently of the live demand. Fix that variable in the machine's
512//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
513//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
514//! policy crate; absent credentials or nominations otherwise skip.
515//!
516//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
517//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
518//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
519//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
520//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
521//! after its run reads back as `completed`, immediately before the listed-artifact batch.
522//! Its separate legacy pass reaches only pre-cutover machine residue in the core
523//! repository, after fully paginated non-completed `ci.yml` listings prove every run was
524//! created strictly after the stamp plus ten minutes, refreshed before each batch.
525//! Failed or incomplete ownership reads preserve residue. One 24-hour waiting period
526//! applies to both passes and delays legacy startup until cutover plus that period;
527//! it is a margin, never the ownership authorisation. The legacy limitation is that no
528//! evidence covers a development-machine run that remains alive a day after its stamp.
529//! Neither pass removes the board's `onetaskgraph.origin` field.
530//!
531//! # What a session of requests costs, and where the report is
532//!
533//! This source records **every** request it sends into [`accounting::Accounting`], at
534//! `send_once` — the one place a request leaves this crate, which is why a read path added
535//! later is counted without anybody remembering to count it. That is the whole of what this
536//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
537//! session's spend is arrived at, and what it deliberately does not know are set out.
538//!
539//! What one whole session of the live journey costs, counted that way against this crate's
540//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
541//! reduction it came out of, and with what it does and does not say about rate-limit points.
542//!
543//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
544//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
545//! code path — no environment variable, no feature, no build configuration — because an
546//! instrument nobody switches on measures nothing, and
547//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
548//! source's counts the whole session rather than this source's share. The credentialed lane
549//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
550//! passed or failed.
551//!
552//! **A live session refuses to start unless the account can afford it.** Before it does any
553//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
554//! GitHub documents as not counting against the REST rate limit and which answers both of
555//! its budgets at once — and starts only if, for each of them, what remains minus this
556//! session's estimated cost is still at least
557//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
558//! allowance. A session that cannot **declines**: it did not run, so it is
559//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
560//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
561//! waiting for the budget to come back. The estimate is derived offline from
562//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
563//! which is also where the published rule that model rests on is cited; the accounting
564//! above records the gate's own read like any other request, and
565//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
566//!
567//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
568//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
569//! text, which is what lets it run on every platform and on a pull request from a fork with
570//! no credential — and that is what actually stops a regression merging. But an offline
571//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
572//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
573//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
574//! number of nodes this query may return"* and whose `cost` is what that document would
575//! spend, both for a document **without executing it**, and the lane asks it for every query
576//! document this source sends, under the largest bindings this source sends, and fails when
577//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
578//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
579//! one; the offline pins still cover it. It records what those calls reported about the
580//! account's own allowance, because whether asking is free is a thing to observe rather than
581//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
582//! and `cost` is metered against an hourly allowance the accounting above reads off a
583//! credentialed run's own response headers.
584//!
585//! **GitHub has two rate limiters and this source is refused by both, so nothing here
586//! treats them as one thing.** The primary budget is the hourly allowance `gh api
587//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
588//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
589//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
590//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
591//! one part of.
592#![deny(missing_docs)]
593
594use std::collections::BTreeMap;
595use std::sync::{Arc, Mutex};
596use std::time::{Duration, Instant};
597
598use chrono::{DateTime, Utc};
599use onetaskgraph_plugin_api::{
600    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
601    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
602    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
603    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
604    SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
605    TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
606    UnmappedStatus, UpdatedField, WriteSupport,
607};
608use reqwest::{Client, StatusCode, Url};
609use schemars::{Schema, schema_for};
610use secrecy::{ExposeSecret, SecretString};
611use serde::{Deserialize, Serialize};
612use serde_json::{Value, json};
613
614pub mod accounting;
615
616use accounting::Accounting;
617
618/// The registry name for this plugin.
619pub const KIND: &str = "github-projects";
620/// GitHub's maximum connection page size.
621pub const MAX_PAGE_SIZE: u32 = 100;
622/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
623/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
624/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
625pub const SEARCH_PAGE_SIZE: u32 = 20;
626/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
627/// its comments: the largest batch the node-count model prices at one point.
628///
629/// Each aliased item is resolved once, and what GitHub charges for it is the connections
630/// under it — its labels, its page of board memberships, the field values of each of those
631/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
632/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
633/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
634/// one point and fails if one item more would still be priced at one.
635pub const DETAIL_BATCH: usize = 24;
636
637/// The most nodes any one document this source sends may be asked to return.
638///
639/// GitHub's own published per-query ceiling, taken from
640/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
641/// workspace cannot hold a stale copy of somebody else's number. A query above it is
642/// **refused before it is executed**, whoever is asking and whatever board they are
643/// asking about — so this is a bound on the documents rather than a budget that runs out.
644///
645/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
646/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
647/// everything the credential does — two numbers against two limits, and this constant
648/// bounds only the first. The second is computed offline too, per document:
649/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
650/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
651/// lane. There is no constant like this one to hold a price under, because points are an
652/// hourly allowance rather than a per-call bound.
653///
654/// Neither is a session's price. What `session-cost.md` records of a whole session is its
655/// **requests** and its **worst-case nodes**; what a whole session spends in points is
656/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
657/// [`accounting`]. The module section on the three ways this source reaches an item says how
658/// the count is arrived at, and which of the page sizes below decide it.
659pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
660
661/// Nested connection size for the connections that hang off one item.
662///
663/// It multiplies through every document that reaches an item under a page — the count
664/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
665/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
666/// every document under these constants and fails naming any that reaches the limit, so
667/// raising this is caught there rather than by GitHub.
668const NESTED_PAGE_SIZE: u32 = 50;
669/// How many of one issue's board memberships are read when an issue is reached directly.
670///
671/// An issue reached through a search or through its own node id carries its board half in
672/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
673/// under a page of issues, so every point of it multiplies through the whole document and
674/// is paid for whether or not any issue is on a second board — which is why it is
675/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
676///
677/// **Three, because what a page misses is now recovered rather than refused**, and the
678/// recovery is what the value is chosen against. An issue whose entry for this board sits
679/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
680/// that page's own cursor — so the value trades a bound every read pays for a request only
681/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
682/// boards would pay that request *per issue*, which is order N against the one page per
683/// hundred issues a read costs today. At three it is only reached by an issue on four or
684/// more boards at once, which keeps the recovery path exceptional rather than routine for
685/// a plausible deployment.
686const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
687/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
688/// its two connections for.
689///
690/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
691/// second is a duplicate a copy already takes the first of. Both connections are walked to
692/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
693/// never what the answer is. It is small because every point of it is paid on every lookup,
694/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
695/// worst-case nodes than the one whole-board read they replaced.
696const ORIGIN_PAGE_SIZE: u32 = 3;
697
698pub use github_graphql_node_count::{NodeCountError, Variables};
699
700/// The largest value this source can bind to each page-size variable its documents name.
701///
702/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
703/// constant above it wherever a caller's own limit could reach it — `$first` at
704/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
705/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
706/// not one configuration of it, which is what makes a bound computed under it a bound on
707/// every read.
708pub fn largest_page_sizes() -> Variables {
709    Variables::from([
710        ("first".to_owned(), MAX_PAGE_SIZE),
711        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
712        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
713        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
714    ])
715}
716
717/// The most nodes `document` could be asked to return, by GitHub's published rules.
718///
719/// Computed offline from the document's own text under [`largest_page_sizes`] — no
720/// network, no credential and no schema — by
721/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
722/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
723/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
724///
725/// # Errors
726///
727/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
728/// no single operation, or binds a page size this source does not name — each of which is
729/// a defect in the document rather than a number.
730pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
731    node_count(document, &largest_page_sizes())
732}
733
734/// The most rate-limit points one call of `document` could spend, by GitHub's published
735/// rules.
736///
737/// Computed offline from the document's own text under [`largest_page_sizes`] — no
738/// network, no credential and no schema — by
739/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
740/// This is `cost`, metered **per hour** against the allowance one credential shares across
741/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
742/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
743/// under, so what `tests/point_cost.rs` does with it is pin every document in
744/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
745/// figures against GitHub's own reported `cost`.
746///
747/// # Errors
748///
749/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
750/// no single operation, or binds a page size this source does not name — each of which is
751/// a defect in the document rather than a number.
752pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
753    github_graphql_node_count::point_cost(document, &largest_page_sizes())
754}
755
756/// The most nodes `document` could be asked to return under `variables`.
757///
758/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
759/// [`accounting`] is this under the bindings one request really sent — one spelling of the
760/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
761/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
762///
763/// # Errors
764///
765/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
766/// no single operation, or binds a page size `variables` does not name.
767pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
768    github_graphql_node_count::node_count(document, variables)
769}
770
771/// The issue-title prefix that makes a board issue a document.
772///
773/// A GitHub Projects board has no document type — it holds issues — so the discriminator
774/// is the title, and this is the whole of it: an issue whose title begins with these bytes
775/// is a document and every other issue is the task or project the sub-issue rule makes it.
776///
777/// It is spelled **once**, here, and read rather than restated everywhere else — including
778/// by the shared journeys, which take it from this constant so a board fixture cannot
779/// drift from what this source reads. `docs/metadata.md` records the two consequences that
780/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
781/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
782/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
783pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
784
785/// Exact GraphQL query documents issued by this plugin.
786///
787/// Keeping the production documents here lets the pinned-schema test validate the same
788/// bytes that are sent to GitHub, rather than a test-only copy which could drift
789/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
790/// field, and its guarded caller always supplies the complete existing option set with ids.
791pub mod graphql {
792    /// The board half of one item: the field values every document here reads it from.
793    ///
794    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
795    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
796    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
797    /// *the same value*, because
798    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
799    /// one path. Three spellings of it is what would drift, so there is one.
800    ///
801    /// The `Status` option and this source's own origin text field are the whole of it. It
802    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
803    /// content, so it holds nothing the content's own `labels` do not already say, and it
804    /// would sit a label connection two page sizes deep.
805    macro_rules! board_item_values {
806        () => {
807            r#"fieldValues(first:$nestedFirst){nodes{
808          ... on ProjectV2ItemFieldSingleSelectValue{name field{
809            ... on ProjectV2SingleSelectField{id name options{id name}}
810          }}
811          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
812        }pageInfo{hasNextPage}}"#
813        };
814    }
815
816    /// Everything this source reads about one issue, wherever it reaches that issue.
817    ///
818    /// A macro rather than a constant so the three documents below can `concat!` it: one
819    /// spelling of these fields is what makes an issue read through the board-scoped
820    /// search, through its own node id, and through its project's sub-issue relationship
821    /// resolve to *the same* item, which is the whole of what
822    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
823    ///
824    /// `projectItems` is what carries the board half of an issue: the board item's own id
825    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
826    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
827    /// issue rather than on the board, which is what makes the cost of a read proportional
828    /// to what was asked for instead of to the board's size.
829    ///
830    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
831    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
832    /// not on that page: a page here is where the search for the entry starts rather than
833    /// where it ends.
834    ///
835    /// It does **not** select the board's `Labels` field value, and that is the whole of
836    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
837    /// a label connection there sits under `fieldValues` under `projectItems` under a page
838    /// of issues, spending `$nestedFirst` twice down one path, and took
839    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
840    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
841    /// above, and that connection is where every label this source reports comes from. No
842    /// document in this module selects the board field any longer, [`BOARD`] included; the
843    /// module documentation records why nothing it could have held is lost.
844    macro_rules! board_issue {
845        () => {
846            concat!(
847                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
848      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
849      projectItems(first:$boardItems){nodes{id project{id number}
850        "#,
851                board_item_values!(),
852                r#"}pageInfo{hasNextPage endCursor}}}"#
853            )
854        };
855    }
856
857    /// Every issue of one board, found by a search scoped to that board.
858    ///
859    /// This is how the projects a board holds are listed, and it selects no `items`
860    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
861    /// container walked page by page, so nothing nested inside a board item is paid for.
862    /// Which of the issues it returns is a project is then read off `parent` — GitHub
863    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
864    /// discriminator has to be applied to the field, which is a scalar on the issue and
865    /// costs nothing.
866    pub const SEARCH_ISSUES: &str = concat!(
867        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
868      search(query:$search,type:$type,first:$first,after:$after){
869        pageInfo{hasNextPage endCursor}
870        nodes{__typename ...BoardIssue}
871      }
872    }"#,
873        board_issue!()
874    );
875
876    /// What a dependency read selects of each far end: enough to say which kind of item it
877    /// is, its body included for the kind marker.
878    macro_rules! related_issue {
879        () => {
880            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
881        };
882    }
883
884    /// One issue by its own node id, which is what a qualified id names here — with what a
885    /// write of it needs and the issue does not carry in `board_issue!`: the field
886    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
887    ///
888    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
889    /// answers a write made moments ago with the value from before it, and resolving a node
890    /// id does not.
891    ///
892    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
893    /// reads it by its own id, and with them that one read answers everything the write
894    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
895    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
896    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
897    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
898    /// they sit under one item, and this read is still one point.
899    pub const ISSUE: &str = concat!(
900        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
901      node(id:$id){__typename ...BoardIssue ... on Issue{
902        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
903          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
904          ... on ProjectV2Field{__typename id name}
905        }pageInfo{hasNextPage}}}}}
906        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
907      }}
908    }"#,
909        board_issue!(),
910        related_issue!()
911    );
912
913    /// One project's tasks: the sub-issues of the issue that project is.
914    ///
915    /// The work this costs is the project's own size. Nothing about it grows as the board
916    /// gains projects, or as those projects gain tasks.
917    pub const SUB_ISSUES: &str = concat!(
918        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
919      node(id:$id){__typename
920        ... on Issue{subIssues(first:$first,after:$after){
921          pageInfo{hasNextPage endCursor}
922          nodes{__typename ...BoardIssue}
923        }}}
924    }"#,
925        board_issue!()
926    );
927
928    /// What a read of the board's own `items` selects of each item's content.
929    ///
930    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
931    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
932    /// content by one spelling.
933    macro_rules! board_item_content {
934        () => {
935            r#" content{
936        ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
937        ... on PullRequest{__typename id}
938        ... on DraftIssue{__typename id title body createdAt updatedAt}
939      }"#
940        };
941    }
942
943    /// Reads the board's fields and one page of its items.
944    pub const BOARD: &str = concat!(
945        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
946      owner:repositoryOwner(login:$owner){
947        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
948      }
949    } fragment Board on ProjectV2 { id title
950      fields(first:$nestedFirst){nodes{
951        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
952        ... on ProjectV2Field{__typename id name}
953      }pageInfo{hasNextPage}}
954      items(first:$first,after:$after){nodes{id "#,
955        board_item_values!(),
956        board_item_content!(),
957        r#"} pageInfo{hasNextPage endCursor}}
958    }"#
959    );
960
961    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
962    /// the board.
963    ///
964    /// **`originItems`** is the board's own items narrowed by its own field filter —
965    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
966    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
967    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
968    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
969    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
970    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
971    /// fresh `addProjectV2ItemById` the way that connection does.
972    ///
973    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
974    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
975    /// indexes that comment, and the index catches up with a write in a second or two rather
976    /// than in minutes, so it finds a carrier another process wrote that the first read is
977    /// still behind on.
978    ///
979    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
980    /// — and resumes from its own cursor; a connection already walked to its end is resumed
981    /// from its last cursor, which answers an empty page. Every candidate either read returns
982    /// is confirmed against its own origin field before it is reported, so a token match of
983    /// the search or anything else the filter admits never is.
984    ///
985    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
986    /// own whole reads counts this one among them.
987    pub const ORIGIN_LOOKUP: &str = concat!(
988        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
989      originItems:repositoryOwner(login:$owner){
990        ... on ProjectV2Owner{projectV2(number:$number){
991          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
992        board_item_values!(),
993        board_item_content!(),
994        r#"} pageInfo{hasNextPage endCursor}}
995        }}
996      }
997      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
998        pageInfo{hasNextPage endCursor}
999        nodes{__typename ...BoardIssue}
1000      }
1001    }"#,
1002        board_issue!()
1003    );
1004
1005    /// The board's own id and field definitions, and not one of its items.
1006    ///
1007    /// What a write needs of the board when the item it writes does not say: the id a field
1008    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1009    /// origin fields. It selects no `items`, so what it costs is the board's field list
1010    /// however many items the board holds — and it decides nothing about which items those
1011    /// are, which is the question a read of one item by its own id answers instead.
1012    ///
1013    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1014    /// board's item reads by their root counts this one among them.
1015    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1016      boardFields:repositoryOwner(login:$owner){
1017        ... on ProjectV2Owner{projectV2(number:$number){id
1018          fields(first:$nestedFirst){nodes{
1019            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1020            ... on ProjectV2Field{__typename id name}
1021          }pageInfo{hasNextPage}}
1022        }}
1023      }
1024    }"#;
1025
1026    /// One board draft by its own node id, with the board item it sits in.
1027    ///
1028    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1029    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1030    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1031    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1032    /// board listing hands it to, and nothing has to list the board to find one.
1033    pub const DRAFT: &str = concat!(
1034        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1035      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1036        projectV2Items(first:$boardItems){nodes{id project{id number}
1037        "#,
1038        board_item_values!(),
1039        r#"}pageInfo{hasNextPage endCursor}}}}
1040    }"#
1041    );
1042
1043    /// One issue's board memberships alone, walked past the page a read of it carried.
1044    ///
1045    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1046    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1047    /// boards than that page holds may have this board's entry past its end. This asks that
1048    /// one issue for its memberships and nothing else — the caller already holds the issue —
1049    /// so an answer of "this board does not hold it" is only ever given about a connection
1050    /// read to exhaustion.
1051    ///
1052    /// It selects the board item's id, its project number and the same
1053    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1054    /// very same resolver: an issue recovered this way reports the same title, the same
1055    /// status, the same labels and the same qualified id as one whose entry was on the
1056    /// page.
1057    ///
1058    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1059    /// multiplies through it and the membership connection can be walked at
1060    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1061    /// further request for any issue a person really keeps.
1062    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1063        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1064      node(id:$id){
1065        ... on Issue{projectItems(first:$first,after:$after){
1066          nodes{id project{id number}
1067        "#,
1068        board_item_values!(),
1069        r#"}
1070          pageInfo{hasNextPage endCursor}}}
1071      }
1072    }"#
1073    );
1074    /// Resolves the configured repository's node id, which creating an issue requires.
1075    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1076    /// What creating an issue needs and has not read yet: the board's own id and field
1077    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1078    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1079    ///
1080    /// Sent at the point a create knows which repository it is for, when neither half is
1081    /// already known to this process; a create needing only one of them sends that one's own
1082    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1083    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1084    /// status.
1085    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1086      boardFields:repositoryOwner(login:$owner){
1087        ... on ProjectV2Owner{projectV2(number:$number){id
1088          fields(first:$nestedFirst){nodes{
1089            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1090            ... on ProjectV2Field{__typename id name}
1091          }pageInfo{hasNextPage}}
1092        }}
1093      }
1094      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1095    }"#;
1096    /// Reads both dependency directions for one issue, with each far end's own kind — and
1097    /// the issue's own body, which is where an edge to another source is recorded, so that
1098    /// half of a dependency read needs no second read of the issue or of the board.
1099    pub const ISSUE_DEPENDENCIES: &str = concat!(
1100        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1101      ... on Issue{body
1102        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1103        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1104      }}}"#,
1105        related_issue!()
1106    );
1107    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1108    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1109    /// answered when it was.
1110    pub const CREATE_ISSUE: &str =
1111        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1112    /// Puts an existing issue on the configured board.
1113    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1114    /// Updates an issue's visible fields and its open or closed state in one call.
1115    pub const UPDATE_ISSUE: &str =
1116        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1117    /// Updates an existing draft's user-visible fields.
1118    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1119    /// Updates a text or single-select value on one project item.
1120    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}}}}}}}}"#;
1121    /// Writes up to three board fields and an optional clear in one ordered mutation.
1122    pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeClear:Boolean!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}}}"#;
1123    /// Clears one project item's value of one field, which is what a `none` priority is.
1124    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}}}}}}}}"#;
1125    /// Creates one single-select field with its options. Only the guarded field setup may use
1126    /// this document, and only for a field the board lacks.
1127    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1128    /// Replaces a single-select field's options. Only the guarded field setup — the
1129    /// `status-options` and `fields` operations — may use this document, because GitHub
1130    /// treats the input as the complete option list.
1131    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1132    /// A fresh snapshot of the Status field and every board item's assignment.
1133    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}}}}}}"#;
1134    /// Files one issue under another as a sub-issue, which is what project membership is.
1135    pub const ADD_SUB_ISSUE: &str =
1136        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1137    /// Takes one issue back out of its parent.
1138    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1139    /// Adds GitHub's native issue blocked-by relationship.
1140    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1141    /// Removes one native issue blocked-by relationship.
1142    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1143    /// Deletes one issue, which takes its board item with it.
1144    ///
1145    /// The engine sends this in one situation only: undoing a copy that could not finish,
1146    /// over the items that same copy created. Deleting the issue removes the board item
1147    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1148    pub const DELETE_ISSUE: &str =
1149        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1150
1151    /// Everything this source reads about one issue comment, wherever it reaches one.
1152    ///
1153    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1154    /// and a comment just edited are handed to one mapper, so they are selected by one
1155    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1156    /// longer exists, and `login` is the one member every kind of actor carries.
1157    macro_rules! issue_comment {
1158        () => {
1159            "id author{login} createdAt updatedAt body url"
1160        };
1161    }
1162
1163    /// One task's comments: a page of its issue's own `comments` connection.
1164    ///
1165    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1166    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1167    /// list every time somebody edited it; left unordered the connection answers in the order
1168    /// the comments were written, which is the order GitHub documents for the same collection
1169    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1170    /// node count and the caller's own page size is pushed straight down.
1171    pub const ISSUE_COMMENTS: &str = concat!(
1172        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173        issue_comment!(),
1174        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1175    );
1176    /// One issue by its own node id, with a page of its comments: what `task show` and a
1177    /// comment listing read, in one request.
1178    ///
1179    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1180    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1181    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1182    /// connection there would multiply through both of those documents' price, and neither
1183    /// needs one.
1184    pub const ISSUE_DETAIL: &str = concat!(
1185        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1186      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1187        issue_comment!(),
1188        r#"}pageInfo{hasNextPage endCursor}}}}
1189    }"#,
1190        board_issue!()
1191    );
1192
1193    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1194    /// page of its comments when `$comments` asks for them.
1195    macro_rules! issue_details_alias {
1196        ($n:literal) => {
1197            concat!(
1198                "\n      i",
1199                stringify!($n),
1200                ":node(id:$id",
1201                stringify!($n),
1202                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1203                issue_comment!(),
1204                "}pageInfo{hasNextPage endCursor}}}}"
1205            )
1206        };
1207    }
1208
1209    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1210    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1211    ///
1212    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1213    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1214    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1215    /// neither — so every connection under it would be priced at nothing and the pin in
1216    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1217    /// one-item read the model already prices, so the batch costs what its aliases cost.
1218    ///
1219    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1220    /// slots it has no item for to the last item it does, and reads that item again; the
1221    /// price is the document's, whatever its variables, so a short batch costs what a full
1222    /// one does and nothing more.
1223    pub const ISSUE_DETAILS: &str = concat!(
1224        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!){"#,
1225        issue_details_alias!(0),
1226        issue_details_alias!(1),
1227        issue_details_alias!(2),
1228        issue_details_alias!(3),
1229        issue_details_alias!(4),
1230        issue_details_alias!(5),
1231        issue_details_alias!(6),
1232        issue_details_alias!(7),
1233        issue_details_alias!(8),
1234        issue_details_alias!(9),
1235        issue_details_alias!(10),
1236        issue_details_alias!(11),
1237        issue_details_alias!(12),
1238        issue_details_alias!(13),
1239        issue_details_alias!(14),
1240        issue_details_alias!(15),
1241        issue_details_alias!(16),
1242        issue_details_alias!(17),
1243        issue_details_alias!(18),
1244        issue_details_alias!(19),
1245        issue_details_alias!(20),
1246        issue_details_alias!(21),
1247        issue_details_alias!(22),
1248        issue_details_alias!(23),
1249        "\n    }",
1250        board_issue!()
1251    );
1252
1253    /// Which issue one comment is on, read before that comment is edited or removed.
1254    ///
1255    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1256    /// comment id given against the wrong task would change a comment on another issue.
1257    pub const COMMENT_ISSUE: &str =
1258        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1259    /// Adds one comment to an issue, signed as the account the token belongs to.
1260    pub const ADD_COMMENT: &str = concat!(
1261        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1262        issue_comment!(),
1263        r#"}}}}"#
1264    );
1265    /// Replaces the body of one issue comment.
1266    pub const UPDATE_COMMENT: &str = concat!(
1267        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1268        issue_comment!(),
1269        r#"}}}"#
1270    );
1271    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1272    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1273
1274    /// Every document above, with what this source is doing when it sends one.
1275    ///
1276    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1277    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1278    /// document added later with "talking to GitHub" and never say so.
1279    ///
1280    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1281    /// const` here that this list omits, so the two cannot part — which is the same guard
1282    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1283    pub const DOCUMENTS: [(&str, &str); 33] = [
1284        (SEARCH_ISSUES, "searching this board's issues"),
1285        (ISSUE, "reading one issue"),
1286        (
1287            ISSUE_BOARD_ITEMS,
1288            "reading one issue's board memberships past the page it came with",
1289        ),
1290        (SUB_ISSUES, "reading a project's tasks"),
1291        (BOARD, "reading the board"),
1292        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1293        (BOARD_FIELDS, "reading the board's fields"),
1294        (DRAFT, "reading one draft"),
1295        (REPOSITORY, "reading the destination repository"),
1296        (
1297            CREATION_CONTEXT,
1298            "reading the board's fields and the destination repository",
1299        ),
1300        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1301        (CREATE_ISSUE, "creating an issue"),
1302        (ADD_TO_BOARD, "adding an issue to the board"),
1303        (UPDATE_ISSUE, "updating an issue"),
1304        (UPDATE_DRAFT, "updating a draft item"),
1305        (UPDATE_FIELD, "writing a board field"),
1306        (UPDATE_FIELDS, "writing board fields together"),
1307        (CLEAR_FIELD, "clearing a board field"),
1308        (
1309            CREATE_FIELD,
1310            "creating a board single-select field with its options",
1311        ),
1312        (
1313            STATUS_OPTIONS_SNAPSHOT,
1314            "snapshotting board Status options and assignments",
1315        ),
1316        (
1317            STATUS_OPTIONS_UPDATE,
1318            "safely replacing the board Status option list",
1319        ),
1320        (ADD_SUB_ISSUE, "filing an issue under its project"),
1321        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1322        (ADD_BLOCKED_BY, "recording a dependency"),
1323        (REMOVE_BLOCKED_BY, "removing a dependency"),
1324        (DELETE_ISSUE, "deleting an issue"),
1325        (ISSUE_COMMENTS, "reading a task's comments"),
1326        (ISSUE_DETAIL, "reading one issue with its comments"),
1327        (
1328            ISSUE_DETAILS,
1329            "reading a batch of issues with their comments",
1330        ),
1331        (COMMENT_ISSUE, "reading which issue a comment is on"),
1332        (ADD_COMMENT, "adding a comment"),
1333        (UPDATE_COMMENT, "editing a comment"),
1334        (DELETE_COMMENT, "deleting a comment"),
1335    ];
1336}
1337
1338/// Which of GitHub's two rate limiters refused a request.
1339///
1340/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1341/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1342/// the whole reason this is carried rather than collapsed into "rate limited".
1343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344enum Limiter {
1345    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1346    Primary,
1347    /// The burst limiter over content-generating requests, which nothing reports.
1348    Secondary,
1349}
1350
1351/// The wordings GitHub answers a secondary rate limit with.
1352///
1353/// It sends them under a forbidden status, under a too-many-requests status, and inside
1354/// the `errors` of a *successful* response, which is why the text is what this matches on
1355/// rather than the status. `abuse detection` is the wording GitHub used before the
1356/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1357/// what a burst of content creation is refused with.
1358///
1359/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1360/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1361/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1362/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1363pub const SECONDARY_WORDINGS: [&str; 5] = [
1364    "secondary rate limit",
1365    "temporarily blocked from content creation",
1366    "abuse detection",
1367    "submitted too quickly",
1368    "exceeded a secondary",
1369];
1370
1371/// The wordings GitHub answers an exhausted primary budget with.
1372///
1373/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1374/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1375/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1376/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1377/// two phrases is a substring of it, so without it that answer read as a refusal that will
1378/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1379/// one reason.
1380pub const PRIMARY_WORDINGS: [&str; 4] = [
1381    "api rate limit exceeded",
1382    "api rate limit already exceeded",
1383    "rate limit exceeded",
1384    "rate_limited",
1385];
1386
1387/// What a response *says about itself*, which is the only place a refusal can be read.
1388///
1389/// Deliberately not the whole response body. A board is a place people write about their
1390/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1391/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1392/// reported. So the item data is never read: what is read is GitHub's own REST-style
1393/// `message` envelope, which is what a forbidden status carries, and the `message` and
1394/// `type` of each GraphQL error, which is where a *successful* response says it.
1395///
1396/// A body that is not JSON at all has nothing structured to read, so only a failing
1397/// response's own text is taken — a successful response that is not JSON is malformed
1398/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1399fn refusal_wording(status: StatusCode, body: &str) -> String {
1400    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1401        return if status.is_success() {
1402            String::new()
1403        } else {
1404            body.to_owned()
1405        };
1406    };
1407    let mut said: Vec<&str> = parsed
1408        .get("message")
1409        .and_then(Value::as_str)
1410        .into_iter()
1411        .collect();
1412    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1413        for error in errors {
1414            said.extend(
1415                ["message", "type"]
1416                    .into_iter()
1417                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1418            );
1419        }
1420    }
1421    said.join("; ")
1422}
1423
1424impl Limiter {
1425    /// Which limiter refused this response, or `None` when none of them did.
1426    ///
1427    /// The wording is read first and the status only decides what carries none of it,
1428    /// because GitHub answers a secondary limit with a forbidden status far more often
1429    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1430    /// really is a credential this token lacks.
1431    ///
1432    /// A response is a refusal because of its status or its own wording. A spent budget
1433    /// only ever explains one; it never turns an answer into a refusal.
1434    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1435        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1436        if SECONDARY_WORDINGS
1437            .iter()
1438            .any(|wording| normalized.contains(wording))
1439        {
1440            return Some(Self::Secondary);
1441        }
1442        if status == StatusCode::TOO_MANY_REQUESTS {
1443            return Some(Self::Primary);
1444        }
1445        // An exhausted budget *explains* a response that failed; it does not make one that
1446        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1447        // request the budget allowed as well as on the ones it then refuses, so reading
1448        // the header alone threw away a good answer — and, once refusals were retried,
1449        // replayed a request that had already taken effect.
1450        if !status.is_success() && budget_exhausted {
1451            return Some(Self::Primary);
1452        }
1453        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1454        // `errors` of an HTTP 200, where nothing about the status says so at all.
1455        if status.is_success()
1456            && PRIMARY_WORDINGS
1457                .iter()
1458                .any(|wording| normalized.contains(wording))
1459        {
1460            return Some(Self::Primary);
1461        }
1462        None
1463    }
1464
1465    /// What this limiter is called where an operator can look it up.
1466    const fn name(self) -> &'static str {
1467        match self {
1468            Self::Primary => "GitHub's primary API rate limit",
1469            Self::Secondary => "GitHub's secondary rate limit",
1470        }
1471    }
1472
1473    /// What the endpoint an operator would go and check says about this limiter.
1474    const fn where_to_look(self) -> &'static str {
1475        match self {
1476            Self::Primary => {
1477                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1478                 comes back."
1479            }
1480            Self::Secondary => {
1481                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1482                 primary budget and does not report this one, so budget showing there says \
1483                 nothing about this refusal, and every further attempt extends it."
1484            }
1485        }
1486    }
1487
1488    /// The next step this limiter actually calls for.
1489    const fn what_to_do(self) -> &'static str {
1490        match self {
1491            Self::Primary => {
1492                "wait for the reset `gh api rate_limit` reports, then run the command again."
1493            }
1494            Self::Secondary => {
1495                "leave this board alone for a few minutes, then run the command again — or \
1496                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1497            }
1498        }
1499    }
1500}
1501
1502/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1503#[derive(Debug, Clone, Copy)]
1504struct Limited {
1505    limiter: Limiter,
1506    hint: Option<u64>,
1507}
1508
1509impl Limited {
1510    /// What the caller is told once this source has waited as long as it may.
1511    ///
1512    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1513    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1514    /// about *which* limiter it was makes it a different kind of failure. What differs is
1515    /// the operator's next step, and that is what the message carries — a secondary
1516    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1517    /// budget looks fine, and then back to retry the very burst that was refused.
1518    fn exhausted(
1519        self,
1520        doing: &str,
1521        waits: u32,
1522        waited: Duration,
1523        needed: Duration,
1524        budget: Duration,
1525    ) -> SourceError {
1526        SourceError::RateLimited {
1527            retry_after_seconds: self.hint,
1528            message: Some(format!(
1529                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1530                 again, and the next wait of {} would take it past the {} one call may spend \
1531                 waiting. {} next: {}",
1532                self.limiter.name(),
1533                plural(waits, "refusal"),
1534                seconds(waited),
1535                seconds(needed),
1536                seconds(budget),
1537                self.limiter.where_to_look(),
1538                self.limiter.what_to_do(),
1539            )),
1540        }
1541    }
1542}
1543
1544/// One HTTP attempt's result, with what its response said about the rate limit.
1545///
1546/// The two travel together so the record and the outcome are written from the same place:
1547/// what a response said about the budget is only readable while that response is in hand,
1548/// and what the attempt *meant* is only decidable once its body has been read.
1549struct Attempted {
1550    result: Result<Value, Attempt>,
1551    limits: accounting::RateLimit,
1552    /// GitHub's own reported cost for this call, for a document that asked for it.
1553    reported_cost: Option<u64>,
1554}
1555
1556/// One attempt's outcome: an error to report, or a rate limit to wait out.
1557enum Attempt {
1558    Failed(SourceError),
1559    Limited(Limited),
1560}
1561
1562fn plural(count: u32, thing: &str) -> String {
1563    if count == 1 {
1564        format!("{count} {thing}")
1565    } else {
1566        format!("{count} {thing}s")
1567    }
1568}
1569
1570fn seconds(duration: Duration) -> String {
1571    format!("{:.1}s", duration.as_secs_f64())
1572}
1573
1574/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1575///
1576/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1577/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1578/// header, and neither is what makes a response a refusal — so the whole cost of one this
1579/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1580/// instead. Refusing the response over the header would turn a readable refusal into an
1581/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1582fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1583    value
1584        .and_then(|value| value.to_str().ok())
1585        .and_then(|value| value.trim().parse::<u64>().ok())
1586}
1587
1588/// Every mutation this source sends creates content — an issue, a board item, a field of
1589/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1590/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1591/// and what the keyword says are the same set. That is what makes the keyword a sound test
1592/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1593/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1594fn is_mutation(query: &str) -> bool {
1595    query.trim_start().starts_with("mutation")
1596}
1597
1598/// What this source was doing, for a diagnostic that has to say so.
1599///
1600/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1601/// a document added without a description is caught by that list's own gate instead of
1602/// falling through to the vague arm below.
1603fn operation_description(query: &str) -> &'static str {
1604    graphql::DOCUMENTS
1605        .iter()
1606        .find(|(document, _)| *document == query)
1607        .map_or("talking to GitHub", |(_, doing)| *doing)
1608}
1609
1610/// GitHub's published ceiling on content-generating requests, per minute.
1611///
1612/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1613/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1614/// from it, so a pacing value checked only against itself cannot go stale here.
1615pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1616/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1617/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1618/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1619pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1620/// Shortest interval between two content-creating mutations, in milliseconds.
1621///
1622/// GitHub documents two secondary limits on content-generating requests:
1623/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1624/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1625/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1626/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1627/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1628/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1629/// deliberately *not* what this paces at. An installation that wants the hourly bound
1630/// honoured for a long sequence of copies says so through
1631/// `pacing.min_mutation_interval_ms`.
1632pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1633/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1634///
1635/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1636/// own advice for a secondary limit — wait, and wait longer each time — without spending
1637/// the first minute of a transient refusal doing nothing.
1638pub const RETRY_BACKOFF_MS: u64 = 1_000;
1639/// Total time one call may spend waiting out rate limits before it reports a failure.
1640///
1641/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1642/// short enough that a command an operator is watching returns. The bound is what makes
1643/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1644/// the limiter, not in a process nobody can tell from a wedged one.
1645pub const RETRY_BUDGET_MS: u64 = 120_000;
1646
1647fn default_token_env() -> String {
1648    "GH_PROJECTS_TOKEN".to_owned()
1649}
1650fn default_endpoint() -> String {
1651    "https://api.github.com/graphql".to_owned()
1652}
1653
1654/// The name of a `Status` single-select option on the board.
1655///
1656/// Validated on the way in rather than checked later, so a blank option name — which
1657/// nothing on a board can be — is a state this type cannot hold.
1658#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1659#[serde(try_from = "String")]
1660#[schemars(extend("minLength" = 1))]
1661pub struct ColumnName(String);
1662
1663impl ColumnName {
1664    /// The option name, as the board spells it.
1665    fn as_str(&self) -> &str {
1666        &self.0
1667    }
1668}
1669
1670impl TryFrom<String> for ColumnName {
1671    type Error = String;
1672
1673    fn try_from(name: String) -> Result<Self, Self::Error> {
1674        if name.trim().is_empty() {
1675            return Err("a status_mapping option name cannot be blank".to_owned());
1676        }
1677        Ok(Self(name))
1678    }
1679}
1680
1681/// The two closed states this product can mean.
1682///
1683/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1684/// work nor abandoned work, so nothing here ever writes it.
1685#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1686#[serde(rename_all = "kebab-case")]
1687pub enum ClosedState {
1688    /// `COMPLETED` — precisely done.
1689    Completed,
1690    /// `NOT_PLANNED` — precisely cancelled.
1691    NotPlanned,
1692}
1693
1694impl ClosedState {
1695    const fn reason(self) -> &'static str {
1696        match self {
1697            Self::Completed => "COMPLETED",
1698            Self::NotPlanned => "NOT_PLANNED",
1699        }
1700    }
1701}
1702
1703/// Configuration for one GitHub Projects v2 board.
1704#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1705#[serde(default, deny_unknown_fields)]
1706pub struct GitHubProjectsConfig {
1707    /// Login of the user or organization which owns the board.
1708    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1709    /// The project number shown in the board's GitHub URL.
1710    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1711    // 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.
1712    /// `owner/name` of the repository this source creates an issue in when the item's own
1713    /// `repositories` field does not decide it.
1714    ///
1715    /// An item naming exactly one repository is created there; a task or a document naming
1716    /// none or several is created in its parent project's repository; and a project, or a
1717    /// task or document with no parent, naming none or several is created here. A board
1718    /// has no repository of its own and `createIssue` requires one, so a write without
1719    /// this is refused naming the field. Reads never need it.
1720    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1721    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1722    /// Environment variable containing a fine-grained token with Projects and Issues
1723    /// read/write plus Pull requests read-only access for every repository represented on
1724    /// the board.
1725    #[serde(default = "default_token_env")]
1726    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1727    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1728    #[serde(default = "default_endpoint")]
1729    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1730    /// Per-instance mapping from a status category to the option of the board's one
1731    /// `Status` field it lands on, for a task and for a project.
1732    ///
1733    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1734    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1735    /// where a kind left out leaves the category unmapped for that kind. A category this
1736    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1737    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1738    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1739    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1740    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1741    /// either kind. No two categories may name one option for the same kind, ignoring case.
1742    /// `unknown` may name one existing option; every unknown word then lands on it and
1743    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1744    /// each unknown word because it never creates board options.
1745    #[serde(default)]
1746    pub status_mapping: StatusMapping,
1747    /// Per-instance mapping from a task's priority to an option of this board's
1748    /// single-select field named `Priority`.
1749    ///
1750    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1751    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1752    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1753    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1754    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1755    /// no two levels may name one option. Reads and writes never create the field or an
1756    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1757    /// the board lacks is refused pointing there.
1758    #[serde(default)]
1759    pub priority_mapping: Option<PriorityMappingConfig>,
1760    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1761    ///
1762    /// Every field keeps its shipped default when it is absent, and the defaults are
1763    /// GitHub's own published limits rather than taste. See [`Pacing`].
1764    #[serde(default)]
1765    pub pacing: PacingConfig,
1766}
1767
1768/// Which option of the board's `Priority` field each priority lands on.
1769///
1770/// One member per level rather than a map, so a key that is not a level is refused where
1771/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1772/// value in the field, not an option of it.
1773#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1774#[serde(default, deny_unknown_fields)]
1775pub struct PriorityMappingConfig {
1776    /// The option `urgent` lands on; `Urgent` when absent.
1777    pub urgent: Option<PriorityOptionName>,
1778    /// The option `high` lands on; `High` when absent.
1779    pub high: Option<PriorityOptionName>,
1780    /// The option `medium` lands on; `Medium` when absent.
1781    pub medium: Option<PriorityOptionName>,
1782    /// The option `low` lands on; `Low` when absent.
1783    pub low: Option<PriorityOptionName>,
1784}
1785
1786/// The name of an option of the board's `Priority` single-select field.
1787///
1788/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1789/// blank name.
1790#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1791#[serde(try_from = "String")]
1792#[schemars(extend("minLength" = 1))]
1793pub struct PriorityOptionName(String);
1794
1795impl PriorityOptionName {
1796    /// The option name, as the board spells it.
1797    fn as_str(&self) -> &str {
1798        &self.0
1799    }
1800}
1801
1802impl TryFrom<String> for PriorityOptionName {
1803    type Error = String;
1804
1805    fn try_from(name: String) -> Result<Self, Self::Error> {
1806        if name.trim().is_empty() {
1807            return Err("a priority_mapping option name cannot be blank".to_owned());
1808        }
1809        Ok(Self(name))
1810    }
1811}
1812
1813/// The name of the board field a priority is held in.
1814pub const PRIORITY_FIELD: &str = "Priority";
1815
1816/// The four priorities a board option can hold, in the order a new `Priority` field lists
1817/// them. `none` is not among them: it is the field holding no value.
1818///
1819/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1820/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1821/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1822/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1823pub const PRIORITY_LEVELS: [Priority; 4] = [
1824    Priority::Urgent,
1825    Priority::High,
1826    Priority::Medium,
1827    Priority::Low,
1828];
1829
1830/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1831/// see that list for what this pins.
1832#[must_use]
1833pub const fn level_position(priority: Priority) -> Option<usize> {
1834    match priority {
1835        Priority::None => None,
1836        Priority::Urgent => Some(0),
1837        Priority::High => Some(1),
1838        Priority::Medium => Some(2),
1839        Priority::Low => Some(3),
1840    }
1841}
1842
1843/// This instance's complete priority-to-option mapping, read in both directions.
1844///
1845/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1846/// two levels name one option.
1847#[derive(Debug, Clone)]
1848struct PriorityMapping {
1849    options: [PriorityOptionName; 4],
1850}
1851
1852impl PriorityMapping {
1853    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1854        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1855        let mapping = Self {
1856            options: [
1857                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1858                config.high.unwrap_or_else(|| shipped("High")),
1859                config.medium.unwrap_or_else(|| shipped("Medium")),
1860                config.low.unwrap_or_else(|| shipped("Low")),
1861            ],
1862        };
1863        for (index, option) in mapping.options.iter().enumerate() {
1864            if let Some(earlier) = mapping.options[..index]
1865                .iter()
1866                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1867            {
1868                return Err(SourceError::Config {
1869                    message: format!(
1870                        "priority_mapping of source {instance} sends both {} and {} to the board \
1871                         option {:?}; one option cannot read back as two priorities",
1872                        PRIORITY_LEVELS[earlier],
1873                        PRIORITY_LEVELS[index],
1874                        option.as_str()
1875                    ),
1876                });
1877            }
1878        }
1879        Ok(mapping)
1880    }
1881
1882    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1883    fn option(&self, priority: Priority) -> Option<&str> {
1884        level_position(priority).map(|index| self.options[index].as_str())
1885    }
1886
1887    /// The priority a board option name reports, or `None` when nothing maps to it.
1888    fn priority_of(&self, option: &str) -> Option<Priority> {
1889        self.options
1890            .iter()
1891            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1892            .map(|index| PRIORITY_LEVELS[index])
1893    }
1894
1895    /// Every mapped option name, in the order a new `Priority` field lists them.
1896    fn names(&self) -> impl Iterator<Item = &str> {
1897        self.options.iter().map(PriorityOptionName::as_str)
1898    }
1899}
1900
1901/// What one item's `Priority` field says, read through this instance's mapping.
1902#[derive(Debug, Clone, PartialEq, Eq)]
1903enum HeldPriority {
1904    /// A priority this source reports: an option the mapping names, or no value (`none`).
1905    Read(Priority),
1906    /// An option the mapping does not name, which is never read as a level or as `none`.
1907    Unmapped(String),
1908}
1909
1910/// How fast this source writes, and how long it waits out a rate-limit refusal.
1911///
1912/// Configurable because a GitHub Enterprise installation sets its own limits and an
1913/// operator who has already been refused may want to go slower still — not because the
1914/// defaults are guesses.
1915#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1916#[serde(default, deny_unknown_fields)]
1917pub struct PacingConfig {
1918    /// Shortest interval between two content-creating mutations, in milliseconds.
1919    ///
1920    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1921    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1922    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.
1923    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1924    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1925    /// zero while there is a budget to spend, because a schedule of zero-length waits
1926    /// consumes none of it and so never ends.
1927    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.
1928    /// Total time one call may spend waiting out rate limits, in milliseconds.
1929    ///
1930    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1931    /// the bound is what makes this a wait rather than a hang.
1932    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.
1933}
1934
1935/// The largest any pacing setting may be, in milliseconds.
1936///
1937/// One hour. GitHub's own harshest published bound on content-generating requests works
1938/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1939/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1940/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1941/// and an interval beyond it is a command that never sends its second mutation. It also
1942/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1943/// what an `Instant` can hold on every platform.
1944pub const MAX_PACING_MS: u64 = 3_600_000;
1945
1946/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1947/// source holds.
1948#[derive(Debug, Clone, Copy)]
1949struct Pacing {
1950    min_mutation_interval: Duration,
1951    retry_backoff: Duration,
1952    retry_budget: Duration,
1953}
1954
1955impl Pacing {
1956    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1957    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1958        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1959            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1960                message: format!(
1961                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1962                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1963                     GitHub's own harshest published limit"
1964                ),
1965            }),
1966            Some(value) => Ok(Duration::from_millis(value)),
1967            None => Ok(Duration::from_millis(default)),
1968        };
1969        let retry_backoff = bounded(
1970            config.retry_backoff_ms,
1971            RETRY_BACKOFF_MS,
1972            "retry_backoff_ms",
1973        )?;
1974        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1975        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1976            return Err(SourceError::Config {
1977                message: format!(
1978                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1979                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1980                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1981                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1982                     waiting at all",
1983                    retry_budget.as_millis()
1984                ),
1985            });
1986        }
1987        Ok(Self {
1988            min_mutation_interval: bounded(
1989                config.min_mutation_interval_ms,
1990                MIN_MUTATION_INTERVAL_MS,
1991                "min_mutation_interval_ms",
1992            )?,
1993            retry_backoff,
1994            retry_budget,
1995        })
1996    }
1997}
1998
1999/// Factory for [`GitHubProjectsSource`].
2000#[derive(Debug, Clone, Copy, Default)]
2001pub struct Plugin;
2002
2003impl SourcePlugin for Plugin {
2004    fn kind(&self) -> &'static str {
2005        KIND
2006    }
2007    fn config_schema(&self) -> Schema {
2008        schema_for!(GitHubProjectsConfig)
2009    }
2010    fn build(
2011        &self,
2012        name: &SourceName,
2013        config: &Value,
2014        secrets: &dyn SecretResolver,
2015    ) -> Result<Box<dyn TaskSource>, SourceError> {
2016        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2017    }
2018}
2019
2020impl Plugin {
2021    /// Build a source recording every request it sends into an accounting the caller holds.
2022    ///
2023    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2024    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2025    /// session total rather than two — see [`accounting`] and
2026    /// [`GitHubProjectsSource::recording_into`].
2027    ///
2028    /// # Errors
2029    ///
2030    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2031    /// [`SourceError::Config`] for configuration this plugin cannot use and
2032    /// [`SourceError::Auth`] for a credential it cannot find.
2033    pub fn build_recording_into(
2034        &self,
2035        name: &SourceName,
2036        config: &Value,
2037        secrets: &dyn SecretResolver,
2038        ledger: Arc<Accounting>,
2039    ) -> Result<Box<dyn TaskSource>, SourceError> {
2040        let config: GitHubProjectsConfig =
2041            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2042                message: format!("source {name}: {e}"),
2043            })?;
2044        let prefix = format!("source {name}: ");
2045        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2046            |error| match error {
2047                // The shared `StatusMapping::distinct` names the source itself.
2048                SourceError::Config { message } if message.starts_with(&prefix) => {
2049                    SourceError::Config { message }
2050                }
2051                SourceError::Config { message } => SourceError::Config {
2052                    message: format!("{prefix}{message}"),
2053                },
2054                SourceError::Auth { message } => SourceError::Auth {
2055                    message: format!("source {name}: {message}"),
2056                },
2057                other => other,
2058            },
2059        )?;
2060        Ok(Box::new(source))
2061    }
2062}
2063
2064/// Where a status category lands on this board, once configuration is resolved.
2065#[derive(Debug, Clone, PartialEq, Eq)]
2066enum StatusTarget {
2067    /// Not usable against this instance for this kind, and why.
2068    Disabled(UnmappedStatus),
2069    /// The board's `Status` option of this name.
2070    Column(ColumnName),
2071    /// A closed issue, with both its board option and the reason that says which closed it means.
2072    // 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.
2073    Terminal(ColumnName, ClosedState),
2074}
2075
2076/// Every status category, in the order the vocabulary declares them.
2077///
2078/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2079/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2080/// added to the shared vocabulary fails to compile until it is named there, and this
2081/// crate's suite reconciles this list against that enum's own derived schema, which is
2082/// generated from the variants rather than written beside them. The schema is what
2083/// catches a list left one short — a list checking only the positions it already holds
2084/// would pass while every mapping indexed by the new position panicked.
2085pub const CATEGORIES: [StatusCategory; 8] = [
2086    StatusCategory::Draft,
2087    StatusCategory::Backlog,
2088    StatusCategory::Todo,
2089    StatusCategory::Queued,
2090    StatusCategory::InProgress,
2091    StatusCategory::Done,
2092    StatusCategory::Cancelled,
2093    StatusCategory::Unknown,
2094];
2095
2096/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2097#[must_use]
2098pub const fn category_position(category: StatusCategory) -> usize {
2099    match category {
2100        StatusCategory::Draft => 0,
2101        StatusCategory::Backlog => 1,
2102        StatusCategory::Todo => 2,
2103        StatusCategory::Queued => 3,
2104        StatusCategory::InProgress => 4,
2105        StatusCategory::Done => 5,
2106        StatusCategory::Cancelled => 6,
2107        StatusCategory::Unknown => 7,
2108    }
2109}
2110
2111/// The spelling a status category is configured and reported under.
2112fn category_name(category: StatusCategory) -> &'static str {
2113    match category {
2114        StatusCategory::Draft => "draft",
2115        StatusCategory::Backlog => "backlog",
2116        StatusCategory::Todo => "todo",
2117        StatusCategory::Queued => "queued",
2118        StatusCategory::InProgress => "in-progress",
2119        StatusCategory::Done => "done",
2120        StatusCategory::Cancelled => "cancelled",
2121        StatusCategory::Unknown => "unknown",
2122    }
2123}
2124
2125/// A shipped default's option name.
2126///
2127/// The literals below are this file's own and non-blank, and they are validated by the
2128/// one constructor a configured name goes through rather than beside it.
2129fn shipped_column(name: &'static str) -> ColumnName {
2130    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2131}
2132
2133/// The shipped default for one category this instance's `status_mapping` does not mention,
2134/// for either kind.
2135fn shipped_default(category: StatusCategory) -> StatusTarget {
2136    match category {
2137        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2138        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2139        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2140        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2141        StatusCategory::Done => {
2142            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2143        }
2144        StatusCategory::Cancelled => {
2145            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2146        }
2147        StatusCategory::Draft | StatusCategory::Unknown => {
2148            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2149        }
2150    }
2151}
2152
2153/// The two kinds a status is written and read for, each with its own half of the mapping.
2154const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2155
2156/// This instance's complete category-to-target mapping for each kind, read in both
2157/// directions.
2158///
2159/// One target per category per kind, held at that category's own [`category_position`], so
2160/// a category missing from the mapping, named twice in it, or filed out of order is a state
2161/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2162/// targets are options of the board's one `Status` field.
2163#[derive(Debug, Clone)]
2164struct BoardStatuses {
2165    tasks: [StatusTarget; CATEGORIES.len()],
2166    projects: [StatusTarget; CATEGORIES.len()],
2167}
2168
2169impl BoardStatuses {
2170    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2171    /// would read back from one option.
2172    ///
2173    /// A category the mapping does not mention keeps its shipped default for both kinds; one
2174    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2175    /// omits unmapped rather than defaulted.
2176    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2177        let resolve_kind =
2178            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2179                // `CATEGORIES[position] == category` for every category — the crate's suite
2180                // asserts it — so mapping the list in order fills each category's own slot.
2181                let mut targets = CATEGORIES.map(shipped_default);
2182                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2183                    if !configured.mentions(category) {
2184                        continue;
2185                    }
2186                    *slot = match configured.name_for(category, kind) {
2187                        Err(why) => StatusTarget::Disabled(why),
2188                        Ok(name) => {
2189                            let option = ColumnName::try_from(name.as_str().to_owned())
2190                                .map_err(|message| SourceError::Config { message })?;
2191                            match category {
2192                                StatusCategory::Done => {
2193                                    StatusTarget::Terminal(option, ClosedState::Completed)
2194                                }
2195                                StatusCategory::Cancelled => {
2196                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2197                                }
2198                                _ => StatusTarget::Column(option),
2199                            }
2200                        }
2201                    };
2202                }
2203                StatusMapping::distinct(
2204                    instance,
2205                    kind,
2206                    CATEGORIES
2207                        .iter()
2208                        .zip(&targets)
2209                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2210                )?;
2211                Ok(targets)
2212            };
2213        Ok(Self {
2214            tasks: resolve_kind(ItemKind::Task)?,
2215            projects: resolve_kind(ItemKind::Project)?,
2216        })
2217    }
2218
2219    /// Every category's target for `kind`, in category order.
2220    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2221        match kind {
2222            ItemKind::Task => &self.tasks,
2223            ItemKind::Project => &self.projects,
2224        }
2225    }
2226
2227    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2228        &self.targets(kind)[category_position(category)]
2229    }
2230
2231    /// The category a board option name reports for `kind`, or `None` when nothing of that
2232    /// kind maps to it.
2233    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2234        CATEGORIES.into_iter().find(|category| {
2235            self.target(kind, *category)
2236                .option()
2237                .is_some_and(|name| name.eq_ignore_ascii_case(option))
2238        })
2239    }
2240
2241    /// Every option name either kind maps a category to, each once ignoring case, in
2242    /// category order with a task's name before a project's — what the guarded setup asks
2243    /// the `Status` field to hold.
2244    fn wanted(&self) -> Vec<String> {
2245        let mut wanted: Vec<String> = Vec::new();
2246        for category in CATEGORIES {
2247            for kind in STATUS_KINDS {
2248                if let Some(name) = self.target(kind, category).option()
2249                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2250                {
2251                    wanted.push(name.to_owned());
2252                }
2253            }
2254        }
2255        wanted
2256    }
2257
2258    /// The status an item of `kind` reports, from the three things a read of it says: its
2259    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2260    ///
2261    /// The closed state decides the category and the `Status` option decides the name, so
2262    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2263    /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2264    /// a duplicate is not finished work, and calling it done is a lie the next copy would
2265    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2266    /// it is read permissively rather than refused — reads are faithful, and refusals
2267    /// belong on writes. An open item's option reads through its own kind's mapping, and an
2268    /// option that mapping does not name reads as `Unknown` under its own name.
2269    ///
2270    /// One function of those three rather than of a response, so a narrow status write can
2271    /// answer what a re-read would report by applying it to the state it has just written.
2272    fn status(
2273        &self,
2274        kind: ItemKind,
2275        option: Option<&str>,
2276        closed: bool,
2277        reason: Option<&str>,
2278    ) -> Status {
2279        if closed {
2280            let category = match reason {
2281                None | Some("COMPLETED") => StatusCategory::Done,
2282                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2283                Some(_) => StatusCategory::Unknown,
2284            };
2285            let fallback = match category {
2286                StatusCategory::Done => "Done",
2287                StatusCategory::Cancelled => "Cancelled",
2288                _ => "Closed",
2289            };
2290            return Status {
2291                category,
2292                name: option.unwrap_or(fallback).to_owned(),
2293            };
2294        }
2295        let name = option.unwrap_or("Open").to_owned();
2296        Status {
2297            category: self
2298                .category_of(kind, &name)
2299                .unwrap_or(StatusCategory::Unknown),
2300            name,
2301        }
2302    }
2303}
2304
2305impl BoardStatuses {
2306    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2307    /// case; a kind lacking none is left out.
2308    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2309        STATUS_KINDS
2310            .into_iter()
2311            .filter_map(|kind| {
2312                let missing: Vec<String> = self
2313                    .targets(kind)
2314                    .iter()
2315                    .filter_map(StatusTarget::option)
2316                    .filter(|wanted| {
2317                        !existing
2318                            .iter()
2319                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2320                    })
2321                    .map(str::to_owned)
2322                    .collect();
2323                (!missing.is_empty()).then_some(KindMissing { kind, missing })
2324            })
2325            .collect()
2326    }
2327}
2328
2329impl StatusTarget {
2330    /// The board option this target selects, or `None` for an unmapped one.
2331    fn option(&self) -> Option<&str> {
2332        match self {
2333            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2334            Self::Disabled(_) => None,
2335        }
2336    }
2337}
2338
2339// 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.
2340/// One repository this source can create an issue in, as `owner/name`.
2341///
2342/// Every `createIssue` this source sends names one of these: the item's own single
2343/// `repositories` entry, else its parent project issue's repository, else the configured
2344/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2345/// that choice and says what it refuses before `createIssue`.
2346// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2347#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2348struct RepositoryTarget {
2349    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2350    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2351}
2352
2353impl RepositoryTarget {
2354    fn parse(value: &str) -> Result<Self, SourceError> {
2355        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2356            message: format!(
2357                "repository must be spelled owner/name; {value:?} names no repository"
2358            ),
2359        })?;
2360        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2361            return Err(SourceError::Config {
2362                message: format!(
2363                    "repository must be spelled owner/name with a GitHub login and one \
2364                     repository name; {value:?} is not"
2365                ),
2366            });
2367        }
2368        Ok(Self {
2369            owner: owner.to_owned(),
2370            name: name.to_owned(),
2371        })
2372    }
2373
2374    /// The one host whose repositories this source creates issues in, spelled once: it is
2375    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2376    const HOST: &str = "github.com";
2377
2378    fn origin(&self) -> String {
2379        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2380    }
2381
2382    /// The repository a normalized origin names, or why it is none this source can create
2383    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2384    fn from_origin(origin: &Repository) -> Result<Self, String> {
2385        let not_here = || {
2386            format!(
2387                "{} is not a {}/owner/name repository",
2388                origin.as_str(),
2389                Self::HOST
2390            )
2391        };
2392        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2393        if host != Self::HOST {
2394            return Err(not_here());
2395        }
2396        Self::parse(rest).map_err(|_| not_here())
2397    }
2398
2399    fn slug(&self) -> String {
2400        format!("{}/{}", self.owner, self.name)
2401    }
2402}
2403
2404/// A source which reads GitHub afresh for every operation.
2405pub struct GitHubProjectsSource {
2406    /// This source's configured name, used both to tell a far end naming this source
2407    /// from one naming a system it knows nothing about, and to name the instance a
2408    /// status refusal is about.
2409    name: SourceName,
2410    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2411    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2412    repository: Option<RepositoryTarget>,
2413    endpoint: Url,
2414    token: SecretString,
2415    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2416    statuses: BoardStatuses,
2417    /// Where each priority lands on this board, or `None` when this instance holds none.
2418    priorities: Option<PriorityMapping>,
2419    client: Client,
2420    /// Every item this source has created in this command, in the order it created them —
2421    /// dropped by [`TaskSource::end_command`].
2422    ///
2423    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2424    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2425    /// a copy resolving a dependency on an item it had just created refused it as not
2426    /// found. A board read is completed from this — an item remembered here and absent from
2427    /// the read is added back, because the board really does hold it and only the read is
2428    /// behind.
2429    ///
2430    /// It is not a cache of a user's work: nothing is remembered that this process did not
2431    /// itself just write, it lives and dies with the process, and it is never consulted for
2432    /// an item this source did not create.
2433    created: Mutex<Vec<Resolved>>,
2434    /// Every item that already existed and that this source has written in this command, as
2435    /// it wrote it — dropped by [`TaskSource::end_command`].
2436    ///
2437    /// The other half of [`Self::created`], held on the same terms and for the reason a
2438    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2439    /// filter is an index behind a write this process made moments ago, so a query matching
2440    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2441    /// is remembered that this process did not itself just write.
2442    updated: Mutex<Vec<Resolved>>,
2443    /// Every issue this source has added a comment to or edited a comment of in this command
2444    /// — dropped by [`TaskSource::end_command`].
2445    ///
2446    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
2447    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
2448    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
2449    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
2450    /// from before — until the index caught up. Each id here is a candidate of every such
2451    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
2452    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
2453    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
2454    /// process wrote is still found only once the index has it.
2455    commented: Mutex<Vec<NativeId>>,
2456    /// How fast this source writes, and how long it waits out a refusal.
2457    pacing: Pacing,
2458    /// When the last content-creating mutation finished, or the moment the furthest-out
2459    /// reserved slot releases the next one, whichever is later — so the one after it can be
2460    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2461    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2462    /// what it is measured from.
2463    last_mutation: Mutex<Option<Instant>>,
2464    /// The board as this process last read it, for the length of one command — dropped by
2465    /// [`TaskSource::end_command`].
2466    ///
2467    /// A copy of a project used to re-read the whole board, paged, before writing each of
2468    /// its items, which is by far the largest part of a copy's request count and none of
2469    /// its work. Nothing else changes this board while a command runs — this source's own
2470    /// writes are the only writer — so one read answers them all.
2471    ///
2472    /// It is not a store of a user's work and it is not the cache the no-persistence
2473    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2474    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2475    /// an item this command created and then depends on resolves whether or not GitHub's
2476    /// own eventually-consistent read has caught up. A write to an item already on the
2477    /// board updates the entry here too, so what this holds is the last read plus this
2478    /// process's own writes rather than a snapshot taken before them.
2479    board_cache: Mutex<Option<Board>>,
2480    /// Every issue this board's own search reported, for the length of one command — dropped
2481    /// by [`TaskSource::end_command`].
2482    ///
2483    /// The second half of a board read, and cached for the same reason and on the same
2484    /// terms as the first: it lives and dies with the process, nothing is written down, and
2485    /// a write this process makes updates the entry here exactly as it updates the one in
2486    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2487    /// that lists this board's projects and its tasks pays for one search rather than two.
2488    search_cache: Mutex<Option<Vec<Resolved>>>,
2489    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2490    /// the length of one command — dropped by [`TaskSource::end_command`].
2491    ///
2492    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2493    /// and dies with the process, nothing is written down, a write this process makes
2494    /// updates the entry here as it updates the other two, and every answer is completed
2495    /// with this process's own writes each time it is given. A command that asks the same
2496    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2497    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2498    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2499    search_next: Mutex<BTreeMap<String, Option<String>>>,
2500    /// Records already resolved in this command, reused by writes and for comment identity.
2501    /// Explicit item reads still reach GitHub. Nothing is persisted, and
2502    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2503    /// its item as a person has since left it.
2504    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2505    /// The board's own id and field definitions as this process last read them on their
2506    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2507    ///
2508    /// What a write needs of the board and its item does not say, read once per command
2509    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2510    /// lives and dies with the process and nothing is written down. It holds no item and so
2511    /// can answer no question about one — see [`Self::board_fields`].
2512    fields_cache: Mutex<Option<BoardFields>>,
2513    /// Each destination repository's node id, resolved once per repository
2514    /// rather than per issue created.
2515    ///
2516    /// A repository's node id does not change, and re-reading it for every issue of a copy
2517    /// spent one request per item on an answer this source already had. It is a map rather
2518    /// than one entry because a copy files each item in the repository its own
2519    /// `repositories` field names, so a plan across five repositories asks GitHub five
2520    /// times and not once per item.
2521    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2522    /// What every request this source sends is recorded into.
2523    ///
2524    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2525    /// a request leaves this crate, so nothing has to be switched on for a session to be
2526    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2527    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2528    /// source's reads and writes — adds up one accounting instead of two. See
2529    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2530    ledger: Arc<Accounting>,
2531}
2532
2533/// GitHub's closed single-select color vocabulary.
2534#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2535#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2536pub enum StatusOptionColor {
2537    /// Gray.
2538    Gray,
2539    /// Blue.
2540    Blue,
2541    /// Green.
2542    Green,
2543    /// Yellow.
2544    Yellow,
2545    /// Purple.
2546    Purple,
2547    /// Red.
2548    Red,
2549    /// Orange.
2550    Orange,
2551    /// Pink.
2552    Pink,
2553}
2554
2555/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2556/// applies its additions.
2557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2558pub enum SetupMode {
2559    /// Read without mutation.
2560    Plan,
2561    /// Apply and verify.
2562    Apply,
2563}
2564
2565/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2566/// against it goes on compiling.
2567pub type StatusOptionsMode = SetupMode;
2568
2569/// The explicit result of the requested operation.
2570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2571#[serde(rename_all = "kebab-case")]
2572pub enum StatusOptionsOutcome {
2573    /// A read-only plan.
2574    Planned,
2575    /// Apply found nothing missing.
2576    Unchanged,
2577    /// Additions were applied and verified.
2578    Applied,
2579}
2580
2581/// A GitHub single-select option's opaque GraphQL node identifier.
2582#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2583#[serde(transparent)]
2584pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2585
2586impl TryFrom<String> for StatusOptionId {
2587    type Error = String;
2588
2589    fn try_from(id: String) -> Result<Self, Self::Error> {
2590        if id.trim().is_empty() {
2591            return Err("a GitHub Status option id cannot be blank".to_owned());
2592        }
2593        Ok(Self(id))
2594    }
2595}
2596
2597/// One existing or proposed option in a guarded Status-field update.
2598#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2599pub struct StatusOption {
2600    /// GitHub's stable id.
2601    pub id: StatusOptionId,
2602    /// The visible option name.
2603    pub name: ColumnName,
2604    /// GitHub's single-select color token.
2605    pub color: StatusOptionColor,
2606    /// The option description, including an empty one.
2607    pub description: String,
2608}
2609
2610/// One board item's Status assignment, retained as recovery data.
2611#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2612pub struct StatusAssignment {
2613    /// The project item id whose assignment this is.
2614    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2615    // carried verbatim as operator recovery data; introducing a semantic type would claim
2616    // validation rules GitHub does not publish and no operation here interprets.
2617    pub item_id: String,
2618    /// The selected option, absent when the item has no status.
2619    #[serde(skip_serializing_if = "Option::is_none")]
2620    pub option: Option<AssignedStatusOption>,
2621}
2622
2623/// The inseparable id and name of an assigned option.
2624#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2625pub struct AssignedStatusOption {
2626    /// GitHub's stable id.
2627    pub id: StatusOptionId,
2628    /// The visible name.
2629    pub name: ColumnName,
2630}
2631
2632/// The plan and verified outcome of reconciling configured Status options.
2633#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2634pub struct StatusOptionsReport {
2635    /// The configured source name.
2636    pub source: SourceName,
2637    /// Configured option names absent before the operation.
2638    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2639    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2640    // serialized string here preserves the report's intentionally simple public contract.
2641    pub missing: Vec<String>,
2642    /// What the requested operation did.
2643    pub outcome: StatusOptionsOutcome,
2644    /// The complete option list observed before any mutation.
2645    pub existing: Vec<StatusOption>,
2646}
2647
2648#[derive(Debug, Clone, PartialEq, Eq)]
2649struct StatusSnapshot {
2650    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2651    // passed back as the mutation's project identity; a newtype could enforce no stronger
2652    // invariant because GitHub publishes no grammar for it.
2653    board_id: String,
2654    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2655    // passed back as the mutation's field identity; a newtype could enforce no stronger
2656    // invariant because GitHub publishes no grammar for it.
2657    field_id: String,
2658    options: Vec<StatusOption>,
2659    assignments: Vec<StatusAssignment>,
2660}
2661
2662/// The name of the board field a status is held in.
2663const STATUS_FIELD: &str = "Status";
2664
2665/// Every item's value of each field `report` names, as it stood before the setup wrote
2666/// anything — what a person puts back when the setup is refused part way.
2667fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2668    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2669        .fields
2670        .iter()
2671        .map(|field| (field.field.name(), before.assignments(field.field)))
2672        .collect();
2673    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2674        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2675    })
2676}
2677
2678/// One board field the guarded setup reads and writes — every one it reads, and the only
2679/// ones it writes.
2680#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2681pub enum BoardField {
2682    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2683    Status,
2684    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2685    Priority,
2686}
2687
2688impl BoardField {
2689    /// The field's name on the board.
2690    #[must_use]
2691    pub const fn name(self) -> &'static str {
2692        match self {
2693            Self::Status => STATUS_FIELD,
2694            Self::Priority => PRIORITY_FIELD,
2695        }
2696    }
2697
2698    /// The field a board calls `name`, or `None` for one this setup does not own.
2699    fn named(name: &str) -> Option<Self> {
2700        [Self::Status, Self::Priority]
2701            .into_iter()
2702            .find(|field| field.name() == name)
2703    }
2704}
2705
2706/// What the guarded setup did to one field.
2707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2708#[serde(rename_all = "kebab-case")]
2709pub enum FieldOutcome {
2710    /// A read-only plan.
2711    Planned,
2712    /// Apply found the field there with every configured option.
2713    Unchanged,
2714    /// Missing options were added to the field that was there, and verified.
2715    Applied,
2716    /// The field was not there; it was created holding the configured options, and verified.
2717    Created,
2718}
2719
2720/// One field's plan, or its verified outcome.
2721#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2722pub struct FieldReport {
2723    /// Which field.
2724    pub field: BoardField,
2725    /// Whether the board had the field before the operation.
2726    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2727    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2728    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2729    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2730    // one constructor, and it derives `outcome` from `exists` in one match.
2731    pub exists: bool,
2732    /// Configured option names the field lacked before the operation — every one of them,
2733    /// in the order a new field lists them, when the field was not there at all.
2734    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2735    // mapping name and has therefore already passed its nonblank validation; the serialized
2736    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2737    pub missing: Vec<String>,
2738    /// For the `Status` field, which item kind each missing name is configured for: one
2739    /// entry per kind `status_mapping` names a missing option for, task before project, each
2740    /// listing that kind's missing names in category order. A name both kinds use is in
2741    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2742    /// `Priority`, which only a task holds.
2743    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2744    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2745    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2746    #[schemars(!skip_serializing_if)]
2747    pub kinds: Vec<KindMissing>,
2748    /// What the requested operation did.
2749    pub outcome: FieldOutcome,
2750    /// The field's complete option list observed before any mutation; empty when the field
2751    /// was not there.
2752    pub existing: Vec<StatusOption>,
2753}
2754
2755/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2756#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2757pub struct KindMissing {
2758    /// The kind these names are configured for.
2759    pub kind: ItemKind,
2760    /// The names that kind maps a category to and the field lacked, in category order.
2761    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2762    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2763    // intentionally simple public contract.
2764    pub missing: Vec<String>,
2765}
2766
2767/// The plan and verified outcome of setting up every field a source's configuration names.
2768#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2769pub struct FieldsReport {
2770    /// The configured source name.
2771    pub source: SourceName,
2772    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2773    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2774    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2775    // per field would change a published JSON shape. The states the list could hold and the
2776    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2777    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2778    pub fields: Vec<FieldReport>,
2779}
2780
2781/// Which options one field is configured with, in the order a new field would list them.
2782struct FieldPlan {
2783    field: BoardField,
2784    wanted: Vec<String>,
2785}
2786
2787/// One single-select field as the guarded setup snapshots it.
2788#[derive(Debug, Clone, PartialEq, Eq)]
2789struct SnapshotField {
2790    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2791    // passed back as the mutation's field identity; a newtype could enforce no stronger
2792    // invariant because GitHub publishes no grammar for it.
2793    field_id: String,
2794    options: Vec<StatusOption>,
2795}
2796
2797/// Every single-select field of a board and every item's value of each.
2798#[derive(Debug, Clone, PartialEq, Eq)]
2799struct BoardSnapshot {
2800    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2801    // passed back as the mutation's project identity; a newtype could enforce no stronger
2802    // invariant because GitHub publishes no grammar for it.
2803    board_id: String,
2804    fields: BTreeMap<BoardField, SnapshotField>,
2805    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2806    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2807}
2808
2809impl BoardSnapshot {
2810    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2811    /// carries.
2812    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2813        self.items
2814            .iter()
2815            .map(|(item_id, values)| StatusAssignment {
2816                item_id: item_id.clone(),
2817                option: values.get(&field).cloned(),
2818            })
2819            .collect()
2820    }
2821}
2822
2823impl GitHubProjectsSource {
2824    /// Report missing configured Status options and, when `apply` is true, add them with
2825    /// a whole-list mutation that preserves every existing id and verifies the result.
2826    ///
2827    /// # Errors
2828    ///
2829    /// Refuses a board without a single-select `Status` field. A post-write difference in
2830    /// any pre-existing option id or item assignment is refused with the complete pre-write
2831    /// assignment snapshot in the diagnostic for recovery.
2832    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2833    // successful mutation, both drift refusals, source selection, missing Status, casing,
2834    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2835    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2836    // responses from entering the defensive malformed-response branches below.
2837    pub async fn status_options(
2838        &self,
2839        mode: StatusOptionsMode,
2840    ) -> Result<StatusOptionsReport, SourceError> {
2841        let before = self.status_snapshot().await?;
2842        // A terminal category's option is as configured as an open one's: a terminal
2843        // write validates it before closing and refuses when the board lacks it. Both
2844        // kinds' names are options of the one field, so both are asked for.
2845        let missing = self
2846            .statuses
2847            .wanted()
2848            .into_iter()
2849            .filter(|wanted| {
2850                !before
2851                    .options
2852                    .iter()
2853                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2854            })
2855            .collect::<Vec<_>>();
2856        let report = StatusOptionsReport {
2857            source: self.name.clone(),
2858            missing: missing.clone(),
2859            outcome: match (mode, missing.is_empty()) {
2860                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2861                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2862                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2863            },
2864            existing: before.options.clone(),
2865        };
2866        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2867            return Ok(report);
2868        }
2869        let mut options = before
2870            .options
2871            .iter()
2872            .map(|option| {
2873                json!({
2874                    "id": option.id, "name": option.name, "color": option.color,
2875                    "description": option.description,
2876                })
2877            })
2878            .collect::<Vec<_>>();
2879        options.extend(missing.iter().map(|name| {
2880            json!({
2881                "name": name, "color": "GRAY", "description": ""
2882            })
2883        }));
2884        self.graphql(
2885            graphql::STATUS_OPTIONS_UPDATE,
2886            json!({"input": {
2887                "projectId": before.board_id, "fieldId": before.field_id,
2888                "singleSelectOptions": options,
2889            }}),
2890        )
2891        .await?;
2892        let after = self.status_snapshot().await?;
2893        let options_preserved = before
2894            .options
2895            .iter()
2896            .all(|old| after.options.iter().any(|new| new == old));
2897        let additions_present = missing.iter().all(|wanted| {
2898            after
2899                .options
2900                .iter()
2901                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2902        });
2903        if !options_preserved || !additions_present || after.assignments != before.assignments {
2904            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2905                SourceError::Malformed {
2906                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2907                }
2908            })?;
2909            return Err(SourceError::Refused {
2910                message: format!(
2911                    "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}"
2912                ),
2913            });
2914        }
2915        Ok(report)
2916    }
2917
2918    /// A fresh snapshot of the Status field and every board item's assignment of it.
2919    ///
2920    /// # Errors
2921    ///
2922    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2923    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2924        // Status alone, as this operation has always read it: a `Priority` field is another
2925        // operation's, so nothing about it can refuse this one.
2926        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2927        let field = board
2928            .fields
2929            .remove(&BoardField::Status)
2930            .ok_or_else(|| self.no_status_field())?;
2931        Ok(StatusSnapshot {
2932            assignments: board.assignments(BoardField::Status),
2933            board_id: board.board_id,
2934            field_id: field.field_id,
2935            options: field.options,
2936        })
2937    }
2938
2939    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2940    fn no_status_field(&self) -> SourceError {
2941        SourceError::Refused {
2942            message: format!("source {} board has no Status field", self.name),
2943        }
2944    }
2945
2946    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2947    // the real CLI loopback journey, including pagination. The individual malformed guards
2948    // are defensive validation of a schema-pinned third-party response, not separate user
2949    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2950    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2951    /// every board item's value of each, walked to the end of the board's items. A field not
2952    /// in `owned` is read past whatever it holds.
2953    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2954        let mut after: Option<String> = None;
2955        let mut snapshot: Option<BoardSnapshot> = None;
2956        loop {
2957            let data = self
2958                .graphql(
2959                    graphql::STATUS_OPTIONS_SNAPSHOT,
2960                    json!({
2961                        "owner": self.owner, "number": self.project_number,
2962                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2963                    }),
2964                )
2965                .await?;
2966            let board = data
2967                .pointer("/owner/projectV2")
2968                .filter(|board| board.is_object())
2969                .ok_or_else(|| SourceError::Refused {
2970                    message: format!(
2971                        "source {} has no accessible GitHub Projects board",
2972                        self.name
2973                    ),
2974                })?;
2975            if board
2976                .pointer("/fields/pageInfo/hasNextPage")
2977                .and_then(Value::as_bool)
2978                != Some(false)
2979            {
2980                return Err(SourceError::Malformed {
2981                    message:
2982                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2983                            .into(),
2984                });
2985            }
2986            let mut fields = BTreeMap::new();
2987            // Only the fields this setup owns, by name: a node the single-select fragment did not
2988            // match carries no name, and a person's own single-select field — a `Size`, a
2989            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2990            // `Status` or `Priority` field without its options is malformed, not absent.
2991            // 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.
2992            for (owned, field) in board
2993                .pointer("/fields/nodes")
2994                .and_then(Value::as_array)
2995                .ok_or_else(|| SourceError::Malformed {
2996                    message: "GitHub project fields.nodes is not an array".into(),
2997                })?
2998                .iter()
2999                .filter_map(|field| {
3000                    let named = BoardField::named(field.get("name")?.as_str()?)?;
3001                    owned.contains(&named).then_some((named, field))
3002                })
3003            {
3004                let options = field
3005                    .get("options")
3006                    .and_then(Value::as_array)
3007                    .ok_or_else(|| SourceError::Malformed {
3008                        message: "GitHub single-select field options is not an array".into(),
3009                    })?
3010                    .iter()
3011                    .map(|option| {
3012                        Ok(StatusOption {
3013                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3014                                .map_err(|message| SourceError::Malformed { message })?,
3015                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3016                                .map_err(|message| SourceError::Malformed {
3017                                    message: format!(
3018                                        "GitHub single-select option name is invalid: {message}"
3019                                    ),
3020                                })?,
3021                            color: serde_json::from_value(
3022                                option.get("color").cloned().unwrap_or(Value::Null),
3023                            )
3024                            .map_err(|error| {
3025                                SourceError::Malformed {
3026                                    message: format!(
3027                                        "GitHub single-select option color is invalid: {error}"
3028                                    ),
3029                                }
3030                            })?,
3031                            description: optional_str(option, "description")?
3032                                .unwrap_or_default()
3033                                .to_owned(),
3034                        })
3035                    })
3036                    .collect::<Result<Vec<_>, SourceError>>()?;
3037                let snapshot = SnapshotField {
3038                    field_id: required_nonblank_str(field, "id")?.to_owned(),
3039                    options,
3040                };
3041                // A board's field names are unique, so a second one is an answer that cannot
3042                // say which field the setup would act on — refused rather than one chosen.
3043                if fields.insert(owned, snapshot).is_some() {
3044                    return Err(SourceError::Malformed {
3045                        message: format!(
3046                            "GitHub answered two {} fields for this board",
3047                            owned.name()
3048                        ),
3049                    });
3050                }
3051            }
3052            let board_id = required_nonblank_str(board, "id")?.to_owned();
3053            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3054                board_id,
3055                fields,
3056                items: Vec::new(),
3057            });
3058            let items = board
3059                .pointer("/items/nodes")
3060                .and_then(Value::as_array)
3061                .ok_or_else(|| SourceError::Malformed {
3062                    message: "GitHub project items.nodes is not an array".into(),
3063                })?;
3064            for item in items {
3065                let field_values =
3066                    item.get("fieldValues")
3067                        .ok_or_else(|| SourceError::Malformed {
3068                            message: "GitHub project item is missing fieldValues".into(),
3069                        })?;
3070                if field_values
3071                    .pointer("/pageInfo/hasNextPage")
3072                    .and_then(Value::as_bool)
3073                    != Some(false)
3074                {
3075                    return Err(SourceError::Malformed {
3076                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3077                    });
3078                }
3079                let values = item
3080                    .pointer("/fieldValues/nodes")
3081                    .and_then(Value::as_array)
3082                    .ok_or_else(|| SourceError::Malformed {
3083                        message: "GitHub project item fieldValues.nodes is not an array".into(),
3084                    })?;
3085                let item_id = required_nonblank_str(item, "id")?;
3086                let mut assigned = BTreeMap::new();
3087                for value in values {
3088                    let Some(field) = value
3089                        .pointer("/field/name")
3090                        .and_then(Value::as_str)
3091                        .and_then(BoardField::named)
3092                        .filter(|field| owned.contains(field))
3093                    else {
3094                        continue;
3095                    };
3096                    let held = assigned.insert(
3097                        field,
3098                        AssignedStatusOption {
3099                            id: StatusOptionId::try_from(
3100                                required_str(value, "optionId")?.to_owned(),
3101                            )
3102                            .map_err(|message| SourceError::Malformed { message })?,
3103                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3104                                .map_err(|message| SourceError::Malformed {
3105                                    message: format!(
3106                                        "GitHub assigned {} name is invalid: {message}",
3107                                        field.name()
3108                                    ),
3109                                })?,
3110                        },
3111                    );
3112                    // An item holds one value of a field, so a second one leaves no way to
3113                    // tell which it holds — and a verification or recovery built on either
3114                    // could restore the wrong one.
3115                    if held.is_some() {
3116                        return Err(SourceError::Malformed {
3117                            message: format!(
3118                                "GitHub answered two {} values for board item {item_id}",
3119                                field.name()
3120                            ),
3121                        });
3122                    }
3123                }
3124                current.items.push((item_id.to_owned(), assigned));
3125            }
3126            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3127                message: "GitHub project is missing items".into(),
3128            })?;
3129            let has_next = page
3130                .pointer("/pageInfo/hasNextPage")
3131                .and_then(Value::as_bool)
3132                .ok_or_else(|| SourceError::Malformed {
3133                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3134                })?;
3135            if !has_next {
3136                break;
3137            }
3138            let next =
3139                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3140            validate_cursor_progress(after.as_deref(), next)?;
3141            after = Some(next.to_owned());
3142        }
3143        snapshot.ok_or_else(|| SourceError::Malformed {
3144            message: "GitHub returned no board field snapshot".into(),
3145        })
3146    }
3147    // llmlint: ignore-end[changed_behavior_has_e2e]
3148
3149    /// Report every board field this source's configuration names and, with
3150    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3151    /// the `Priority` field when the board has none.
3152    ///
3153    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3154    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3155    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3156    /// color and description: the whole option list goes back with every existing id, because
3157    /// a re-minted id clears every item's value.
3158    ///
3159    /// # Errors
3160    ///
3161    /// Refuses a board without a single-select `Status` field. After an apply the board is
3162    /// read again, and a pre-existing option or any item's value of either field that moved is
3163    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3164    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3165    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3166    // with no Status field and a non-github-projects source through the compiled CLI against
3167    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3168    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3169        let owned: Vec<BoardField> = if self.priorities.is_some() {
3170            vec![BoardField::Status, BoardField::Priority]
3171        } else {
3172            vec![BoardField::Status]
3173        };
3174        let before = self.board_snapshot(&owned).await?;
3175        let mut plans = vec![FieldPlan {
3176            field: BoardField::Status,
3177            wanted: self.statuses.wanted(),
3178        }];
3179        if !before.fields.contains_key(&BoardField::Status) {
3180            return Err(self.no_status_field());
3181        }
3182        if let Some(mapping) = &self.priorities {
3183            plans.push(FieldPlan {
3184                field: BoardField::Priority,
3185                wanted: mapping.names().map(str::to_owned).collect(),
3186            });
3187        }
3188        // The snapshot reads single-select fields alone, so a field it did not find may still
3189        // be on the board under the name, of another type: creating one beside it would fail
3190        // part way, or leave two fields of one name. Asked of the board's own field list, and
3191        // only when a field is missing.
3192        if plans
3193            .iter()
3194            .any(|plan| !before.fields.contains_key(&plan.field))
3195        {
3196            let board = self.board_fields().await?;
3197            for plan in plans
3198                .iter()
3199                .filter(|plan| !before.fields.contains_key(&plan.field))
3200            {
3201                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3202                    return Err(SourceError::Refused {
3203                        message: format!(
3204                            "source {}'s board has a {} field that is not a single-select field \
3205                             (it is a {}), so it cannot hold this source's options; next: rename \
3206                             or remove that field, then run this again",
3207                            self.name,
3208                            plan.field.name(),
3209                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3210                        ),
3211                    });
3212                }
3213            }
3214        }
3215        let mut reports = Vec::new();
3216        for plan in &plans {
3217            let held = before.fields.get(&plan.field);
3218            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3219            let mut missing: Vec<String> = Vec::new();
3220            for wanted in &plan.wanted {
3221                let present = existing
3222                    .iter()
3223                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3224                    || missing
3225                        .iter()
3226                        .any(|named| named.eq_ignore_ascii_case(wanted));
3227                if !present {
3228                    missing.push(wanted.clone());
3229                }
3230            }
3231            let kinds = match plan.field {
3232                BoardField::Status => self.statuses.missing_by_kind(&existing),
3233                BoardField::Priority => Vec::new(),
3234            };
3235            reports.push(FieldReport {
3236                field: plan.field,
3237                exists: held.is_some(),
3238                kinds,
3239                outcome: match (mode, held.is_some(), missing.is_empty()) {
3240                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3241                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3242                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3243                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3244                },
3245                missing,
3246                existing,
3247            });
3248        }
3249        let report = FieldsReport {
3250            source: self.name.clone(),
3251            fields: reports,
3252        };
3253        let writes: Vec<&FieldReport> = report
3254            .fields
3255            .iter()
3256            .filter(|field| !field.missing.is_empty() || !field.exists)
3257            .collect();
3258        if mode == SetupMode::Plan || writes.is_empty() {
3259            return Ok(report);
3260        }
3261        let mut landed: Vec<&str> = Vec::new();
3262        for field in &writes {
3263            let added = field
3264                .missing
3265                .iter()
3266                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3267            let sent = match before.fields.get(&field.field) {
3268                Some(held) => {
3269                    let mut options = held
3270                        .options
3271                        .iter()
3272                        .map(|option| {
3273                            json!({
3274                                "id": option.id, "name": option.name, "color": option.color,
3275                                "description": option.description,
3276                            })
3277                        })
3278                        .collect::<Vec<_>>();
3279                    options.extend(added);
3280                    self.graphql(
3281                        graphql::STATUS_OPTIONS_UPDATE,
3282                        json!({"input": {
3283                            "projectId": before.board_id, "fieldId": held.field_id,
3284                            "singleSelectOptions": options,
3285                        }}),
3286                    )
3287                    .await
3288                }
3289                None => {
3290                    self.graphql(
3291                        graphql::CREATE_FIELD,
3292                        json!({"input": {
3293                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3294                            "name": field.field.name(),
3295                            "singleSelectOptions": added.collect::<Vec<_>>(),
3296                        }}),
3297                    )
3298                    .await
3299                }
3300            };
3301            // A mutation that failed does not establish that GitHub left its field as it was,
3302            // so every failure from here on carries the recovery data a drift refusal does.
3303            match sent {
3304                Ok(_) => landed.push(field.field.name()),
3305                Err(error) => {
3306                    let changed = if landed.is_empty() {
3307                        String::new()
3308                    } else {
3309                        format!("changed the {} field and then ", landed.join(" and "))
3310                    };
3311                    return Err(SourceError::Refused {
3312                        message: format!(
3313                            "the guarded field setup {changed}failed on the {} field, which it may \
3314                             have changed part way: {error}; the pre-write item assignments \
3315                             are:\n{}",
3316                            field.field.name(),
3317                            recovery(&report, &before)?
3318                        ),
3319                    });
3320                }
3321            }
3322        }
3323        // The board has been written, so a verification read that fails leaves it unverified
3324        // rather than unchanged, and says what to put back.
3325        let after = match self.board_snapshot(&owned).await {
3326            Ok(after) => after,
3327            Err(error) => {
3328                return Err(SourceError::Refused {
3329                    message: format!(
3330                        "the guarded field setup changed the {} field and then could not read the \
3331                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3332                        landed.join(" and "),
3333                        recovery(&report, &before)?
3334                    ),
3335                });
3336            }
3337        };
3338        let mut moved = Vec::new();
3339        for field in &report.fields {
3340            let name = field.field.name();
3341            let now = after
3342                .fields
3343                .get(&field.field)
3344                .map(|held| held.options.as_slice())
3345                .unwrap_or_default();
3346            if !field.existing.iter().all(|old| now.contains(old)) {
3347                moved.push(format!(
3348                    "a pre-existing {name} option id, name, color or description"
3349                ));
3350            }
3351            if !field.missing.iter().all(|wanted| {
3352                now.iter()
3353                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3354            }) {
3355                moved.push(format!("an added {name} option"));
3356            }
3357            if after.assignments(field.field) != before.assignments(field.field) {
3358                moved.push(format!("an item's {name} value"));
3359            }
3360        }
3361        if !moved.is_empty() {
3362            return Err(SourceError::Refused {
3363                message: format!(
3364                    "GitHub changed {} after the guarded field setup; the pre-write item \
3365                     assignments are:\n{}",
3366                    moved.join(", "),
3367                    recovery(&report, &before)?
3368                ),
3369            });
3370        }
3371        Ok(report)
3372    }
3373
3374    /// Validate configuration and capture the named credential without exposing it.
3375    ///
3376    /// # Errors
3377    ///
3378    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3379    /// [`SourceError::Auth`] when the named credential is missing or empty.
3380    pub fn new(
3381        name: &SourceName,
3382        config: GitHubProjectsConfig,
3383        secrets: &dyn SecretResolver,
3384    ) -> Result<Self, SourceError> {
3385        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3386    }
3387
3388    /// The same, recording every request it sends into an accounting the caller holds too.
3389    ///
3390    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3391    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3392    /// up — passes the one it records those into, so the session total accounts for the
3393    /// whole session rather than for this source's share of it.
3394    ///
3395    /// # Errors
3396    ///
3397    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3398    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3399    pub fn recording_into(
3400        name: &SourceName,
3401        config: GitHubProjectsConfig,
3402        secrets: &dyn SecretResolver,
3403        ledger: Arc<Accounting>,
3404    ) -> Result<Self, SourceError> {
3405        if !valid_github_owner(&config.owner) {
3406            return Err(SourceError::Config {
3407                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3408            });
3409        }
3410        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3411            return Err(SourceError::Config {
3412                message: format!("project_number must be between 1 and {}", i32::MAX),
3413            });
3414        }
3415        if !valid_environment_name(&config.token_env) {
3416            return Err(SourceError::Config {
3417                message: "token_env must be a valid environment-variable name".into(),
3418            });
3419        }
3420        let repository = config
3421            .repository
3422            .as_deref()
3423            .map(RepositoryTarget::parse)
3424            .transpose()?;
3425        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3426            message: format!("endpoint is not a valid URL: {e}"),
3427        })?;
3428        if endpoint.scheme() != "https"
3429            && !(endpoint.scheme() == "http"
3430                && endpoint
3431                    .host_str()
3432                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3433        {
3434            return Err(SourceError::Config {
3435                message:
3436                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3437                        .into(),
3438            });
3439        }
3440        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3441            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),
3442        })?;
3443        Ok(Self {
3444            name: name.clone(),
3445            owner: config.owner,
3446            project_number: config.project_number,
3447            repository,
3448            endpoint,
3449            token,
3450            credential_name: config.token_env,
3451            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3452            priorities: config
3453                .priority_mapping
3454                .map(|mapping| PriorityMapping::resolve(mapping, name))
3455                .transpose()?,
3456            client: Client::builder()
3457                .user_agent("onetaskgraph")
3458                .build()
3459                .map_err(|e| SourceError::Config {
3460                    message: format!("cannot build HTTP client: {e}"),
3461                })?,
3462            created: Mutex::new(Vec::new()),
3463            updated: Mutex::new(Vec::new()),
3464            commented: Mutex::new(Vec::new()),
3465            pacing: Pacing::resolve(config.pacing, name)?,
3466            last_mutation: Mutex::new(None),
3467            board_cache: Mutex::new(None),
3468            search_cache: Mutex::new(None),
3469            narrowed_cache: Mutex::new(BTreeMap::new()),
3470            resolved_cache: Mutex::new(BTreeMap::new()),
3471            search_next: Mutex::new(BTreeMap::new()),
3472            fields_cache: Mutex::new(None),
3473            repository_cache: Mutex::new(BTreeMap::new()),
3474            ledger,
3475        })
3476    }
3477
3478    /// A snapshot of every request this source has sent, and what each cost.
3479    ///
3480    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3481    /// of them can sit side by side. When this source was built with
3482    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3483    /// point of building it that way.
3484    #[must_use]
3485    pub fn accounting(&self) -> accounting::Session {
3486        self.ledger.snapshot()
3487    }
3488
3489    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3490    /// rate limit rather than handing it straight back as an error.
3491    ///
3492    /// Retrying is safe for every document here, including the mutations, and the reason
3493    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3494    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3495    /// this replays has already taken effect. An outcome this source cannot know — the
3496    /// send failed, or the body could not be read, so the mutation may well have landed —
3497    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3498    /// attempt. A duplicate write would come from replaying one of those, and none is
3499    /// replayed.
3500    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3501        if is_mutation(query)
3502            && ![
3503                graphql::ADD_COMMENT,
3504                graphql::UPDATE_COMMENT,
3505                graphql::DELETE_COMMENT,
3506            ]
3507            .contains(&query)
3508        {
3509            let mut cache = self.resolved_cache()?;
3510            for argument in ["input", "second", "third", "clear"] {
3511                if let Some(input) = variables.get(argument) {
3512                    cache.retain(|id, item| {
3513                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3514                            input
3515                                .get(key)
3516                                .and_then(Value::as_str)
3517                                .is_some_and(|value| value == id.0 || value == item.item_id)
3518                        })
3519                    });
3520                }
3521            }
3522        }
3523        let doing = operation_description(query);
3524        let mut waited = Duration::ZERO;
3525        let mut waits = 0_u32;
3526        let mut backoff = self.pacing.retry_backoff;
3527        loop {
3528            if is_mutation(query) {
3529                let spacing = self.reserve_mutation_slot();
3530                if !spacing.is_zero() {
3531                    tokio::time::sleep(spacing).await;
3532                }
3533            }
3534            let attempt = self.send_once(query, &variables).await;
3535            if is_mutation(query) {
3536                self.finish_mutation();
3537            }
3538            let limited = match attempt {
3539                Ok(data) => return Ok(data),
3540                Err(Attempt::Failed(error)) => return Err(error),
3541                Err(Attempt::Limited(limited)) => limited,
3542            };
3543            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3544            // move that extends a secondary limit, so a hint below the schedule's own next
3545            // wait is raised to it.
3546            let wait = match limited.hint {
3547                Some(hint) => Duration::from_secs(hint).max(backoff),
3548                None => backoff,
3549            };
3550            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3551            // A wait of nothing spends none of the budget, so it is exhaustion rather
3552            // than a retry. `Pacing::resolve` rules out every way of configuring one
3553            // except a budget of zero, where reporting the first refusal is the ask.
3554            if wait.is_zero() || wait > remaining {
3555                return Err(limited.exhausted(
3556                    doing,
3557                    waits,
3558                    waited,
3559                    wait,
3560                    self.pacing.retry_budget,
3561                ));
3562            }
3563            tokio::time::sleep(wait).await;
3564            waited += wait;
3565            waits += 1;
3566            backoff = backoff.saturating_mul(2);
3567        }
3568    }
3569
3570    /// The next moment a content-creating mutation may leave this source, as a wait from
3571    /// now.
3572    ///
3573    /// The slot is reserved under the lock and the waiting happens outside it, so two
3574    /// callers take two slots rather than the same one — and no lock is held across an
3575    /// await.
3576    ///
3577    /// The moment it is spaced from is the previous mutation's *completion*, which
3578    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3579    /// own is the wrong thing to measure from.
3580    fn reserve_mutation_slot(&self) -> Duration {
3581        if self.pacing.min_mutation_interval.is_zero() {
3582            return Duration::ZERO;
3583        }
3584        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3585        // it would turn an earlier failure into a second one for no gain.
3586        let mut last = self
3587            .last_mutation
3588            .lock()
3589            .unwrap_or_else(std::sync::PoisonError::into_inner);
3590        let now = Instant::now();
3591        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3592        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3593        let at = last.map_or(now, |previous| {
3594            previous
3595                .checked_add(self.pacing.min_mutation_interval)
3596                .map_or(now, |earliest| earliest.max(now))
3597        });
3598        *last = Some(at);
3599        at.saturating_duration_since(now)
3600    }
3601
3602    /// Record that a content-creating mutation has finished, so the next one is spaced
3603    /// from here rather than from the moment this one was released.
3604    ///
3605    /// This source can only choose when a request *departs*; the limiter counts when it
3606    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3607    /// departure from the last therefore hands the limiter a gap of the interval less that
3608    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3609    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3610    /// slower machine while passing on a quick one.
3611    ///
3612    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3613    /// previous request had already arrived before its response came back, so its arrival
3614    /// is no later than this moment, and the next mutation is released at least the
3615    /// interval after this moment and arrives no earlier than it is released: the gap the
3616    /// limiter measures is therefore at least the interval, whatever transit costs and on
3617    /// whatever platform. The price is that a mutation's own round trip no longer counts
3618    /// towards its spacing, which makes this source slightly slower than the configured
3619    /// rate rather than slightly faster — the safe side of a limit that punishes being
3620    /// wrong by refusing reads for the next fifty minutes.
3621    ///
3622    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3623    /// and one that never left costs only a wait nobody needed.
3624    fn finish_mutation(&self) {
3625        if self.pacing.min_mutation_interval.is_zero() {
3626            return;
3627        }
3628        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3629        let mut last = self
3630            .last_mutation
3631            .lock()
3632            .unwrap_or_else(std::sync::PoisonError::into_inner);
3633        let now = Instant::now();
3634        // `max` rather than an assignment: a concurrent caller may already have reserved a
3635        // slot further out, and completing this request must never pull that slot back in.
3636        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3637    }
3638
3639    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3640    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3641    ///
3642    /// This is the one place a request leaves this crate, which is why the accounting is
3643    /// here rather than at each of the callers: a read path added later is counted without
3644    /// anybody remembering to count it, and
3645    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3646    /// when one is not.
3647    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3648        let Attempted {
3649            result,
3650            limits,
3651            reported_cost,
3652        } = self.attempt(query, variables).await;
3653        // No `otherwise` name: every document this source sends is one of its own, and the
3654        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3655        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3656        let outcome = match &result {
3657            Ok(_) => accounting::Outcome::Answered,
3658            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3659            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3660        };
3661        self.ledger.record(sending.finished(outcome, limits));
3662        result
3663    }
3664
3665    /// The attempt itself, with what its response said about the rate limit alongside.
3666    ///
3667    /// The two are returned together rather than recorded here because every one of the
3668    /// early exits below is a different outcome, and a record written at each of them is a
3669    /// record one of them can be added without.
3670    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3671        let mut limits = accounting::RateLimit::default();
3672        let mut reported_cost = None;
3673        let result = self
3674            .attempted(query, variables, &mut limits, &mut reported_cost)
3675            .await;
3676        Attempted {
3677            result,
3678            limits,
3679            reported_cost,
3680        }
3681    }
3682
3683    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3684    async fn attempted(
3685        &self,
3686        query: &str,
3687        variables: &Value,
3688        limits: &mut accounting::RateLimit,
3689        reported_cost: &mut Option<u64>,
3690    ) -> Result<Value, Attempt> {
3691        let response = self
3692            .client
3693            .post(self.endpoint.clone())
3694            .bearer_auth(self.token.expose_secret())
3695            .json(&json!({"query": query, "variables": variables}))
3696            .send()
3697            .await
3698            .map_err(|e| {
3699                Attempt::Failed(SourceError::Unavailable {
3700                    message: format!("GitHub GraphQL request failed: {e}"),
3701                })
3702            })?;
3703        let status = response.status();
3704        let header = |name: &str| whole_seconds(response.headers().get(name));
3705        *limits = accounting::RateLimit::read(|name| {
3706            response
3707                .headers()
3708                .get(name)
3709                .and_then(|value| value.to_str().ok())
3710                .map(str::to_owned)
3711        });
3712        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3713        // that are not text at all — is "not known to be exhausted". This never makes a
3714        // response a refusal on its own: it says which limiter a refusal is attributed to
3715        // and where its hint comes from, so a value this cannot read costs a hint rather
3716        // than an answer.
3717        let exhausted = response
3718            .headers()
3719            .get("x-ratelimit-remaining")
3720            .and_then(|value| value.to_str().ok())
3721            == Some("0");
3722        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3723        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3724        // which is the same question answered as an absolute time. Nothing else here is a
3725        // hint, and a schedule is what answers a refusal that carries none.
3726        let hint = header("retry-after").or_else(|| {
3727            exhausted
3728                .then(|| header("x-ratelimit-reset"))
3729                .flatten()
3730                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3731        });
3732        // Read before it is parsed, because the evidence which tells a secondary rate
3733        // limit from a rejected credential is in the body of a response whose status says
3734        // only "forbidden" — and a non-success response was never parsed at all.
3735        let body = response.text().await.map_err(|e| {
3736            Attempt::Failed(SourceError::Unavailable {
3737                message: format!("GitHub GraphQL response could not be read: {e}"),
3738            })
3739        })?;
3740        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3741            return Err(Attempt::Limited(Limited { limiter, hint }));
3742        }
3743        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3744            return Err(Attempt::Failed(SourceError::Auth {
3745                message: format!(
3746                    "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"
3747                ),
3748            }));
3749        }
3750        if !status.is_success() {
3751            return Err(Attempt::Failed(SourceError::Unavailable {
3752                message: format!("GitHub GraphQL returned HTTP {status}"),
3753            }));
3754        }
3755        // GitHub reports what a call cost only when the document asked it to, and no
3756        // document this source sends does — so this is `None` here and carries the figure
3757        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3758        // pick up is a `dryRun` probe's cost, which is some other document's.
3759        *reported_cost = serde_json::from_str::<Value>(&body)
3760            .ok()
3761            .as_ref()
3762            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3763            .and_then(Value::as_u64);
3764        self.answer(&body).map_err(Attempt::Failed)
3765    }
3766
3767    /// What one successful HTTP response says, once its GraphQL errors are read.
3768    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3769        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3770            message: format!("GitHub returned invalid JSON: {e}"),
3771        })?;
3772        let errors = body
3773            .get("errors")
3774            .map(|value| {
3775                value.as_array().ok_or_else(|| SourceError::Malformed {
3776                    message: "GitHub response errors is not an array".into(),
3777                })
3778            })
3779            .transpose()?;
3780        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3781            let messages = errors
3782                .iter()
3783                .filter_map(|e| e.get("message").and_then(Value::as_str))
3784                .collect::<Vec<_>>()
3785                .join("; ");
3786            let message = if messages.is_empty() {
3787                "GitHub returned GraphQL errors".into()
3788            } else {
3789                messages
3790            };
3791            let normalized = message.to_ascii_lowercase();
3792            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3793                return Err(SourceError::Auth {
3794                    message: format!(
3795                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3796                        self.credential_name
3797                    ),
3798                });
3799            }
3800            return Err(SourceError::Refused { message });
3801        }
3802        body.get("data")
3803            .filter(|data| data.is_object())
3804            .cloned()
3805            .ok_or_else(|| SourceError::Malformed {
3806                message: "GitHub response has no data object".into(),
3807            })
3808    }
3809
3810    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3811    // GraphQL cannot independently page them inside the outer item page. This source page is
3812    // deliberately bounded at that published maximum; the live drift journey exercises it.
3813    async fn board_page(
3814        &self,
3815        items_after: Option<&str>,
3816        items_first: u32,
3817    ) -> Result<Value, SourceError> {
3818        let data = self
3819            .graphql(
3820                graphql::BOARD,
3821                json!({"owner":self.owner,"number":self.project_number,
3822                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3823                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3824            )
3825            .await?;
3826        data.pointer("/owner/projectV2")
3827            .filter(|v| !v.is_null())
3828            .cloned()
3829            .ok_or_else(|| SourceError::Refused {
3830                message: format!(
3831                    "GitHub project {}/{} was not found or is not visible to the token",
3832                    self.owner, self.project_number
3833                ),
3834            })
3835    }
3836
3837    /// The search that finds the issues of this board, narrowed by `also` when it is
3838    /// given.
3839    ///
3840    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3841    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3842    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3843    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3844    /// from a task by the `parent` field each issue carries rather than by the search.
3845    fn board_search(&self, also: Option<&str>) -> String {
3846        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3847        match also {
3848            Some(also) => format!("{scope} {also}"),
3849            None => scope,
3850        }
3851    }
3852
3853    /// One issue this source reached directly, as the board item a read of the board would
3854    /// have produced — or `None` when this board does not hold it.
3855    ///
3856    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3857    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3858    /// item's own id, that item's field values, and the issue as its content. One resolver
3859    /// for both routes is what makes an issue read through a search, through its own node
3860    /// id, or through its project's sub-issues report the same title, the same status, the
3861    /// same labels and the same qualified id.
3862    ///
3863    /// An issue with no entry for *this* board is not this source's to report, which is
3864    /// what keeps an id naming some other repository's issue from being answered as an item
3865    /// of this board. That answer is given about an **exhausted** connection and never
3866    /// about an unread page: the entry is looked for on the page in hand, and only if that
3867    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3868    /// rest of it.
3869    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3870        if optional_str(issue, "__typename")? != Some("Issue") {
3871            return Ok(None);
3872        }
3873        let memberships = issue
3874            .get("projectItems")
3875            .ok_or_else(|| SourceError::Malformed {
3876                message: "GitHub issue is missing projectItems".into(),
3877            })?;
3878        let nodes = memberships
3879            .get("nodes")
3880            .and_then(Value::as_array)
3881            .ok_or_else(|| SourceError::Malformed {
3882                message: "GitHub issue projectItems.nodes is not an array".into(),
3883            })?;
3884        let held = match self.board_entry(nodes) {
3885            Some(held) => held.clone(),
3886            None => {
3887                let info = memberships
3888                    .get("pageInfo")
3889                    .ok_or_else(|| SourceError::Malformed {
3890                        message: "GitHub issue projectItems has no pageInfo".into(),
3891                    })?;
3892                // The page held no entry for this board. Whether that means the issue is
3893                // not on it is a question about the rest of the connection, and only a
3894                // connection with no rest answers it here.
3895                if !required_bool(info, "hasNextPage")? {
3896                    return Ok(None);
3897                }
3898                let cursor = required_str(info, "endCursor")?;
3899                validate_cursor_progress(None, cursor)?;
3900                let issue_id = required_str(issue, "id")?;
3901                match self.board_membership(issue_id, cursor).await? {
3902                    Some(held) => held,
3903                    None => return Ok(None),
3904                }
3905            }
3906        };
3907        let item = json!({
3908            "id": required_str(&held, "id")?,
3909            "project": held.get("project"),
3910            "fieldValues": held.get("fieldValues"),
3911            "content": issue,
3912        });
3913        self.resolve(&item)
3914    }
3915
3916    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3917    ///
3918    /// One spelling of *which membership is this board's*, so the page a read carries and
3919    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3920    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3921        nodes.iter().find(|node| {
3922            node.pointer("/project/number").and_then(Value::as_u64)
3923                == Some(u64::from(self.project_number))
3924        })
3925    }
3926
3927    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3928    ///
3929    /// The recovery read: a page of memberships that holds no entry for this board says
3930    /// nothing about the memberships past it, so the connection is walked to exhaustion
3931    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3932    /// positive answer — the whole connection was read and no entry named this board —
3933    /// rather than a failure, and the walk is held to
3934    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3935    /// with a cursor that does not advance is refused instead of spun on.
3936    async fn board_membership(
3937        &self,
3938        issue: &str,
3939        after: &str,
3940    ) -> Result<Option<Value>, SourceError> {
3941        let mut after = after.to_owned();
3942        loop {
3943            let data = self
3944                .graphql(
3945                    graphql::ISSUE_BOARD_ITEMS,
3946                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3947                           "nestedFirst":NESTED_PAGE_SIZE}),
3948                )
3949                .await?;
3950            let Some(connection) = data
3951                .pointer("/node/projectItems")
3952                .filter(|value| !value.is_null())
3953            else {
3954                // The id resolved to nothing, or to something with no memberships to walk —
3955                // which is the same answer as a connection holding no entry for this board.
3956                return Ok(None);
3957            };
3958            let nodes = connection
3959                .get("nodes")
3960                .and_then(Value::as_array)
3961                .ok_or_else(|| SourceError::Malformed {
3962                    message: "GitHub issue projectItems.nodes is not an array".into(),
3963                })?;
3964            if let Some(held) = self.board_entry(nodes) {
3965                return Ok(Some(held.clone()));
3966            }
3967            let info = connection
3968                .get("pageInfo")
3969                .ok_or_else(|| SourceError::Malformed {
3970                    message: "GitHub issue projectItems has no pageInfo".into(),
3971                })?;
3972            let next = required_bool(info, "hasNextPage")?
3973                .then(|| required_str(info, "endCursor"))
3974                .transpose()?;
3975            match next {
3976                Some(next) => {
3977                    validate_cursor_progress(Some(&after), next)?;
3978                    after = next.to_owned();
3979                }
3980                None => return Ok(None),
3981            }
3982        }
3983    }
3984
3985    /// One page of a board-scoped issue search, and where the next page resumes.
3986    async fn search_page(
3987        &self,
3988        search: &str,
3989        first: u32,
3990        after: Option<&str>,
3991    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3992        let data = self
3993            .graphql(
3994                graphql::SEARCH_ISSUES,
3995                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3996                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3997                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3998            )
3999            .await?;
4000        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4001            message: "GitHub search response has no search connection".into(),
4002        })?;
4003        let mut found = Vec::new();
4004        for node in connection
4005            .get("nodes")
4006            .and_then(Value::as_array)
4007            .ok_or_else(|| SourceError::Malformed {
4008                message: "GitHub search nodes is not an array".into(),
4009            })?
4010        {
4011            if let Some(resolved) = self.resolve_issue(node).await? {
4012                found.push(resolved);
4013            }
4014        }
4015        let info = connection
4016            .get("pageInfo")
4017            .ok_or_else(|| SourceError::Malformed {
4018                message: "GitHub search connection has no pageInfo".into(),
4019            })?;
4020        let next = required_bool(info, "hasNextPage")?
4021            .then(|| required_str(info, "endCursor"))
4022            .transpose()?
4023            .map(str::to_owned);
4024        if let Some(next) = &next {
4025            validate_cursor_progress(after, next)?;
4026        }
4027        Ok((found, next))
4028    }
4029
4030    /// Every issue this board holds, completed with what this run wrote.
4031    ///
4032    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4033    /// is an index and is eventually consistent, so an issue this run created seconds ago
4034    /// can be absent from it, and a project listed straight after being written would
4035    /// otherwise be missing from its own board. What is added back is only what this
4036    /// process itself wrote, out of [`Self::created`], which lives and dies with the
4037    /// process.
4038    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4039        let found = self.searched_issues().await?;
4040        self.completed_with_written(found, |_| true)
4041    }
4042
4043    /// Every issue this board's own search reports, walked to exhaustion, read once per
4044    /// source.
4045    ///
4046    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4047    /// needs it too and the two would otherwise walk the same search twice in one command.
4048    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4049    /// is.
4050    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4051        let cached = self.search_cache()?.clone();
4052        if let Some(held) = cached {
4053            return Ok(held);
4054        }
4055        let mut after: Option<String> = None;
4056        let mut found = Vec::new();
4057        let search = self.board_search(None);
4058        loop {
4059            let (page, next) = self
4060                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4061                .await?;
4062            found.extend(page);
4063            match next {
4064                Some(next) => after = Some(next),
4065                None => break,
4066            }
4067        }
4068        *self.search_cache()? = Some(found.clone());
4069        Ok(found)
4070    }
4071
4072    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4073    fn search_cache(
4074        &self,
4075    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4076        self.search_cache
4077            .lock()
4078            .map_err(|_| SourceError::Unavailable {
4079                message: "this source's view of the board's issues was left inconsistent by an \
4080                      earlier failure; next: run the command again"
4081                    .into(),
4082            })
4083    }
4084
4085    /// `found`, with everything this run wrote that `keep` accepts and the read did not
4086    /// report.
4087    ///
4088    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4089    /// at all: the search index is behind, and a node read of an item filed moments ago can
4090    /// be too.
4091    fn completed_with_written(
4092        &self,
4093        mut found: Vec<Resolved>,
4094        keep: impl Fn(&Resolved) -> bool,
4095    ) -> Result<Vec<Resolved>, SourceError> {
4096        for own in self.created()?.iter().filter(|own| keep(own)) {
4097            if !found.iter().any(|item| item.id == own.id) {
4098                found.push(own.clone());
4099            }
4100        }
4101        Ok(found)
4102    }
4103
4104    /// What resolving one node id reached.
4105    ///
4106    /// Three answers rather than an `Option`, because a board *draft* is none of the other
4107    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4108    /// is completed by a read of the draft itself rather than reported as nothing.
4109    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4110        let asked = self
4111            .graphql(
4112                graphql::ISSUE,
4113                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4114                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4115            )
4116            .await;
4117        let data = match asked {
4118            Ok(data) => data,
4119            // A string that is not a node id at all is not a failure to report: it is an id
4120            // this board does not hold, which is what every read of one already answers.
4121            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4122            Err(error) => return Err(error),
4123        };
4124        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4125            return Ok(Reached::Nothing);
4126        };
4127        if optional_str(node, "__typename")? == Some("DraftIssue") {
4128            return Ok(Reached::Draft);
4129        }
4130        Ok(match self.resolve_issue(node).await? {
4131            Some(item) => Reached::Held(Box::new(item)),
4132            None => Reached::Nothing,
4133        })
4134    }
4135
4136    /// One item of this board by its own id, whatever kind it is.
4137    ///
4138    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4139    /// run wrote is read first, because a node read of an item created moments ago can
4140    /// still be behind the board field values written onto it — see [`Self::created`].
4141    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4142        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4143            return Ok(Some(own.clone()));
4144        }
4145        match self.reach(id).await? {
4146            Reached::Held(item) => Ok(Some(*item)),
4147            Reached::Nothing => Ok(None),
4148            Reached::Draft => self.draft_by_id(id).await,
4149        }
4150    }
4151
4152    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4153    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4154    /// than one request per id.
4155    ///
4156    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4157    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4158    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4159    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4160    /// is completed by a read of the draft, exactly as there.
4161    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4162        let mut found: Vec<Option<Option<Resolved>>> = {
4163            let created = self.created()?;
4164            ids.iter()
4165                .map(|id| {
4166                    created
4167                        .iter()
4168                        .find(|own| own.id == *id)
4169                        .map(|own| Some(own.clone()))
4170                })
4171                .collect()
4172        };
4173        let unread: Vec<NativeId> = ids
4174            .iter()
4175            .zip(&found)
4176            .filter(|(_, found)| found.is_none())
4177            .map(|(id, _)| id.clone())
4178            .collect();
4179        let mut read = Vec::with_capacity(unread.len());
4180        if let [one] = unread.as_slice() {
4181            read.push(self.item_by_id(one).await?);
4182        } else {
4183            for batch in unread.chunks(DETAIL_BATCH) {
4184                let data = match self
4185                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4186                    .await
4187                {
4188                    Ok(data) => data,
4189                    Err(error) if unresolvable_node(&error) => {
4190                        for id in batch {
4191                            read.push(self.item_by_id(id).await?);
4192                        }
4193                        continue;
4194                    }
4195                    Err(error) => return Err(error),
4196                };
4197                for (slot, id) in batch.iter().enumerate() {
4198                    let node =
4199                        data.get(format!("i{slot}"))
4200                            .ok_or_else(|| SourceError::Malformed {
4201                                message: format!(
4202                                    "GitHub answered a batch read with no item for {}",
4203                                    id.0
4204                                ),
4205                            })?;
4206                    read.push(if node.is_null() {
4207                        None
4208                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4209                        self.draft_by_id(id).await?
4210                    } else {
4211                        if optional_str(node, "__typename")? == Some("Issue")
4212                            && required_str(node, "id")? != id.0
4213                        {
4214                            return Err(SourceError::Malformed {
4215                                message: format!(
4216                                    "GitHub answered the read of {} with issue {}",
4217                                    id.0,
4218                                    required_str(node, "id")?
4219                                ),
4220                            });
4221                        }
4222                        self.resolve_issue(node).await?
4223                    });
4224                }
4225            }
4226        }
4227        let mut read = read.into_iter();
4228        Ok(found
4229            .iter_mut()
4230            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4231            .collect())
4232    }
4233
4234    fn resolved_cache(
4235        &self,
4236    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4237        self.resolved_cache
4238            .lock()
4239            .map_err(|_| SourceError::Unavailable {
4240                message: "resolved item records were left inconsistent; run the command again"
4241                    .into(),
4242            })
4243    }
4244
4245    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4246    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4247    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4248        let cached = self.resolved_cache()?.get(id).cloned();
4249        match cached {
4250            Some(item) => Ok(Some(item)),
4251            None => self.item_by_id(id).await,
4252        }
4253    }
4254
4255    /// One board draft by its own id, with the board item it sits in — or `None` when no
4256    /// item of this board is that draft's.
4257    ///
4258    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4259    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4260    /// links a draft to one board item, so the page this read carries is the whole of that
4261    /// connection, and a page that reports more than it holds is refused rather than read
4262    /// as an answer about memberships nobody read.
4263    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4264        let data = self
4265            .graphql(
4266                graphql::DRAFT,
4267                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4268                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4269            )
4270            .await?;
4271        // Gone between the two reads is an answer — the draft is no longer there. Anything
4272        // else than the draft [`Self::reach`] was just told this id is, is not one.
4273        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4274            return Ok(None);
4275        };
4276        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4277            return Err(SourceError::Malformed {
4278                message: format!(
4279                    "GitHub answered {} as a draft and then as something else",
4280                    id.0
4281                ),
4282            });
4283        }
4284        if required_str(draft, "id")? != id.0 {
4285            return Err(SourceError::Malformed {
4286                message: format!("GitHub answered a different draft for {}", id.0),
4287            });
4288        }
4289        let memberships = draft
4290            .get("projectV2Items")
4291            .ok_or_else(|| SourceError::Malformed {
4292                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4293            })?;
4294        let nodes = memberships
4295            .get("nodes")
4296            .and_then(Value::as_array)
4297            .ok_or_else(|| SourceError::Malformed {
4298                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4299            })?;
4300        let info = memberships
4301            .get("pageInfo")
4302            .ok_or_else(|| SourceError::Malformed {
4303                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4304            })?;
4305        // Read whether or not this board's entry is on the page: a page claiming more than
4306        // the one item GitHub links a draft to is a malformed answer either way.
4307        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4308            return Err(SourceError::Malformed {
4309                message: format!(
4310                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4311                     to",
4312                    id.0
4313                ),
4314            });
4315        }
4316        if let Some(node) = nodes.first()
4317            && node
4318                .pointer("/project/number")
4319                .and_then(Value::as_u64)
4320                .is_none()
4321        {
4322            return Err(SourceError::Malformed {
4323                message: format!(
4324                    "GitHub draft {} board item has no numeric project number",
4325                    id.0
4326                ),
4327            });
4328        }
4329        let Some(held) = self.board_entry(nodes) else {
4330            return Ok(None);
4331        };
4332        if required_str(
4333            held.get("project").ok_or_else(|| SourceError::Malformed {
4334                message: format!("GitHub draft {} board item has no project", id.0),
4335            })?,
4336            "id",
4337        )? != self.board_fields().await?.id.as_str()
4338        {
4339            return Ok(None);
4340        }
4341        let item = json!({
4342            "id": required_str(held, "id")?,
4343            "project": held.get("project"),
4344            "fieldValues": held.get("fieldValues"),
4345            "content": draft,
4346        });
4347        self.resolve(&item)
4348    }
4349
4350    /// The board's own id and field definitions, for a write whose item does not carry
4351    /// them — never its items.
4352    ///
4353    /// A board this command has already listed supplies them, since it read them beside its
4354    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4355    /// is consulted about which items the board holds: see the module documentation for
4356    /// why a question about one known item is answered by reading that item.
4357    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4358        if let Some(board) = self.board_cache()?.as_ref() {
4359            return Ok(BoardFields {
4360                id: BoardId::parse(&board.id)?,
4361                fields: board.fields.clone(),
4362            });
4363        }
4364        if let Some(held) = self.fields_cache()?.clone() {
4365            return Ok(held);
4366        }
4367        let data = self
4368            .graphql(
4369                graphql::BOARD_FIELDS,
4370                json!({"owner":self.owner,"number":self.project_number,
4371                       "nestedFirst":NESTED_PAGE_SIZE}),
4372            )
4373            .await?;
4374        self.fields_read(&data)
4375    }
4376
4377    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4378    /// the rest of this command.
4379    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4380        let board = data
4381            .pointer("/boardFields/projectV2")
4382            .filter(|value| !value.is_null())
4383            .ok_or_else(|| SourceError::Refused {
4384                message: format!(
4385                    "GitHub project {}/{} was not found or is not visible to the token",
4386                    self.owner, self.project_number
4387                ),
4388            })?;
4389        let read = BoardFields {
4390            id: BoardId::parse(required_str(board, "id")?)?,
4391            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4392        };
4393        *self.fields_cache()? = Some(read.clone());
4394        Ok(read)
4395    }
4396
4397    /// Read what creating an issue in `repository` needs and this command has not read yet —
4398    /// the board's fields and the repository's node id — in one request when it needs both.
4399    ///
4400    /// When either is already known this sends nothing, and the other is read by its own
4401    /// document where it is asked for, so no create reads anything twice.
4402    async fn creation_context(
4403        &self,
4404        repository: &RepositoryTarget,
4405        incoming: &Incoming<'_>,
4406    ) -> Result<(), SourceError> {
4407        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4408        if fields_known || self.repository_cache()?.contains_key(repository) {
4409            return Ok(());
4410        }
4411        let data = self
4412            .graphql(
4413                graphql::CREATION_CONTEXT,
4414                json!({"owner":self.owner,"number":self.project_number,
4415                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4416                       "repositoryName":repository.name}),
4417            )
4418            .await?;
4419        self.fields_read(&data)?;
4420        self.repository_read(&data, repository, incoming)?;
4421        Ok(())
4422    }
4423
4424    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4425    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4426        self.fields_cache
4427            .lock()
4428            .map_err(|_| SourceError::Unavailable {
4429                message: "this source's view of the board's fields was left inconsistent by an \
4430                      earlier failure; next: run the command again"
4431                    .into(),
4432            })
4433    }
4434
4435    /// What a write to `item` needs of the board, read off that item when it says enough and
4436    /// off [`Self::board_fields`] when it does not.
4437    ///
4438    /// A node read of an item names its board and carries the definition of every field it
4439    /// holds a value of — so an item naming its board, holding a value of the origin field,
4440    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4441    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4442    /// of may still be on the board, and a view reading it as absent would refuse a write the
4443    /// board can take or skip a field write the board needs, so such an item — and a create,
4444    /// which has no item yet — takes the board's fields from their own read instead.
4445    async fn fields_for(
4446        &self,
4447        item: Option<&Resolved>,
4448        writes_status: bool,
4449        selects_priority: bool,
4450    ) -> Result<BoardFields, SourceError> {
4451        if let Some(board) = item.and_then(Resolved::carried_board) {
4452            return Ok(board);
4453        }
4454        if let Some(item) = item
4455            && let Some(board_id) = item.named_board()
4456            && item.defines(ORIGIN_FIELD)
4457            && (!writes_status || item.defines("Status"))
4458            && (!selects_priority || item.defines(PRIORITY_FIELD))
4459        {
4460            return Ok(BoardFields {
4461                id: board_id,
4462                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4463            });
4464        }
4465        self.board_fields().await
4466    }
4467
4468    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4469    /// when that id names nothing here with a sub-issue relationship to walk.
4470    ///
4471    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4472    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4473    /// empty vector is a project that holds nothing.
4474    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4475        let mut after: Option<String> = None;
4476        let mut children = Vec::new();
4477        loop {
4478            let asked = self
4479                .graphql(
4480                    graphql::SUB_ISSUES,
4481                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4482                           "nestedFirst":NESTED_PAGE_SIZE,
4483                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4484                )
4485                .await;
4486            let data = match asked {
4487                Ok(data) => data,
4488                // A string that is not a node id at all is not a failure to report: it is
4489                // the ordinary answer to a selector naming a project by its name.
4490                Err(error) if unresolvable_node(&error) => return Ok(None),
4491                Err(error) => return Err(error),
4492            };
4493            let Some(connection) = data
4494                .pointer("/node/subIssues")
4495                .filter(|value| !value.is_null())
4496            else {
4497                // No such node, or one with no sub-issue relationship — a board draft is
4498                // the one this board can really hold.
4499                return Ok(None);
4500            };
4501            for node in connection
4502                .get("nodes")
4503                .and_then(Value::as_array)
4504                .ok_or_else(|| SourceError::Malformed {
4505                    message: "GitHub subIssues.nodes is not an array".into(),
4506                })?
4507            {
4508                if let Some(resolved) = self.resolve_issue(node).await? {
4509                    children.push(resolved);
4510                }
4511            }
4512            let info = connection
4513                .get("pageInfo")
4514                .ok_or_else(|| SourceError::Malformed {
4515                    message: "GitHub subIssues connection has no pageInfo".into(),
4516                })?;
4517            let next = required_bool(info, "hasNextPage")?
4518                .then(|| required_str(info, "endCursor"))
4519                .transpose()?;
4520            match next {
4521                Some(next) => {
4522                    validate_cursor_progress(after.as_deref(), next)?;
4523                    after = Some(next.to_owned());
4524                }
4525                None => return Ok(Some(children)),
4526            }
4527        }
4528    }
4529
4530    /// Which issue of this board a project *name* is, or `None` when none is.
4531    ///
4532    /// One bounded query which filters on that name at the server, rather than a walk of
4533    /// every issue the board holds. The name is compared again here: the qualifier narrows
4534    /// what GitHub sends, and this source decides what it names.
4535    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4536        let search = self.board_search(Some(&title_qualifier(name)));
4537        let mut after = None;
4538        loop {
4539            let (candidates, next) = self
4540                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4541                .await?;
4542            if let Some(item) = candidates.into_iter().find(|item| {
4543                item.kind == BoardKind::Work(ItemKind::Project)
4544                    && item.title.eq_ignore_ascii_case(name)
4545            }) {
4546                return Ok(Some(item.id));
4547            }
4548            match next {
4549                Some(next) => after = Some(next),
4550                None => return Ok(None),
4551            }
4552        }
4553    }
4554
4555    /// Everything filed under one project of this board: the sub-issues of the issue that
4556    /// project is.
4557    ///
4558    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4559    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4560    /// gains projects, or as another project gains tasks.
4561    ///
4562    /// A qualified id names the issue and is asked for its sub-issues directly: one
4563    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4564    /// read as a project *name*, which costs the one bounded search
4565    /// [`Self::project_by_name`] makes.
4566    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4567        let (project, children) = match self.sub_issues(selector).await? {
4568            Some(children) => (selector.clone(), children),
4569            None => match self.project_by_name(&selector.0).await? {
4570                Some(project) => {
4571                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4572                    (project, children)
4573                }
4574                None => return Ok(Vec::new()),
4575            },
4576        };
4577        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4578    }
4579
4580    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4581    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4582    ///
4583    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4584    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4585    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4586    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4587    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4588    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4589    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4590    /// rather than silently narrowing a caller's answer.
4591    ///
4592    /// The instant is written to the second, rounded down, which can only widen what the
4593    /// search returns; confirmation against each candidate's own comments is what makes the
4594    /// answer exact. The search is an index that lags a write by a second or two — the module
4595    /// documentation records it — so a caller that asks again from its last instant should
4596    /// overlap the two by more than that.
4597    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4598        let found = self.searched(&updated_qualifier(since)).await?;
4599        self.completed_with_written(found, |_| true)
4600    }
4601
4602    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4603    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4604    ///
4605    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4606    /// own record is the fresher of the two.
4607    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4608        let search = self.board_search(Some(also));
4609        let mut after: Option<String> = None;
4610        let mut found = Vec::new();
4611        loop {
4612            let (page, next) = self
4613                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4614                .await?;
4615            found.extend(page);
4616            match next {
4617                Some(next) => after = Some(next),
4618                None => return Ok(found),
4619            }
4620        }
4621    }
4622
4623    /// A bounded task answer; the versioned cursor carries the connection position, how
4624    /// many rows of the page starting there were already handed out, and the own-write ids
4625    /// already observed, including across a new source instance.
4626    ///
4627    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4628    /// sliced from the pages it needs; why is the module documentation's paging contract.
4629    async fn search_tasks(
4630        &self,
4631        query: &TaskQuery,
4632        page: &PageRequest,
4633        also: &str,
4634    ) -> Result<Page<Task>, SourceError> {
4635        let mut position = match &page.cursor {
4636            None => SearchPosition::default(),
4637            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4638                .ok()
4639                .filter(|position| {
4640                    position.version == SEARCH_CURSOR_VERSION
4641                        && position.connection.valid_resume(position.offset)
4642                })
4643                .ok_or_else(|| SourceError::Config {
4644                    message: "page cursor is invalid".into(),
4645                })?,
4646        };
4647        let search = self.board_search(Some(also));
4648        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4649        let own = self.with_own_writes(Vec::new())?;
4650        // An issue this process commented on is a candidate of a comment-activity read
4651        // whether or not the search has caught up with the comment; see `Self::commented`.
4652        let commented = match query.commented_since {
4653            Some(_) => self.commented()?.clone(),
4654            None => Vec::new(),
4655        };
4656        for id in own.iter().map(|item| &item.id).chain(&commented) {
4657            if !position.own.contains(id) {
4658                position.own.push(id.clone());
4659            }
4660        }
4661        let mut tasks = Vec::new();
4662        while !position.connection.exhausted() && tasks.len() < limit {
4663            let first = SEARCH_PAGE_SIZE;
4664            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4665            let key =
4666                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4667                    .expect("search page key is serializable");
4668            let cached = if query.commented_since.is_none() {
4669                self.narrowed_cache()?.get(&key).cloned()
4670            } else {
4671                None
4672            };
4673            let (found, next) = match cached {
4674                Some(found) => {
4675                    let next = self
4676                        .search_next
4677                        .lock()
4678                        .map_err(|_| SourceError::Unavailable {
4679                            message:
4680                                "search pagination was left inconsistent; run the command again"
4681                                    .into(),
4682                        })?
4683                        .get(&key)
4684                        .cloned()
4685                        .flatten();
4686                    (found, next)
4687                }
4688                None => {
4689                    let (found, next) = self
4690                        .search_page(&search, first, position.connection.after())
4691                        .await?;
4692                    if query.commented_since.is_none() {
4693                        self.search_next
4694                            .lock()
4695                            .map_err(|_| SourceError::Unavailable {
4696                                message:
4697                                    "search pagination was left inconsistent; run the command again"
4698                                        .into(),
4699                            })?
4700                            .insert(key.clone(), next.clone());
4701                        self.narrowed_cache()?.insert(key, found.clone());
4702                    }
4703                    (found, next)
4704                }
4705            };
4706            let rows = found.len();
4707            for mut item in found.into_iter().skip(position.offset) {
4708                if tasks.len() == limit {
4709                    break;
4710                }
4711                position.offset += 1;
4712                if position.own.contains(&item.id) {
4713                    if position.seen.contains(&item.id) {
4714                        continue;
4715                    }
4716                    position.seen.push(item.id.clone());
4717                    // The search's own copy of an issue this process only commented on is as
4718                    // good as a node read of it, since its comments are read either way.
4719                    let only_commented = commented.contains(&item.id)
4720                        && !own.iter().any(|written| written.id == item.id);
4721                    if !only_commented {
4722                        let updated_at = item.updated_at;
4723                        let Some(written) = self.search_written(&own, &item.id).await? else {
4724                            continue;
4725                        };
4726                        item = written;
4727                        item.updated_at = item.updated_at.max(updated_at);
4728                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
4729                    }
4730                }
4731                if item.kind == BoardKind::Work(ItemKind::Task) {
4732                    let task = item.task()?;
4733                    if task_matches(&task, query, &query.project)
4734                        && self.commented_since(&item, query.commented_since).await?
4735                    {
4736                        tasks.push(task);
4737                    }
4738                }
4739            }
4740            if position.offset < rows {
4741                continue;
4742            }
4743            position.offset = 0;
4744            position.connection = match next {
4745                Some(after) => SearchConnection::Continuing {
4746                    after: Cursor(after),
4747                },
4748                None => SearchConnection::Exhausted {},
4749            };
4750        }
4751        if position.connection.exhausted() {
4752            for id in position.own.clone() {
4753                if position.seen.contains(&id) {
4754                    continue;
4755                }
4756                if tasks.len() == limit {
4757                    break;
4758                }
4759                position.seen.push(id.clone());
4760                let Some(item) = self.search_written(&own, &id).await? else {
4761                    continue;
4762                };
4763                if item.kind == BoardKind::Work(ItemKind::Task) {
4764                    let task = item.task()?;
4765                    if task_matches(&task, query, &query.project)
4766                        && self.commented_since(&item, query.commented_since).await?
4767                    {
4768                        tasks.push(task);
4769                    }
4770                }
4771            }
4772        }
4773        let more = !position.connection.exhausted()
4774            || position.own.iter().any(|id| !position.seen.contains(id));
4775        Ok(Page {
4776            items: tasks,
4777            next: more.then(|| {
4778                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4779            }),
4780        })
4781    }
4782
4783    /// A resumed process has the ids but no write records; resolve only a record the
4784    /// current page needs, by its uncached node read rather than the lagging search index.
4785    async fn search_written(
4786        &self,
4787        own: &[Resolved],
4788        id: &NativeId,
4789    ) -> Result<Option<Resolved>, SourceError> {
4790        match own.iter().find(|item| item.id == *id) {
4791            Some(item) => Ok(Some(item.clone())),
4792            None => self.item_by_id(id).await,
4793        }
4794    }
4795
4796    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4797    /// without enumerating the board — or `None` for a query carrying none of the three, which
4798    /// keeps the reads it always had.
4799    ///
4800    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4801    /// because it names at most a handful of items. Text and metadata are answered by one
4802    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4803    /// further by `updated:>=` when the query also asks for comment activity, since both
4804    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4805    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4806    ///
4807    /// Completed with what this process wrote, its own record winning over the index's copy
4808    /// of the same item: see [`Self::with_own_writes`].
4809    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4810        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4811            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4812            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4813                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4814                None => qualifiers,
4815            }),
4816            (None, None) => return Ok(None),
4817        };
4818        // A question about comment activity is asked afresh every time, as it always was: it
4819        // is the one a caller polls from one source while waiting for the index, and an
4820        // answer held from the first poll would be the answer to every later one.
4821        let key = query.commented_since.is_none().then(|| asked.key());
4822        let cached = match &key {
4823            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4824            None => None,
4825        };
4826        let found = match cached {
4827            Some(found) => found,
4828            None => {
4829                let found = match &asked {
4830                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4831                    Narrowing::Search(also) => self.searched(also).await?,
4832                };
4833                if let Some(key) = key {
4834                    self.narrowed_cache()?.insert(key, found.clone());
4835                }
4836                found
4837            }
4838        };
4839        self.with_own_writes(found).map(Some)
4840    }
4841
4842    /// The candidates for a project or unscoped document query carrying a searchable text,
4843    /// read without enumerating the board — or `None` for a query with no text or a blank one,
4844    /// which keeps the read it always had.
4845    ///
4846    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4847    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4848    /// costs is the issues that match and never the board. Its answer is held for the command
4849    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4850    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4851    /// substring rule, exactly as an item of the wider read was, and is completed with what this
4852    /// process wrote: see [`Self::with_own_writes`].
4853    async fn text_searched(
4854        &self,
4855        text: Option<&TextQuery>,
4856    ) -> Result<Option<Vec<Resolved>>, SourceError> {
4857        let Some(also) = text_qualifiers(text) else {
4858            return Ok(None);
4859        };
4860        let key = Narrowing::Search(also.clone()).key();
4861        let cached = self.narrowed_cache()?.get(&key).cloned();
4862        let found = match cached {
4863            Some(found) => found,
4864            None => {
4865                let found = self.searched(&also).await?;
4866                self.narrowed_cache()?.insert(key, found.clone());
4867                found
4868            }
4869        };
4870        self.with_own_writes(found).map(Some)
4871    }
4872
4873    /// Every item of this board that may carry `origin` — a superset of those that do — found
4874    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4875    ///
4876    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4877    /// which reads the field every carrier holds, whichever release wrote it — and the
4878    /// board-scoped issue search for the same id as a phrase in the body, where this source
4879    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4880    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4881    /// query's, exactly.
4882    ///
4883    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4884    /// already ended is sent its last cursor again, which answers an empty page, so the one
4885    /// document serves every page of either. What the two leave is stated in the module
4886    /// documentation: a carrier another process added within the last second or two, before
4887    /// either index has it.
4888    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4889        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4890        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4891        let mut items_after: Option<String> = None;
4892        let mut search_after: Option<String> = None;
4893        let mut found: Vec<Resolved> = Vec::new();
4894        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4895            if !found.iter().any(|held| held.id == resolved.id) {
4896                found.push(resolved);
4897            }
4898        };
4899        loop {
4900            let data = self
4901                .graphql(
4902                    graphql::ORIGIN_LOOKUP,
4903                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4904                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4905                           "itemsAfter":items_after,"searchAfter":search_after,
4906                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4907                           "duplicates":true}),
4908                )
4909                .await?;
4910            let items = data
4911                .pointer("/originItems/projectV2/items")
4912                .filter(|value| !value.is_null())
4913                .ok_or_else(|| SourceError::Refused {
4914                    message: format!(
4915                        "GitHub project {}/{} was not found or is not visible to the token",
4916                        self.owner, self.project_number
4917                    ),
4918                })?;
4919            for item in optional_nodes(Some(items), "project items")?
4920                .into_iter()
4921                .flatten()
4922            {
4923                // The board's own items list its drafts too, and a draft is not an issue: no
4924                // narrowed read answers with one, whatever its origin field holds.
4925                if let Some(resolved) = self.resolve(item)?
4926                    && resolved.content_kind == ContentKind::Issue
4927                {
4928                    keep(resolved, &mut found);
4929                }
4930            }
4931            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4932                message: "GitHub search response has no search connection".into(),
4933            })?;
4934            for node in optional_nodes(Some(searched), "search")?
4935                .into_iter()
4936                .flatten()
4937            {
4938                if let Some(resolved) = self.resolve_issue(node).await? {
4939                    keep(resolved, &mut found);
4940                }
4941            }
4942            let items_next = resumed(items, items_after.as_deref())?;
4943            let search_next = resumed(searched, search_after.as_deref())?;
4944            if !items_next.has_more() && !search_next.has_more() {
4945                return Ok(found);
4946            }
4947            items_after = items_next.cursor();
4948            search_after = search_next.cursor();
4949        }
4950    }
4951
4952    /// `found`, with every item this process created or wrote in its place, and every one of
4953    /// them the read did not report added.
4954    ///
4955    /// This process's own record wins over the read's copy of the same item, because a read
4956    /// of an item written moments ago can still be behind what was written onto it — the
4957    /// origin field included, which is the one a narrowed read is confirmed against — and a
4958    /// read that still names an item under a predicate this process's write moved it out of
4959    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4960    /// last saw the item change, which is what a comment-activity read rules a candidate out
4961    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4962    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4963    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4964        // A board draft is not an issue, so no narrowed read returns one, and this process
4965        // having written one does not make it an answer either.
4966        let own: Vec<Resolved> = self
4967            .created()?
4968            .iter()
4969            .chain(self.updated()?.iter())
4970            .filter(|own| own.content_kind == ContentKind::Issue)
4971            .cloned()
4972            .collect();
4973        for mut own in own {
4974            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4975            match found.iter_mut().find(|read| read.id == own.id) {
4976                Some(read) => {
4977                    own.updated_at = own.updated_at.max(read.updated_at);
4978                    *read = own;
4979                }
4980                None => found.push(own),
4981            }
4982        }
4983        Ok(found)
4984    }
4985
4986    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4987    /// there is no instant to hold it to.
4988    ///
4989    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4990    /// or after the instant moved it there: an issue not updated since holds no such comment,
4991    /// and its comments are never asked for — unless this process commented on it in this
4992    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
4993    /// only as far as the first that matches. A board draft is not an issue and has no
4994    /// comments, so it never matches.
4995    async fn commented_since(
4996        &self,
4997        item: &Resolved,
4998        since: Option<DateTime<Utc>>,
4999    ) -> Result<bool, SourceError> {
5000        let Some(since) = since else {
5001            return Ok(true);
5002        };
5003        if item.content_kind == ContentKind::DraftIssue {
5004            return Ok(false);
5005        }
5006        // An `updatedAt` this process's own record or a lagging index holds can predate a
5007        // comment this process wrote since, so only an issue it did not comment on is ruled
5008        // out by one.
5009        if item.updated_at.is_some_and(|updated| updated < since)
5010            && !self.commented()?.contains(&item.id)
5011        {
5012            return Ok(false);
5013        }
5014        let query = TaskQuery {
5015            commented_since: Some(since),
5016            ..TaskQuery::default()
5017        };
5018        let mut after: Option<String> = None;
5019        loop {
5020            let data = self
5021                .graphql(
5022                    graphql::ISSUE_COMMENTS,
5023                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5024                )
5025                .await?;
5026            let Some(connection) = data
5027                .get("node")
5028                .filter(|value| !value.is_null())
5029                .and_then(|node| node.get("comments"))
5030                .filter(|value| !value.is_null())
5031            else {
5032                // Removed since the search reported it: no longer an issue with comments.
5033                return Ok(false);
5034            };
5035            let comments = optional_nodes(Some(connection), "issue comments")?
5036                .into_iter()
5037                .flatten()
5038                .map(comment_from)
5039                .collect::<Result<Vec<_>, _>>()?;
5040            if query.comments_match(&comments) {
5041                return Ok(true);
5042            }
5043            match next_cursor(connection)? {
5044                Some(next) => {
5045                    validate_cursor_progress(after.as_deref(), &next.0)?;
5046                    after = Some(next.0);
5047                }
5048                None => return Ok(false),
5049            }
5050        }
5051    }
5052
5053    /// Every item on the board: the union of both enumerations GitHub offers of one.
5054    ///
5055    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5056    /// board **draft** and reads the board's own fields beside its items, and only the search
5057    /// reports an item that connection is behind on. The module documentation is where the lag and the
5058    /// measurements behind it are written down.
5059    ///
5060    /// A search result is admitted on the same terms as any other issue this source reaches
5061    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5062    /// names *this* board — so an issue the index still believes is here after it was taken
5063    /// off is refused rather than reported.
5064    ///
5065    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5066    /// which is what the cache could otherwise have broken.
5067    async fn board(&self) -> Result<Board, SourceError> {
5068        let cached = self.board_cache()?.clone();
5069        let mut board = match cached {
5070            Some(board) => board,
5071            None => {
5072                let read = self.read_board().await?;
5073                *self.board_cache()? = Some(read.clone());
5074                read
5075            }
5076        };
5077        for held in self.searched_issues().await? {
5078            if !board.items.iter().any(|item| item.id == held.id) {
5079                board.items.push(held);
5080            }
5081        }
5082        for own in self.created()?.iter() {
5083            if !board.items.iter().any(|item| item.id == own.id) {
5084                board.items.push(own.clone());
5085            }
5086        }
5087        Ok(board)
5088    }
5089
5090    /// This process's own view of the board, or the refusal a poisoned lock is.
5091    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5092        self.board_cache
5093            .lock()
5094            .map_err(|_| SourceError::Unavailable {
5095                message: "this source's view of the board was left inconsistent by an earlier \
5096                      failure; next: run the command again"
5097                    .into(),
5098            })
5099    }
5100
5101    /// Bring this process's own view of the board up to an item it has just written.
5102    ///
5103    /// A created item goes to `created`, which is what completes a board read GitHub's own
5104    /// eventual consistency has left behind. An item that was already there is replaced
5105    /// where it sits, so a second write of it in the same command reads its real parent
5106    /// rather than the one it had before the first write.
5107    ///
5108    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5109    /// that wins: an item this same run created is held in `created` and not in the cached
5110    /// board, and `board` completes the cached board *from* `created`, so replacing only
5111    /// the cached copy of such an item replaces nothing and the read still reports the
5112    /// title it was created with. The search is the third, and it is the one an item the
5113    /// board's own projection is behind on sits in *alone* — which is exactly the item this
5114    /// source is least able to re-read, so leaving it out would put the stale title back on
5115    /// the only items the completion in [`Self::board`] exists for.
5116    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5117        self.resolved_cache()?.insert(item.id.clone(), item.clone());
5118        if created {
5119            self.created()?.push(item);
5120            return Ok(());
5121        }
5122        {
5123            let mut own = self.created()?;
5124            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5125                *held = item;
5126                return Ok(());
5127            }
5128        }
5129        {
5130            let mut own = self.updated()?;
5131            match own.iter_mut().find(|held| held.id == item.id) {
5132                Some(held) => *held = item.clone(),
5133                None => own.push(item.clone()),
5134            }
5135        }
5136        if let Some(board) = self.board_cache()?.as_mut()
5137            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5138        {
5139            *held = item.clone();
5140        }
5141        if let Some(found) = self.search_cache()?.as_mut()
5142            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5143        {
5144            *held = item.clone();
5145        }
5146        for found in self.narrowed_cache()?.values_mut() {
5147            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5148                *held = item.clone();
5149            }
5150        }
5151        Ok(())
5152    }
5153
5154    /// Forget one item this process has just deleted, from every half of its own view.
5155    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5156        self.resolved_cache()?.remove(id);
5157        self.created()?.retain(|own| own.id != *id);
5158        self.updated()?.retain(|own| own.id != *id);
5159        self.commented()?.retain(|own| own != id);
5160        if let Some(board) = self.board_cache()?.as_mut() {
5161            board.items.retain(|item| item.id != *id);
5162        }
5163        if let Some(found) = self.search_cache()?.as_mut() {
5164            found.retain(|item| item.id != *id);
5165        }
5166        for found in self.narrowed_cache()?.values_mut() {
5167            found.retain(|item| item.id != *id);
5168        }
5169        Ok(())
5170    }
5171
5172    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5173    fn narrowed_cache(
5174        &self,
5175    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5176        self.narrowed_cache
5177            .lock()
5178            .map_err(|_| SourceError::Unavailable {
5179                message: "this source's view of a narrowed read was left inconsistent by an \
5180                      earlier failure; next: run the command again"
5181                    .into(),
5182            })
5183    }
5184
5185    /// Every page of the board, read from GitHub.
5186    async fn read_board(&self) -> Result<Board, SourceError> {
5187        let mut after: Option<String> = None;
5188        let mut items = Vec::new();
5189        let mut board;
5190        loop {
5191            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5192            for item in page
5193                .pointer("/items/nodes")
5194                .and_then(Value::as_array)
5195                .ok_or_else(|| SourceError::Malformed {
5196                    message: "GitHub project items.nodes is not an array".into(),
5197                })?
5198            {
5199                if let Some(resolved) = self.resolve(item)? {
5200                    items.push(resolved);
5201                }
5202            }
5203            let info = page
5204                .pointer("/items/pageInfo")
5205                .ok_or_else(|| SourceError::Malformed {
5206                    message: "GitHub project items have no pageInfo".into(),
5207                })?;
5208            let has_next = required_bool(info, "hasNextPage")?;
5209            let next = has_next
5210                .then(|| required_str(info, "endCursor"))
5211                .transpose()?;
5212            board = page.clone();
5213            match next {
5214                Some(next) => {
5215                    validate_cursor_progress(after.as_deref(), next)?;
5216                    after = Some(next.to_owned());
5217                }
5218                None => break,
5219            }
5220        }
5221        Ok(Board {
5222            id: required_str(&board, "id")?.to_owned(),
5223            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5224            items,
5225        })
5226    }
5227
5228    /// The existing items this source has written, for completing a narrowed read that is
5229    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5230    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5231        self.updated.lock().map_err(|_| SourceError::Unavailable {
5232            message: "this source's record of what it wrote in this run was left inconsistent \
5233                      by an earlier failure; next: run the command again"
5234                .into(),
5235        })
5236    }
5237
5238    /// The issues this source has commented on in this command; see
5239    /// [`Self::commented`](GitHubProjectsSource::commented).
5240    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
5241        self.commented.lock().map_err(|_| SourceError::Unavailable {
5242            message: "this source's record of what it commented on in this run was left \
5243                      inconsistent by an earlier failure; next: run the command again"
5244                .into(),
5245        })
5246    }
5247
5248    /// Called only once GitHub has answered the comment write, so an issue whose comment
5249    /// failed is never made a candidate a later read would pay a node read for.
5250    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
5251        let mut commented = self.commented()?;
5252        if !commented.contains(issue) {
5253            commented.push(issue.clone());
5254        }
5255        Ok(())
5256    }
5257
5258    /// The items this source has created, for completing a board read that is behind.
5259    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5260        self.created.lock().map_err(|_| SourceError::Unavailable {
5261            message: "this source's record of what it created in this run was left \
5262                      inconsistent by an earlier failure; next: run the command again"
5263                .into(),
5264        })
5265    }
5266
5267    /// One board item as this source reports it, or `None` for content it ignores.
5268    ///
5269    /// A pull request is neither a project nor a task — it is somebody's change, not a
5270    /// unit of plan — and an item whose content the token cannot see has nothing to
5271    /// report at all.
5272    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5273        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5274            message: "GitHub project item is missing content".into(),
5275        })?;
5276        if content.is_null() {
5277            return Ok(None);
5278        }
5279        let content_kind = match required_str(content, "__typename")? {
5280            "Issue" => ContentKind::Issue,
5281            "DraftIssue" => ContentKind::DraftIssue,
5282            _ => return Ok(None),
5283        };
5284        let field_values = item
5285            .get("fieldValues")
5286            .ok_or_else(|| SourceError::Malformed {
5287                message: "GitHub project item is missing fieldValues".into(),
5288            })?;
5289        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5290        let nodes = field_values
5291            .get("nodes")
5292            .and_then(Value::as_array)
5293            .ok_or_else(|| SourceError::Malformed {
5294                message: "GitHub project item fieldValues.nodes is not an array".into(),
5295            })?;
5296        if let Some(labels) = content.get("labels") {
5297            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5298        }
5299        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5300        let (body, slot) = metadata_body(raw_body.clone())?;
5301        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5302            .map(|id| NativeId(id.to_owned()));
5303        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5304        // to read one from; it is a task, and never a project.
5305        let sub_issues = match content_kind {
5306            ContentKind::Issue => sub_issue_total(content)?,
5307            ContentKind::DraftIssue => 0,
5308        };
5309        let content_id = required_str(content, "id")?;
5310        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5311            message: format!("GitHub issue {content_id}: {message}"),
5312        })?;
5313        let raw_title = required_str(content, "title")?;
5314        // The design prefix is read *first*, before either of the two rules that separate
5315        // a project from a task. A document is not work whatever sub-issues it has and
5316        // whatever marker it carries, and reading the prefix later would make a design
5317        // issue with none of either an empty project.
5318        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5319            BoardKind::Document
5320        } else if parent.is_some() {
5321            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5322            // under a project is that project's task even when it has sub-issues of its
5323            // own.
5324            BoardKind::Work(ItemKind::Task)
5325        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5326            BoardKind::Work(ItemKind::Project)
5327        } else {
5328            BoardKind::Work(ItemKind::Task)
5329        };
5330        // The title a person wrote, which for a document is the one without the prefix —
5331        // the same way `content` above is the body without this source's metadata slot.
5332        let title = match kind {
5333            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5334            BoardKind::Work(_) => raw_title.to_owned(),
5335        };
5336        let own_repository = content
5337            .pointer("/repository/nameWithOwner")
5338            .and_then(Value::as_str)
5339            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5340            .transpose()
5341            .map_err(|message| SourceError::Malformed { message })?;
5342        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5343            Repository::from_metadata(&slot)
5344                .map_err(|message| SourceError::Malformed { message })?
5345        } else {
5346            own_repository.clone().into_iter().collect()
5347        };
5348        let id = NativeId(content_id.to_owned());
5349        // Read only for a task, because only a task has either list: a project or a
5350        // document holding one of these keys holds nothing this source reports, and the
5351        // keys are left out of its caller-visible metadata all the same.
5352        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5353            let listed = |key: &str| {
5354                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5355                    .map_err(|message| SourceError::Malformed { message })
5356            };
5357            (
5358                listed(TaskRef::DELIVERS_KEY)?,
5359                listed(TaskRef::DELIVERED_BY_KEY)?,
5360            )
5361        } else {
5362            (Vec::new(), Vec::new())
5363        };
5364        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5365        let priority = self.held_priority(nodes)?;
5366        // Present when the item was reached through its own issue, whose board entry
5367        // names the board; a read of the board's own items has the board already. An
5368        // empty id names nothing a field write could address, so it is read as absent and
5369        // the write goes back to reading the board.
5370        let board_id = item
5371            .pointer("/project/id")
5372            .and_then(Value::as_str)
5373            .filter(|id| !id.is_empty());
5374        let resolved = Resolved {
5375            item_id: required_str(item, "id")?.to_owned(),
5376            id,
5377            content_kind,
5378            kind,
5379            title,
5380            body: body.filter(|value| !value.is_empty()),
5381            raw_body,
5382            status: self
5383                .statuses
5384                .status(kind.status_kind(), option, closed, reason),
5385            option: option.map(str::to_owned),
5386            priority,
5387            closed,
5388            delivers,
5389            delivered_by,
5390            labels: labels(content)?,
5391            parent,
5392            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5393            number: match content_kind {
5394                ContentKind::Issue => Some(issue_number(content)?),
5395                // A draft is filed in no repository, so nothing ever numbered it:
5396                // `DraftIssue` declares no `number` at all, exactly as it declares no
5397                // `subIssuesSummary` the branch above reads.
5398                ContentKind::DraftIssue => None,
5399            },
5400            url: optional_str(content, "url")?.map(str::to_owned),
5401            created_at: optional_time(content, "createdAt")?,
5402            updated_at: optional_time(content, "updatedAt")?,
5403            own_repository,
5404            repositories,
5405            slot,
5406            board_id: board_id.map(str::to_owned),
5407            fields: field_definitions(nodes),
5408            board_fields: Self::carried_board_fields(content, board_id)?,
5409            blocked_by: carried_blocked_by(content)?,
5410        };
5411        self.resolved_cache()?
5412            .insert(resolved.id.clone(), resolved.clone());
5413        Ok(Some(resolved))
5414    }
5415
5416    /// The field definitions of the board `board_id` names — the project this issue's own
5417    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5418    /// `None` when the read carried none, carried no entry for that board, or the board item
5419    /// named no board, which a write then answers by reading the board's fields itself.
5420    ///
5421    /// Matched by the board's node id and never by its number alone: a project number is
5422    /// unique only within its owner, so another owner's board numbered alike can sit on the
5423    /// same page, and its field and option ids address nothing on this one.
5424    fn carried_board_fields(
5425        content: &Value,
5426        board_id: Option<&str>,
5427    ) -> Result<Option<Value>, SourceError> {
5428        let (Some(nodes), Some(board_id)) = (
5429            content.pointer("/boards/nodes").and_then(Value::as_array),
5430            board_id,
5431        ) else {
5432            return Ok(None);
5433        };
5434        let Some(board) = nodes.iter().find_map(|node| {
5435            let project = node.get("project")?;
5436            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5437        }) else {
5438            return Ok(None);
5439        };
5440        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5441            return Ok(None);
5442        };
5443        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5444        Ok(Some(fields.clone()))
5445    }
5446
5447    /// What one board item's `Priority` field says, through this instance's mapping.
5448    ///
5449    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5450    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5451    /// option the mapping does not name is kept as itself — never read as a level or as
5452    /// `none` — for a read of the task to report by name.
5453    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5454        let Some(mapping) = &self.priorities else {
5455            return Ok(HeldPriority::Read(Priority::None));
5456        };
5457        // A value of the field that names no option — a text field someone called `Priority` —
5458        // is malformed rather than `none`: reading it as no priority would let the next copy
5459        // clear one a person set.
5460        let Some(option) = field_values
5461            .iter()
5462            .find(|value| {
5463                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5464            })
5465            .map(|value| required_str(value, "name"))
5466            .transpose()?
5467        else {
5468            return Ok(HeldPriority::Read(Priority::None));
5469        };
5470        Ok(mapping.priority_of(option).map_or_else(
5471            || HeldPriority::Unmapped(option.to_owned()),
5472            HeldPriority::Read,
5473        ))
5474    }
5475
5476    /// What one board item's status is read from: its `Status` option, whether its issue
5477    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5478    /// three into the status it reports.
5479    fn status_parts<'a>(
5480        field_values: &'a [Value],
5481        content: &'a Value,
5482    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5483        let option = field_values
5484            .iter()
5485            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5486            .map(|value| required_str(value, "name"))
5487            .transpose()?;
5488        let closed = optional_str(content, "state")? == Some("CLOSED");
5489        Ok((option, closed, optional_str(content, "stateReason")?))
5490    }
5491
5492    /// The board Status option this write selects, or the refusal that says why not.
5493    ///
5494    /// The mapped option is required for both open and terminal targets. A terminal write
5495    /// validates it before changing either representation, so it can never fall back to
5496    /// closing an issue whose board cannot display the matching status.
5497    ///
5498    /// Answers the field's id, the option's id, and the option's name as the board spells
5499    /// it — which is the name a read of the item reports once it sits there.
5500    fn column_for(
5501        &self,
5502        fields: &Value,
5503        kind: ItemKind,
5504        category: StatusCategory,
5505        target: &StatusTarget,
5506    ) -> Result<Option<(String, String, String)>, SourceError> {
5507        let Some(wanted) = target.option() else {
5508            return Ok(None);
5509        };
5510        let missing = |detail: &str| SourceError::Refused {
5511            message: format!(
5512                "{} status {} of source {} needs the board Status option {wanted:?}, and \
5513                 {detail}; next: add that option to the board, which `onetaskgraph sources \
5514                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5515                 it has",
5516                kind.marker(),
5517                category_name(category),
5518                self.name,
5519                self.name,
5520                category_name(category),
5521                kind.marker()
5522            ),
5523        };
5524        let Some(field) = Board::field(fields, "Status")? else {
5525            return Err(missing("this board has no Status field"));
5526        };
5527        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5528            return Err(missing(
5529                "this board's Status field is not a single-select field",
5530            ));
5531        }
5532        let option = field
5533            .get("options")
5534            .and_then(Value::as_array)
5535            .and_then(|options| {
5536                options.iter().find(|option| {
5537                    option
5538                        .get("name")
5539                        .and_then(Value::as_str)
5540                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5541                })
5542            });
5543        match option {
5544            None => Err(missing("this board does not have it")),
5545            Some(option) => Ok(Some((
5546                required_str(field, "id")?.to_owned(),
5547                required_str(option, "id")?.to_owned(),
5548                required_str(option, "name")?.to_owned(),
5549            ))),
5550        }
5551    }
5552
5553    /// The refusal a status that closes an issue is answered with over a board draft.
5554    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5555        SourceError::Refused {
5556            message: format!(
5557                "status {} of source {} closes the item's issue, and GitHub draft items have \
5558                 no open or closed state",
5559                category_name(category),
5560                self.name
5561            ),
5562        }
5563    }
5564
5565    /// What a status write to one item needs of the board: the board's id and the
5566    /// definition of its `Status` field, read off the item when the item says both.
5567    ///
5568    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5569    /// and its `Status` value carries that field's definition, options and all. An item that
5570    /// does not say — no board id, or no `Status` value to read the field off — takes them
5571    /// from [`Self::board_fields`], which reads no item.
5572    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5573        if let Some(board) = item.carried_board() {
5574            return Ok(board);
5575        }
5576        if item.defines("Status")
5577            && let Some(board_id) = item.named_board()
5578        {
5579            return Ok(BoardFields {
5580                id: board_id,
5581                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5582            });
5583        }
5584        self.board_fields().await
5585    }
5586
5587    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5588    async fn set_status(
5589        &self,
5590        id: &NativeId,
5591        category: StatusCategory,
5592    ) -> Result<Option<Status>, SourceError> {
5593        // Refused before anything is read, in the words a write of the same status is.
5594        let target = self.resolved_target(ItemKind::Task, category)?;
5595        let Some(mut item) = self
5596            .bound_item(id)
5597            .await?
5598            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5599        else {
5600            return Ok(None);
5601        };
5602        let board = self.status_board(&item).await?;
5603        let (field, option, name) = self
5604            .column_for(&board.fields, ItemKind::Task, category, &target)?
5605            .ok_or_else(|| SourceError::Malformed {
5606                message: format!(
5607                    "status {} of source {} names no board Status option",
5608                    category_name(category),
5609                    self.name
5610                ),
5611            })?;
5612        if item.status.category == category && item.option.as_deref() == Some(&name) {
5613            return Ok(Some(item.status));
5614        }
5615        match &target {
5616            StatusTarget::Terminal(_, reason) => {
5617                if item.content_kind == ContentKind::DraftIssue {
5618                    return Err(self.closes_a_draft(category));
5619                }
5620                self.set_item_field(
5621                    board.id.as_str(),
5622                    &item.item_id,
5623                    &field,
5624                    json!({"singleSelectOptionId": option}),
5625                )
5626                .await?;
5627                self.update_content(
5628                    ContentKind::Issue,
5629                    &item.id,
5630                    json!({"stateInput": state_input(Some(&target))}),
5631                )
5632                .await?;
5633                item.closed = true;
5634                item.status =
5635                    self.statuses
5636                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5637                item.option = Some(name);
5638            }
5639            StatusTarget::Column(_) => {
5640                // An option is what an open item's status is, so a closed issue is reopened
5641                // first — sitting closed in the column, it would read back as closed. A draft has
5642                // no state to reopen.
5643                if item.content_kind == ContentKind::Issue && item.closed {
5644                    self.update_content(
5645                        ContentKind::Issue,
5646                        &item.id,
5647                        json!({"stateInput": state_input(Some(&target))}),
5648                    )
5649                    .await?;
5650                    item.closed = false;
5651                }
5652                self.set_item_field(
5653                    board.id.as_str(),
5654                    &item.item_id,
5655                    &field,
5656                    json!({"singleSelectOptionId": option}),
5657                )
5658                .await?;
5659                item.status = self
5660                    .statuses
5661                    .status(ItemKind::Task, Some(&name), false, None);
5662                item.option = Some(name);
5663            }
5664            StatusTarget::Disabled(_) => {
5665                unreachable!("resolved_target refused a disabled status")
5666            }
5667        }
5668        let status = item.status.clone();
5669        self.remember_written(item, false)?;
5670        Ok(Some(status))
5671    }
5672
5673    /// Replace one task's `delivered_by` and nothing else; see
5674    /// [`TaskSource::set_delivered_by`].
5675    ///
5676    /// One update of the body, which differs from the body GitHub holds only inside the
5677    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5678    async fn replace_delivered_by(
5679        &self,
5680        id: &NativeId,
5681        delivered_by: &[TaskRef],
5682    ) -> Result<Option<()>, SourceError> {
5683        let entries = TaskRef::listed(
5684            TaskRef::DELIVERED_BY_KEY,
5685            id,
5686            Some(&self.name),
5687            delivered_by.to_vec(),
5688        )
5689        .map_err(|message| SourceError::Refused { message })?;
5690        let Some(mut item) = self
5691            .bound_item(id)
5692            .await?
5693            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5694        else {
5695            return Ok(None);
5696        };
5697        let mut slot = item.slot.clone();
5698        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5699        self.write_slot(&mut item, &slot).await?;
5700        item.delivered_by = entries;
5701        self.remember_written(item, false)?;
5702        Ok(Some(()))
5703    }
5704
5705    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5706    /// see [`TaskSource::set_task_metadata`].
5707    ///
5708    /// `None` when this board holds no item by that id, or holds one of another kind. The
5709    /// answer is the item as this source now reads it, so what a caller is told the key
5710    /// holds is what the slot holds.
5711    ///
5712    /// A key already holding the value is answered without a write, compared as JSON rather
5713    /// than as the body's bytes: a slot a person spelled with other whitespace would
5714    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5715    async fn set_slot_key(
5716        &self,
5717        id: &NativeId,
5718        kind: BoardKind,
5719        key: &MetadataKey,
5720        value: &Value,
5721    ) -> Result<Option<Resolved>, SourceError> {
5722        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5723            return Ok(None);
5724        };
5725        if item.slot.get(key.as_str()) == Some(value) {
5726            return Ok(Some(item));
5727        }
5728        let mut slot = item.slot.clone();
5729        slot.insert(key.as_str().to_owned(), value.clone());
5730        self.write_slot(&mut item, &slot).await?;
5731        self.remember_written(item.clone(), false)?;
5732        Ok(Some(item))
5733    }
5734
5735    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5736    /// `item` up to what that write left.
5737    ///
5738    /// The body sent differs from the body GitHub holds only inside the slot — see
5739    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5740    /// the mutation the item's content takes, so a board draft's body is written with
5741    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5742    async fn write_slot(
5743        &self,
5744        item: &mut Resolved,
5745        slot: &BTreeMap<String, Value>,
5746    ) -> Result<(), SourceError> {
5747        let held = item.raw_body.clone().unwrap_or_default();
5748        let body = with_slot(&held, slot)?;
5749        if body != held {
5750            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5751                .await?;
5752        }
5753        let (visible, slot) = metadata_body(Some(body.clone()))?;
5754        item.body = visible.filter(|value| !value.is_empty());
5755        item.raw_body = Some(body);
5756        item.slot = slot;
5757        Ok(())
5758    }
5759
5760    /// This instance's target for a category written to an item of `kind`, refusing one
5761    /// that kind has no option for — before anything is read or written.
5762    ///
5763    /// Nothing here mutates the board's option set to make room for a status. GitHub
5764    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5765    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5766    /// field and every item's status.
5767    fn resolved_target(
5768        &self,
5769        kind: ItemKind,
5770        category: StatusCategory,
5771    ) -> Result<StatusTarget, SourceError> {
5772        let target = self.statuses.target(kind, category).clone();
5773        let StatusTarget::Disabled(why) = target else {
5774            return Ok(target);
5775        };
5776        let refusal = why.refusal(&self.name, category, kind);
5777        // Why there is no shipped default, which is the question a person meeting this
5778        // refusal on a source that never mentioned the category asks.
5779        let shipped_none = match category {
5780            StatusCategory::Draft => Some(
5781                "draft has no shipped default because GitHub draft issues cannot have \
5782                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5783            ),
5784            StatusCategory::Unknown => Some(
5785                "unknown has no shipped default because this board keeps no open-ended status \
5786                 word: every word classified unknown is written to the one board Status option \
5787                 status_mapping.unknown names",
5788            ),
5789            _ => None,
5790        };
5791        Err(match (refusal, shipped_none, why) {
5792            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5793                SourceError::Refused {
5794                    message: format!("{message}; {note}"),
5795                }
5796            }
5797            (refusal, _, _) => refusal,
5798        })
5799    }
5800
5801    /// What writing `priority` does to one item's `Priority` field on this board, or the
5802    /// refusal naming what the board lacks.
5803    ///
5804    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5805    /// none already, or of an item not created yet. Every other priority selects the option
5806    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5807    /// without that option, is refused rather than given one: reads and writes never create
5808    /// a field or an option.
5809    fn priority_write(
5810        &self,
5811        fields: &Value,
5812        existing: Option<&Resolved>,
5813        priority: Priority,
5814    ) -> Result<Option<PriorityWrite>, SourceError> {
5815        let Some(mapping) = &self.priorities else {
5816            return Err(self.holds_no_priority());
5817        };
5818        let Some(wanted) = mapping.option(priority) else {
5819            if !existing.is_some_and(Resolved::holds_priority) {
5820                return Ok(None);
5821            }
5822            let field =
5823                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5824                    message: format!(
5825                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5826                    ),
5827                })?;
5828            return Ok(Some(PriorityWrite::Clear {
5829                field: required_str(field, "id")?.to_owned(),
5830            }));
5831        };
5832        let missing = |detail: &str| SourceError::Refused {
5833            message: format!(
5834                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5835                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5836                 it, or point priority_mapping.{priority} of this source at an option the board \
5837                 has",
5838                self.name, self.name
5839            ),
5840        };
5841        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5842            return Err(missing(&format!(
5843                "this board has no {PRIORITY_FIELD} field"
5844            )));
5845        };
5846        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5847            return Err(missing(&format!(
5848                "this board's {PRIORITY_FIELD} field is not a single-select field"
5849            )));
5850        }
5851        // An options list that is absent or not a list is an answer this source cannot read,
5852        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5853        let option = field
5854            .get("options")
5855            .and_then(Value::as_array)
5856            .ok_or_else(|| SourceError::Malformed {
5857                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5858            })?
5859            .iter()
5860            .find(|option| {
5861                option
5862                    .get("name")
5863                    .and_then(Value::as_str)
5864                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5865            })
5866            .ok_or_else(|| missing("this board does not have it"))?;
5867        Ok(Some(PriorityWrite::Select {
5868            field: required_str(field, "id")?.to_owned(),
5869            option: required_str(option, "id")?.to_owned(),
5870        }))
5871    }
5872
5873    /// Apply one priority write to one board item.
5874    async fn write_priority(
5875        &self,
5876        board_id: &str,
5877        item_id: &str,
5878        write: &PriorityWrite,
5879    ) -> Result<(), SourceError> {
5880        match write {
5881            PriorityWrite::Select { field, option } => {
5882                self.set_item_field(
5883                    board_id,
5884                    item_id,
5885                    field,
5886                    json!({"singleSelectOptionId": option}),
5887                )
5888                .await
5889            }
5890            PriorityWrite::Clear { field } => {
5891                let data = self
5892                    .graphql(
5893                        graphql::CLEAR_FIELD,
5894                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5895                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5896                    )
5897                    .await?;
5898                let returned = data
5899                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5900                    .ok_or_else(|| SourceError::Malformed {
5901                        message: "GitHub field clear returned no project item".into(),
5902                    })?;
5903                if required_str(returned, "id")? != item_id {
5904                    return Err(SourceError::Malformed {
5905                        message: "GitHub field clear returned the wrong project item".into(),
5906                    });
5907                }
5908                Ok(())
5909            }
5910        }
5911    }
5912
5913    /// The refusal a priority is answered with by an instance configured with no
5914    /// `priority_mapping`, which holds none.
5915    fn holds_no_priority(&self) -> SourceError {
5916        SourceError::Refused {
5917            message: format!(
5918                "source {} holds no task priority: its configuration sets no priority_mapping; \
5919                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5920                 fields {} --apply` to set its board up",
5921                self.name, self.name
5922            ),
5923        }
5924    }
5925
5926    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5927    ///
5928    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5929    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5930    async fn set_priority(
5931        &self,
5932        id: &NativeId,
5933        priority: Priority,
5934    ) -> Result<Option<Priority>, SourceError> {
5935        if self.priorities.is_none() {
5936            return Err(self.holds_no_priority());
5937        }
5938        let Some(mut item) = self
5939            .bound_item(id)
5940            .await?
5941            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5942        else {
5943            return Ok(None);
5944        };
5945        if priority == Priority::None && !item.holds_priority() {
5946            return Ok(Some(priority));
5947        }
5948        // The item's own read carries the field's definition whenever it holds a value of
5949        // it, which a clear always does; a select onto an item holding none reads the board.
5950        let board = match (item.carried_board(), item.named_board()) {
5951            (Some(board), _) => board,
5952            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5953                id,
5954                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5955            },
5956            _ => self.board_fields().await?,
5957        };
5958        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5959            return Ok(Some(priority));
5960        };
5961        let (document, root, input) = match write {
5962            PriorityWrite::Select { field, option } => (
5963                graphql::UPDATE_FIELD,
5964                "updateProjectV2ItemFieldValue",
5965                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5966            ),
5967            PriorityWrite::Clear { field } => (
5968                graphql::CLEAR_FIELD,
5969                "clearProjectV2ItemFieldValue",
5970                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5971            ),
5972        };
5973        let data = self
5974            .graphql(
5975                document,
5976                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5977            )
5978            .await?;
5979        let returned = data
5980            .get(root)
5981            .and_then(|value| value.get("projectV2Item"))
5982            .ok_or_else(|| SourceError::Malformed {
5983                message: "GitHub priority write returned no project item".into(),
5984            })?;
5985        if required_str(returned, "id")? != item.item_id {
5986            return Err(SourceError::Malformed {
5987                message: "GitHub priority write returned the wrong project item".into(),
5988            });
5989        }
5990        let value = returned
5991            .get("fieldValueByName")
5992            .ok_or_else(|| SourceError::Malformed {
5993                message: "GitHub priority write returned no priority read-back".into(),
5994            })?;
5995        if !value.is_null()
5996            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5997        {
5998            return Err(SourceError::Malformed {
5999                message: "GitHub priority read-back is not a Priority field value".into(),
6000            });
6001        }
6002        let values = if value.is_null() {
6003            Vec::new()
6004        } else {
6005            vec![value.clone()]
6006        };
6007        item.priority = self.held_priority(&values)?;
6008        let answer = item.task()?.priority;
6009        self.remember_written(item, false)?;
6010        Ok(Some(answer))
6011    }
6012
6013    /// Replace one task's visible body and nothing else; see
6014    /// [`TaskSource::set_task_content`].
6015    ///
6016    /// One update of the body, which differs from the body GitHub holds only outside the
6017    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
6018    /// this source keeps there reads back as it was. A body that would not change is not
6019    /// sent at all.
6020    async fn replace_content(
6021        &self,
6022        id: &NativeId,
6023        content: &str,
6024    ) -> Result<Option<()>, SourceError> {
6025        let Some(mut item) = self
6026            .bound_item(id)
6027            .await?
6028            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6029        else {
6030            return Ok(None);
6031        };
6032        let held = item.raw_body.clone().unwrap_or_default();
6033        let body = with_content(&held, content)?;
6034        // Checked before anything is sent: content ending in what this source reads as its own
6035        // metadata slot would read back as metadata rather than as the content it was.
6036        let (visible, slot) = metadata_body(Some(body.clone()))?;
6037        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
6038            return Err(SourceError::Refused {
6039                message: format!(
6040                    "this content ends in what source {} reads as its own metadata slot \
6041                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6042                     as content; next: remove that trailing block from the content",
6043                    self.name
6044                ),
6045            });
6046        }
6047        if body != held {
6048            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6049                .await?;
6050        }
6051        item.body = visible.filter(|value| !value.is_empty());
6052        item.raw_body = Some(body);
6053        item.slot = slot;
6054        self.remember_written(item, false)?;
6055        Ok(Some(()))
6056    }
6057
6058    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
6059    ///
6060    /// One read of the item — which carries the board's field definitions and the issue's
6061    /// `blockedBy`, so neither is read again — and then only what differs from it: the
6062    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
6063    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
6064    /// the title, the body — visible content and metadata slot together — and a state change.
6065    /// So an update naming any of title, body, metadata, status and priority is one read and
6066    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
6067    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6068    /// as a whole write does; an open one selects its option and then reopens. The origin
6069    /// field is never written: an update is of an item that already exists, whose origin is
6070    /// what it is.
6071    ///
6072    /// The task answered is the item as those writes left it, built from the read and what was
6073    /// sent rather than read again — the same record a later read in this run answers from.
6074    async fn targeted_update(
6075        &self,
6076        id: &NativeId,
6077        update: &TaskUpdate,
6078    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6079        // Everything this source can refuse without reading the item is refused first, in the
6080        // words a whole write of the same fields is refused with.
6081        update.consistent()?;
6082        if update
6083            .title
6084            .as_deref()
6085            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6086        {
6087            return Err(SourceError::Refused {
6088                message: format!(
6089                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6090                     spells a document, so it would read back as one rather than as a task; \
6091                     retitle it",
6092                    self.name
6093                ),
6094            });
6095        }
6096        if let Some(delivers) = &update.delivers {
6097            TaskRef::listed(
6098                TaskRef::DELIVERS_KEY,
6099                id,
6100                Some(&self.name),
6101                delivers.clone(),
6102            )
6103            .map_err(|message| SourceError::Refused { message })?;
6104        }
6105        if self.priorities.is_none()
6106            && update
6107                .priority
6108                .is_some_and(|priority| priority != Priority::None)
6109        {
6110            return Err(self.holds_no_priority());
6111        }
6112        let target = update
6113            .status
6114            .as_ref()
6115            .map(|status| self.resolved_target(ItemKind::Task, status.category))
6116            .transpose()?;
6117        let Some(mut item) = self
6118            .bound_item(id)
6119            .await?
6120            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6121        else {
6122            return Ok(None);
6123        };
6124        let before = item.task()?;
6125
6126        let mut status_move = None;
6127        if let (Some(status), Some(target)) = (&update.status, target) {
6128            let board = self.status_board(&item).await?;
6129            let (field, option, name) = self
6130                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6131                .ok_or_else(|| SourceError::Malformed {
6132                    message: format!(
6133                        "status {} of source {} names no board Status option",
6134                        category_name(status.category),
6135                        self.name
6136                    ),
6137                })?;
6138            let terminal = matches!(target, StatusTarget::Terminal(_, _));
6139            if terminal && item.content_kind == ContentKind::DraftIssue {
6140                return Err(self.closes_a_draft(status.category));
6141            }
6142            let landed = match &target {
6143                StatusTarget::Terminal(_, reason) => {
6144                    self.statuses
6145                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6146                }
6147                _ => self
6148                    .statuses
6149                    .status(ItemKind::Task, Some(&name), false, None),
6150            };
6151            let option_moves = item
6152                .option
6153                .as_deref()
6154                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6155            let state_moves = item.content_kind == ContentKind::Issue
6156                && (item.closed != terminal || (terminal && item.status != landed));
6157            if let Some(moves) = Moves::of(option_moves, state_moves) {
6158                status_move = Some(StatusMove {
6159                    board: board.id,
6160                    field,
6161                    option,
6162                    name,
6163                    target,
6164                    landed,
6165                    moves,
6166                });
6167            }
6168        }
6169
6170        let mut priority_move = None;
6171        if let Some(priority) = update.priority
6172            && self.priorities.is_some()
6173            && item.priority != HeldPriority::Read(priority)
6174        {
6175            let board = match (item.carried_board(), item.named_board()) {
6176                (Some(board), _) => board,
6177                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6178                    id: board,
6179                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6180                },
6181                _ => self.board_fields().await?,
6182            };
6183            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6184                priority_move = Some((board.id, write, priority));
6185            }
6186        }
6187
6188        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6189        // recorded in the slot, and the slot travels in the one body update below.
6190        let edges = match &update.depends_on {
6191            Some(edges) => Some(
6192                self.partition_edges(
6193                    BoardKind::Work(ItemKind::Task),
6194                    item.content_kind,
6195                    item.blocked_by.as_deref(),
6196                    edges,
6197                )
6198                .await?,
6199            ),
6200            None => None,
6201        };
6202
6203        let mut slot = item.slot.clone();
6204        for (key, value) in &update.metadata_set {
6205            slot.insert(key.as_str().to_owned(), value.clone());
6206        }
6207        for key in &update.metadata_remove {
6208            slot.remove(key.as_str());
6209        }
6210        if let Some(delivers) = &update.delivers {
6211            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6212        }
6213        if let Some((_, recorded)) = &edges {
6214            record_edges(&mut slot, recorded);
6215        }
6216        let held = item.raw_body.clone().unwrap_or_default();
6217        let content = match &update.content {
6218            Some(content) => with_content(&held, content)?,
6219            None => held.clone(),
6220        };
6221        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6222        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6223        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6224        let body = if slot == item.slot {
6225            content
6226        } else {
6227            with_slot(&content, &slot)?
6228        };
6229        // Checked before anything is sent, as a content write checks it: content ending in
6230        // what this source reads as its own slot would read back as metadata.
6231        let (visible, read) = metadata_body(Some(body.clone()))?;
6232        let wanted = update.content.as_deref().or(item.body.as_deref());
6233        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6234            return Err(SourceError::Refused {
6235                message: format!(
6236                    "this content ends in what source {} reads as its own metadata slot \
6237                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6238                     as content; next: remove that trailing block from the content",
6239                    self.name
6240                ),
6241            });
6242        }
6243        let recorded_moves =
6244            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6245
6246        // One `updateIssue` carries all three, because every mutation spends the secondary
6247        // limiter and the title, body and state are one mutation's inputs.
6248        let mut fields = serde_json::Map::new();
6249        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6250            fields.insert("title".to_owned(), json!(title));
6251        }
6252        if body != held {
6253            fields.insert("body".to_owned(), json!(body));
6254        }
6255        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6256            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6257        }
6258        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6259        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6260        // without undoing an earlier field when a later one fails — so a body written before a
6261        // board field the board then refused would be left changed. Written after every other
6262        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6263        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6264        // first, together in one request — a terminal option selected before the issue
6265        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6266        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6267        let mut clear: Option<(&BoardId, &str)> = None;
6268        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6269            board_writes.push((
6270                &moving.board,
6271                (
6272                    moving.field.clone(),
6273                    json!({"singleSelectOptionId": moving.option}),
6274                ),
6275            ));
6276        }
6277        match &priority_move {
6278            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6279                board,
6280                (field.clone(), json!({"singleSelectOptionId": option})),
6281            )),
6282            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6283            None => {}
6284        }
6285        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6286        boards.extend(clear.map(|(board, _)| board));
6287        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6288        for board in boards {
6289            let writes = board_writes
6290                .iter()
6291                .filter(|(on, _)| on.as_str() == board.as_str())
6292                .map(|(_, write)| write.clone())
6293                .collect::<Vec<_>>();
6294            let cleared = clear
6295                .filter(|(on, _)| on.as_str() == board.as_str())
6296                .map(|(_, field)| field);
6297            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6298                .await?;
6299        }
6300        let mut blocked_by_moved = false;
6301        if let Some((native, _)) = &edges
6302            && item.content_kind == ContentKind::Issue
6303        {
6304            blocked_by_moved = self
6305                .reconcile_blocked_by(
6306                    &item.id,
6307                    native,
6308                    Issue::Existing(item.blocked_by.as_deref()),
6309                )
6310                .await?;
6311        }
6312        if !fields.is_empty() {
6313            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6314                .await?;
6315        }
6316
6317        if let Some(title) = &update.title {
6318            item.title.clone_from(title);
6319        }
6320        item.body = visible.filter(|value| !value.is_empty());
6321        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6322        item.slot = slot;
6323        if let Some(delivers) = &update.delivers {
6324            item.delivers.clone_from(delivers);
6325        }
6326        if let Some(moving) = status_move {
6327            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6328                && item.content_kind == ContentKind::Issue;
6329            item.status = moving.landed;
6330            item.option = Some(moving.name);
6331        }
6332        if let Some((_, _, priority)) = priority_move {
6333            item.priority = HeldPriority::Read(priority);
6334        }
6335        let task = item.task()?;
6336        let mut written = update.changed(&before, &task);
6337        if blocked_by_moved || recorded_moves {
6338            written.insert(UpdatedField::DependsOn);
6339        }
6340        self.remember_written(item, false)?;
6341        Ok(Some(TaskUpdateOutcome {
6342            task,
6343            written,
6344            delivers_before: before.delivers,
6345        }))
6346    }
6347
6348    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6349    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6350    ///
6351    /// One update of the body: the content outside the slot, and inside it that one entry,
6352    /// every other entry kept as it was. This source keeps no template answers — an issue has
6353    /// no room beside itself that is not its body, and answers written there would duplicate
6354    /// what the content already says and count against GitHub's body limit — so `answers`
6355    /// reaches nothing here. A body that would not change is not sent at all.
6356    async fn replace_rendering(
6357        &self,
6358        id: &NativeId,
6359        kind: BoardKind,
6360        content: &str,
6361        provenance: &Value,
6362    ) -> Result<Option<()>, SourceError> {
6363        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6364            return Ok(None);
6365        };
6366        let held = item.raw_body.clone().unwrap_or_default();
6367        let mut slot = item.slot.clone();
6368        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6369        let body = with_slot(&with_content(&held, content)?, &slot)?;
6370        // Checked before anything is sent, as a content write checks it.
6371        let (visible, read) = metadata_body(Some(body.clone()))?;
6372        if visible.as_deref().unwrap_or_default() != content || read != slot {
6373            return Err(SourceError::Refused {
6374                message: format!(
6375                    "this content ends in what source {} reads as its own metadata slot \
6376                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6377                     as content; next: remove that trailing block from the template",
6378                    self.name
6379                ),
6380            });
6381        }
6382        if body != held {
6383            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6384                .await?;
6385        }
6386        item.body = visible.filter(|value| !value.is_empty());
6387        item.raw_body = Some(body);
6388        item.slot = read;
6389        self.remember_written(item, false)?;
6390        Ok(Some(()))
6391    }
6392
6393    async fn set_item_field(
6394        &self,
6395        board_id: &str,
6396        item_id: &str,
6397        field_id: &str,
6398        value: Value,
6399    ) -> Result<(), SourceError> {
6400        let data = self
6401            .graphql(
6402                graphql::UPDATE_FIELD,
6403                json!({"input":{
6404                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6405                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6406            )
6407            .await?;
6408        let returned = data
6409            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6410            .ok_or_else(|| SourceError::Malformed {
6411                message: "GitHub field update returned no project item".into(),
6412            })?;
6413        if required_str(returned, "id")? != item_id {
6414            return Err(SourceError::Malformed {
6415                message: "GitHub field update returned the wrong project item".into(),
6416            });
6417        }
6418        Ok(())
6419    }
6420
6421    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6422    /// one request. Every returned item id is checked, including optional aliases.
6423    async fn set_item_fields(
6424        &self,
6425        board: &str,
6426        item: &str,
6427        fields: &[(String, Value)],
6428        clear: Option<&str>,
6429    ) -> Result<(), SourceError> {
6430        if fields.len() <= 1 && clear.is_none() {
6431            if let Some((field, value)) = fields.first() {
6432                self.set_item_field(board, item, field, value.clone())
6433                    .await?;
6434            }
6435            return Ok(());
6436        }
6437        if fields.is_empty() {
6438            if let Some(field) = clear {
6439                self.write_priority(
6440                    board,
6441                    item,
6442                    &PriorityWrite::Clear {
6443                        field: field.to_owned(),
6444                    },
6445                )
6446                .await?;
6447            }
6448            return Ok(());
6449        }
6450        let input = |index: usize| {
6451            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6452            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6453        };
6454        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6455            "input":input(0),"second":input(1),"third":input(2),
6456            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6457            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6458        })).await?;
6459        for alias in [
6460            Some("updateProjectV2ItemFieldValue"),
6461            (fields.len() > 1).then_some("second"),
6462            (fields.len() > 2).then_some("third"),
6463            clear.map(|_| "cleared"),
6464        ]
6465        .into_iter()
6466        .flatten()
6467        {
6468            let returned = data
6469                .get(alias)
6470                .and_then(|value| value.get("projectV2Item"))
6471                .ok_or_else(|| SourceError::Malformed {
6472                    message: format!("GitHub field update {alias} returned no project item"),
6473                })?;
6474            if required_str(returned, "id")? != item {
6475                return Err(SourceError::Malformed {
6476                    message: format!("GitHub field update {alias} returned the wrong project item"),
6477                });
6478            }
6479        }
6480        Ok(())
6481    }
6482
6483    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6484        let mut after: Option<String> = None;
6485        let mut ids = Vec::new();
6486        loop {
6487            let data = self
6488                .graphql(
6489                    graphql::ISSUE_DEPENDENCIES,
6490                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6491                )
6492                .await?;
6493            let connection =
6494                data.pointer("/node/blockedBy")
6495                    .ok_or_else(|| SourceError::Malformed {
6496                        message: "GitHub dependency response has no blockedBy connection".into(),
6497                    })?;
6498            ids.extend(
6499                connection
6500                    .get("nodes")
6501                    .and_then(Value::as_array)
6502                    .ok_or_else(|| SourceError::Malformed {
6503                        message: "GitHub dependency response nodes is not an array".into(),
6504                    })?
6505                    .iter()
6506                    .map(|value| required_str(value, "id").map(str::to_owned))
6507                    .collect::<Result<Vec<_>, _>>()?,
6508            );
6509            let next = next_cursor(connection)?;
6510            if let Some(next) = &next {
6511                validate_cursor_progress(after.as_deref(), &next.0)?;
6512            }
6513            after = next.map(|cursor| cursor.0);
6514            if after.is_none() {
6515                return Ok(ids);
6516            }
6517        }
6518    }
6519
6520    async fn dependencies(
6521        &self,
6522        id: &NativeId,
6523        near_kind: ItemKind,
6524        direction: Direction,
6525        page: &PageRequest,
6526    ) -> Result<Page<DependencyEdge>, SourceError> {
6527        validate_page(page)?;
6528        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6529        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6530        let recorded = recorded_offset(cursor, direction)?;
6531        // What this issue is blocked by, when a read of it by its own id in this command
6532        // already carried the whole connection — a copy reads the item it writes before it
6533        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6534        // after it. Answered from that read, in the shape the dependency read answers in;
6535        // anything else is asked of GitHub.
6536        let carried = match direction {
6537            Direction::DependsOn => self
6538                .resolved_cache()?
6539                .get(id)
6540                .filter(|item| item.content_kind == ContentKind::Issue)
6541                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6542            Direction::DependedOnBy => None,
6543        }
6544        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6545        // Asked for even in the recorded phase, whose page reads nothing from the
6546        // connection: `__typename` is what says whether this item has a native
6547        // relationship at all, and that is what decides which far ends the reserved key is
6548        // allowed to hold.
6549        let data = match carried {
6550            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6551                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6552            None => {
6553                self.graphql(
6554                    graphql::ISSUE_DEPENDENCIES,
6555                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6556                           "after":if recorded.is_some() {None} else {cursor}}),
6557                )
6558                .await?
6559            }
6560        };
6561        let node =
6562            data.get("node")
6563                .filter(|v| !v.is_null())
6564                .ok_or_else(|| SourceError::Refused {
6565                    message: format!(
6566                        "GitHub item {} was not found or does not support dependencies",
6567                        id.0
6568                    ),
6569                })?;
6570        let connection_name = match direction {
6571            Direction::DependsOn => "blockedBy",
6572            Direction::DependedOnBy => "blocking",
6573        };
6574        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6575        // named natively and the reserved key may hold any far end. An issue's connections
6576        // hold issues, and this source reads them at the near item's own level.
6577        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6578        if let Some(offset) = recorded {
6579            return Ok(recorded_page(
6580                self.recorded_edges(id, near_kind, direction, natively_names, node)
6581                    .await?,
6582                offset,
6583                limit,
6584            ));
6585        }
6586        if natively_names.is_none() {
6587            return Ok(recorded_page(
6588                self.recorded_edges(id, near_kind, direction, natively_names, node)
6589                    .await?,
6590                0,
6591                limit,
6592            ));
6593        }
6594        let connection = node
6595            .get(connection_name)
6596            .ok_or_else(|| SourceError::Malformed {
6597                message: "GitHub dependency response is missing its connection".into(),
6598            })?;
6599        let nodes = connection
6600            .get("nodes")
6601            .and_then(Value::as_array)
6602            .ok_or_else(|| SourceError::Malformed {
6603                message: "GitHub dependency response nodes is not an array".into(),
6604            })?;
6605        // `from` depends on `to`, always. GitHub spells the same relationship from either
6606        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6607        // it — so the near item is `from` in one direction and `to` in the other.
6608        let items = nodes
6609            .iter()
6610            .map(|value| {
6611                let related = NativeId(required_str(value, "id")?.into());
6612                let related_kind = related_kind(value)?;
6613                let (from, to) = match direction {
6614                    Direction::DependsOn => (
6615                        DependencyEndpoint::from_native(id.clone(), near_kind),
6616                        DependencyEndpoint::from_native(related, related_kind),
6617                    ),
6618                    Direction::DependedOnBy => (
6619                        DependencyEndpoint::from_native(related, related_kind),
6620                        DependencyEndpoint::from_native(id.clone(), near_kind),
6621                    ),
6622                };
6623                Ok(DependencyEdge {
6624                    from,
6625                    to,
6626                    kind: DependencyKind::Blocks,
6627                })
6628            })
6629            .collect::<Result<Vec<_>, SourceError>>()?;
6630        let mut next = next_cursor(connection)?;
6631        if let Some(next) = &next {
6632            validate_cursor_progress(cursor, &next.0)?;
6633        }
6634        if next.is_none()
6635            && !self
6636                .recorded_edges(id, near_kind, direction, natively_names, node)
6637                .await?
6638                .is_empty()
6639        {
6640            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6641        }
6642        Ok(Page { items, next })
6643    }
6644
6645    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6646    /// a far end in another source has to live: no GitHub issue relationship can name one.
6647    ///
6648    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6649    /// source never writes one down.
6650    ///
6651    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6652    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6653    /// request beyond the read already made, and reading the board for them would be a
6654    /// walk of every item for one field of one. A draft has no body in that answer, because
6655    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6656    /// listing of the board, which can be behind on the very item asked about.
6657    async fn recorded_edges(
6658        &self,
6659        id: &NativeId,
6660        near_kind: ItemKind,
6661        direction: Direction,
6662        natively_names: Option<ItemKind>,
6663        node: &Value,
6664    ) -> Result<Vec<DependencyEdge>, SourceError> {
6665        if direction != Direction::DependsOn {
6666            return Ok(Vec::new());
6667        }
6668        let slot = match node.get("body") {
6669            Some(body) if natively_names.is_some() => {
6670                metadata_body(body.as_str().map(str::to_owned))?.1
6671            }
6672            _ => {
6673                let Some(item) = self.bound_item(id).await? else {
6674                    return Ok(Vec::new());
6675                };
6676                item.slot
6677            }
6678        };
6679        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6680            .map_err(|message| SourceError::Malformed { message })
6681    }
6682
6683    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6684        self.repository
6685            .as_ref()
6686            .ok_or_else(|| SourceError::Refused {
6687                message: format!(
6688                    "source {} has no repository configured, and a GitHub Projects board has no \
6689                 repository of its own to create an issue in; set repository: owner/name on \
6690                 this source",
6691                    self.name
6692                ),
6693            })
6694    }
6695
6696    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6697    /// states.
6698    ///
6699    /// The fallback is demanded first, whichever arm answers: a write without a configured
6700    /// repository is refused naming the field exactly as it was before the rule existed,
6701    /// so a source that could not write before cannot write now, rather than writing for
6702    /// the one item whose own field happens to decide it.
6703    ///
6704    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6705    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6706    /// entry owned by someone other than the owner of the parent issue's repository —
6707    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6708    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6709    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6710    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6711    /// and is visible to the token is checked where its node id is resolved, still before
6712    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6713    /// looked up in a listing of the board, which can be minutes behind an issue its own
6714    /// `projectItems` already places on it — and that read answers first from this process's
6715    /// own record, so a project created moments ago in this command answers though GitHub
6716    /// has not caught up.
6717    async fn creation_target(
6718        &self,
6719        incoming: &Incoming<'_>,
6720    ) -> Result<RepositoryTarget, SourceError> {
6721        let fallback = self.configured_repository()?;
6722        let what = |incoming: &Incoming<'_>| {
6723            format!(
6724                "{} {:?}",
6725                incoming.written.kind().describes(),
6726                incoming.title
6727            )
6728        };
6729        let parent = match incoming.parent {
6730            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6731                SourceError::Refused {
6732                    message: format!(
6733                        "GitHub project issue {} was not found on the board of source {}, so {} \
6734                         cannot be filed under it",
6735                        parent.0,
6736                        self.name,
6737                        what(incoming)
6738                    ),
6739                }
6740            })?),
6741            None => None,
6742        };
6743        let parents_repository = parent
6744            .as_ref()
6745            .map(|parent| {
6746                // A draft is on the board and so is found, but it has no repository to
6747                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6748                // would refuse the task only once `createIssue` had made it.
6749                if parent.content_kind == ContentKind::DraftIssue {
6750                    return Err(SourceError::Refused {
6751                        message: format!(
6752                            "GitHub project item {} on the board of source {} is a draft, \
6753                             which cannot have sub-issues, so {} cannot be filed under it",
6754                            parent.id.0,
6755                            self.name,
6756                            what(incoming)
6757                        ),
6758                    });
6759                }
6760                // An issue's repository is where a sub-issue is placed and whose owner it
6761                // is compared against, so a parent whose repository this source cannot
6762                // spell as `owner/name` — GitHub's login grammar is wider than this
6763                // source's floor — is one nothing can be filed under.
6764                parent
6765                    .own_repository
6766                    .as_ref()
6767                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6768                    .ok_or_else(|| SourceError::Malformed {
6769                        message: format!(
6770                            "GitHub project issue {} on the board of source {} is in {}, which \
6771                             is not a {}/owner/name repository this source can place {} in",
6772                            parent.id.0,
6773                            self.name,
6774                            parent
6775                                .own_repository
6776                                .as_ref()
6777                                .map_or("no repository", Repository::as_str),
6778                            RepositoryTarget::HOST,
6779                            what(incoming)
6780                        ),
6781                    })
6782            })
6783            .transpose()?;
6784        match incoming.repositories {
6785            [named] => {
6786                let target =
6787                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6788                        message: format!(
6789                            "{} names repository {}, which is not a {}/owner/name repository \
6790                             source {} can create an issue in; name one that is, or name none",
6791                            what(incoming),
6792                            named.as_str(),
6793                            RepositoryTarget::HOST,
6794                            self.name
6795                        ),
6796                    })?;
6797                if let Some(parents) = &parents_repository
6798                    && parents.owner != target.owner
6799                {
6800                    return Err(SourceError::Refused {
6801                        message: format!(
6802                            "{} names repository {}, owned by {}, but its project's issue is in \
6803                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6804                             of the same owner as its parent issue; name a repository of {}, or \
6805                             name none",
6806                            what(incoming),
6807                            target.slug(),
6808                            target.owner,
6809                            parents.slug(),
6810                            parents.owner,
6811                            parents.owner
6812                        ),
6813                    });
6814                }
6815                Ok(target)
6816            }
6817            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6818        }
6819    }
6820
6821    /// The node id of the repository `incoming` is being created in, or the refusal naming
6822    /// the item and the repository the token cannot see.
6823    ///
6824    /// Resolved once per command per repository; see [`Self::repository_cache`].
6825    async fn repository_id(
6826        &self,
6827        repository: &RepositoryTarget,
6828        incoming: &Incoming<'_>,
6829    ) -> Result<String, SourceError> {
6830        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6831            return Ok(id);
6832        }
6833        let data = self
6834            .graphql(
6835                graphql::REPOSITORY,
6836                json!({"owner":repository.owner,"name":repository.name}),
6837            )
6838            .await?;
6839        self.repository_read(&data, repository, incoming)
6840    }
6841
6842    /// The repository's node id out of an answer carrying the `repository` root, held for
6843    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6844    fn repository_read(
6845        &self,
6846        data: &Value,
6847        repository: &RepositoryTarget,
6848        incoming: &Incoming<'_>,
6849    ) -> Result<String, SourceError> {
6850        let node = data
6851            .get("repository")
6852            .filter(|value| !value.is_null())
6853            .ok_or_else(|| SourceError::Refused {
6854                message: format!(
6855                    "GitHub repository {} was not found or is not visible to the token, so {} \
6856                     {:?} cannot be created in it",
6857                    repository.slug(),
6858                    incoming.written.kind().describes(),
6859                    incoming.title
6860                ),
6861            })?;
6862        let id = required_str(node, "id")?.to_owned();
6863        self.repository_cache()?
6864            .insert(repository.clone(), id.clone());
6865        Ok(id)
6866    }
6867
6868    fn repository_cache(
6869        &self,
6870    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6871        self.repository_cache
6872            .lock()
6873            .map_err(|_| SourceError::Unavailable {
6874                message: "this source's record of the destination repository was left \
6875                          inconsistent by an earlier failure; next: run the command again"
6876                    .into(),
6877            })
6878    }
6879
6880    /// Create or update one board item, whichever kind it is.
6881    async fn write_item(
6882        &self,
6883        incoming: &Incoming<'_>,
6884        target: Option<&NativeId>,
6885        depends_on: &[DependencyEdge],
6886    ) -> Result<NativeId, SourceError> {
6887        // Refused before anything is read or written: a task or a project titled the way
6888        // this board spells a document would land as an issue this same source reads back
6889        // as a document, so the field this destination cannot carry is named rather than
6890        // written and silently reclassified.
6891        if let Written::Work(kind, _) = incoming.written
6892            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6893        {
6894            return Err(SourceError::Refused {
6895                message: format!(
6896                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6897                     spells a document, so it would read back as one rather than as a {}; \
6898                     retitle it, or copy it as a document",
6899                    kind.marker(),
6900                    self.name,
6901                    kind.marker()
6902                ),
6903            });
6904        }
6905        // The destination is read by its own id, and whether this board holds it is decided
6906        // by that read — its own `projectItems` — rather than by whether a listing of the
6907        // board happens to include it yet. See the module documentation.
6908        let existing = match target {
6909            Some(target) => {
6910                Some(
6911                    self.bound_item(target)
6912                        .await?
6913                        .ok_or_else(|| SourceError::Refused {
6914                            message: format!("GitHub destination item {} was not found", target.0),
6915                        })?,
6916                )
6917            }
6918            None => None,
6919        };
6920        let existing = existing.as_ref();
6921        // An existing issue is never moved; a new one is created where the rule says — and
6922        // knowing where is what lets the board's fields and that repository's id be read
6923        // together, before anything below needs either.
6924        let creation_target = match existing {
6925            Some(_) => None,
6926            None => {
6927                let target = self.creation_target(incoming).await?;
6928                self.creation_context(&target, incoming).await?;
6929                Some(target)
6930            }
6931        };
6932        let board = self
6933            .fields_for(
6934                existing,
6935                incoming.written.status().is_some(),
6936                incoming
6937                    .priority
6938                    .is_some_and(|priority| priority != Priority::None),
6939            )
6940            .await?;
6941        let status_target = incoming
6942            .written
6943            .work_status()
6944            .map(|(kind, status)| self.resolved_target(kind, status.category))
6945            .transpose()?;
6946        let column = match (incoming.written.work_status(), status_target.as_ref()) {
6947            (Some((kind, status)), Some(target)) => {
6948                self.column_for(&board.fields, kind, status.category, target)?
6949            }
6950            _ => None,
6951        };
6952        // Resolved before anything is created, for the reason the column above is: a
6953        // priority this board has no option for is refused while nothing has been written.
6954        let priority_write = match incoming.priority {
6955            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6956            None => None,
6957        };
6958        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6959        if content_kind == ContentKind::DraftIssue {
6960            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6961                (status_target.as_ref(), incoming.written.status())
6962            {
6963                return Err(self.closes_a_draft(status.category));
6964            }
6965            if incoming.parent.is_some() {
6966                return Err(SourceError::Refused {
6967                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6968                });
6969            }
6970        }
6971        match existing {
6972            Some(item) if content_kind == ContentKind::Issue => {
6973                if item.labels != incoming.labels {
6974                    return Err(SourceError::Refused {
6975                        message: "GitHub issue labels differ from the labels being written".into(),
6976                    });
6977                }
6978            }
6979            _ => {
6980                if !incoming.labels.is_empty() {
6981                    return Err(SourceError::Refused {
6982                        message: "GitHub items created by this destination carry no labels".into(),
6983                    });
6984                }
6985            }
6986        }
6987
6988        // The repository the issue really lives in is what the slot below is written against,
6989        // so a single entry that is where the issue is created travels as no key at all, and
6990        // the read side derives it back from the issue.
6991        let own_repository = match (existing, &creation_target) {
6992            (Some(item), _) => item.own_repository.clone(),
6993            (None, Some(target)) => Some(
6994                Repository::try_from(target.origin())
6995                    .map_err(|message| SourceError::Config { message })?,
6996            ),
6997            (None, None) => None,
6998        };
6999        let (native, fallback) = self
7000            .partition_edges(
7001                incoming.written.kind(),
7002                content_kind,
7003                existing.and_then(|item| item.blocked_by.as_deref()),
7004                depends_on,
7005            )
7006            .await?;
7007        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
7008        let body = compose_body(incoming.content, &slot)?;
7009        // Read before anything is created, for the reason the field below is: a value
7010        // this destination cannot store has to refuse, and refusing after `createIssue`
7011        // would leave an issue behind that nothing asked for. The engine writes a
7012        // qualified id here; a caller handing this key anything else is told so rather
7013        // than having it silently stored as no origin at all.
7014        // 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.
7015        let origin = match incoming.metadata.get(ORIGIN_KEY) {
7016            None => "",
7017            Some(Value::String(origin)) => origin.as_str(),
7018            Some(other) => {
7019                return Err(SourceError::Refused {
7020                    message: format!(
7021                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
7022                         is {other}"
7023                    ),
7024                });
7025            }
7026        };
7027        // Resolved before anything is created: a board that cannot carry the copy origin
7028        // has to refuse the write, and refusing it after `createIssue` would leave an
7029        // issue behind that nothing asked for.
7030        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
7031            Some(field) => {
7032                if required_str(field, "__typename")? != "ProjectV2Field" {
7033                    return Err(SourceError::Refused {
7034                        message: format!(
7035                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
7036                        ),
7037                    });
7038                }
7039                Some(required_str(field, "id")?.to_owned())
7040            }
7041            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
7042                return Err(SourceError::Refused {
7043                    message: format!(
7044                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
7045                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
7046                         the board"
7047                    ),
7048                });
7049            }
7050            None => None,
7051        };
7052
7053        let Landed {
7054            content_id,
7055            item_id,
7056            url,
7057            number,
7058        } = match existing {
7059            // Its content is written last, below, once everything else has landed.
7060            Some(item) => Landed {
7061                content_id: item.id.clone(),
7062                item_id: item.item_id.clone(),
7063                url: item.url.clone(),
7064                number: item.number,
7065            },
7066            None => {
7067                let target = creation_target
7068                    .as_ref()
7069                    .ok_or_else(|| SourceError::Malformed {
7070                        message: "a new item was decided without a repository to create it in"
7071                            .into(),
7072                    })?;
7073                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7074                    .await?
7075            }
7076        };
7077
7078        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7079        let column = column
7080            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7081            .map(|(field, option, _)| (field, option));
7082        // Creating an item here is several calls — `createIssue`, which files it on the
7083        // board, then its board fields, the parent and the dependencies — and GitHub can fail
7084        // at any of them. Everything this source can refuse *before* the first of those is
7085        // already checked above, so what is left is GitHub itself failing part way. When it
7086        // does over an item this call created, the issue is taken back: a write that
7087        // refused must not leave an item behind that nobody asked for, and one that does
7088        // makes the retry create a second.
7089        // Whether the board-field write carrying a moved origin was answered as landing whole.
7090        // When it was refused, GitHub does not say which of its fields ran before the one that
7091        // failed, so the origin may or may not have moved.
7092        let mut origin_landed = false;
7093        let landed = self
7094            .finish_write(
7095                board.id.as_str(),
7096                incoming,
7097                &content_id,
7098                &item_id,
7099                content_kind,
7100                existing,
7101                origin_field.as_deref(),
7102                origin,
7103                column,
7104                status_target.as_ref(),
7105                priority_write.as_ref(),
7106                &native,
7107                &mut origin_landed,
7108            )
7109            .await;
7110        // An existing item's title, body and state go last, in one `updateIssue`, once its board
7111        // fields and its relationships have landed: a refusal of any of those then leaves its
7112        // body — and the metadata slot inside it — exactly as it stood.
7113        let landed = match (landed, existing) {
7114            (Ok(()), Some(item)) => {
7115                self.update_existing(item, incoming, &body, status_target.as_ref())
7116                    .await
7117            }
7118            (landed, _) => landed,
7119        };
7120        if let Err(error) = landed {
7121            match existing {
7122                // Best effort, and the write's own failure is what the caller is told: a
7123                // refusal naming the tidy-up would hide why the write failed at all.
7124                None => {
7125                    let _ = self.delete_issue(&content_id).await;
7126                }
7127                // The origin field is the one piece of an existing item's metadata written
7128                // before its body, so a write refused after it puts it back as it was. When
7129                // that is refused too, the write's own failure is still what the caller is
7130                // told — with what it left behind added, because the item's metadata is then
7131                // not as it stood and a caller retrying has to know which key moved.
7132                Some(item) => {
7133                    let before = item.origin.as_deref().unwrap_or("");
7134                    if let Some(field) = origin_field.as_deref()
7135                        && before != origin
7136                        && let Err(restore) = self
7137                            .set_item_field(
7138                                board.id.as_str(),
7139                                &item.item_id,
7140                                field,
7141                                json!({"text": before}),
7142                            )
7143                            .await
7144                    {
7145                        let left = if origin_landed {
7146                            format!(
7147                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7148                                 not be put back to {before:?} ({restore}), so item {} still \
7149                                 holds {origin:?} there",
7150                                item.id.0
7151                            )
7152                        } else {
7153                            format!(
7154                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7155                                 {origin:?}, GitHub does not say whether that part of it ran, \
7156                                 and putting it back to {before:?} was refused ({restore}), so \
7157                                 item {} holds {origin:?} or {before:?} there",
7158                                item.id.0
7159                            )
7160                        };
7161                        return Err(noting(
7162                            error,
7163                            &format!(
7164                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7165                                 run the write again"
7166                            ),
7167                        ));
7168                    }
7169                }
7170            }
7171            return Err(error);
7172        }
7173
7174        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7175            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7176                self.statuses
7177                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7178            }
7179            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7180                self.statuses
7181                    .status(kind, written_option.as_deref(), false, None)
7182            }
7183            (Some((_, status)), _) => status.clone(),
7184            (None, _) => Status {
7185                category: StatusCategory::Unknown,
7186                name: "Open".to_owned(),
7187            },
7188        };
7189
7190        // So the rest of this command reads what it just did rather than what the board
7191        // said before it. See `remember_written` for which half takes it.
7192        let remembered = Resolved {
7193            item_id,
7194            id: content_id.clone(),
7195            content_kind,
7196            kind: incoming.written.kind(),
7197            title: incoming.title.to_owned(),
7198            // The visible half of the body this write composed, split back off it the
7199            // way a read splits it — so what this record reports is what a read of the
7200            // same issue reports, rather than the person's text with the metadata slot
7201            // still on the end of it.
7202            body: metadata_body(body.clone())?.0,
7203            raw_body: body.clone(),
7204            // A document has no status of its own; what it reads back as is whatever
7205            // the issue's own state says, which is what a re-read reports.
7206            status: written_status,
7207            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7208            priority: match incoming.priority {
7209                Some(priority) => HeldPriority::Read(priority),
7210                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7211                    item.priority.clone()
7212                }),
7213            },
7214            // What `state_input` asked for: closed for a terminal target, open for any other
7215            // status, and the issue's own state left as it was by a document write.
7216            closed: content_kind == ContentKind::Issue
7217                && match status_target.as_ref() {
7218                    Some(StatusTarget::Terminal(_, _)) => true,
7219                    Some(_) => false,
7220                    None => existing.is_some_and(|item| item.closed),
7221                },
7222            delivers: incoming.delivers.to_vec(),
7223            delivered_by: incoming.delivered_by.to_vec(),
7224            labels: incoming.labels.to_vec(),
7225            parent: incoming.parent.cloned(),
7226            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7227            number,
7228            // In the update path this is the item's own url, read off `existing` where the
7229            // record above was bound, so one expression serves both halves.
7230            url,
7231            created_at: existing.and_then(|item| item.created_at),
7232            updated_at: existing.and_then(|item| item.updated_at),
7233            own_repository,
7234            repositories: incoming.repositories.to_vec(),
7235            slot,
7236            board_id: Some(board.id.as_str().to_owned()),
7237            fields: board
7238                .fields
7239                .get("nodes")
7240                .and_then(Value::as_array)
7241                .cloned()
7242                .unwrap_or_default(),
7243            board_fields: Some(board.fields.clone()),
7244            // What this write left the relationship holding is known by id alone, and a
7245            // later read of its edges needs each far end's kind, so it reads them again.
7246            blocked_by: None,
7247        };
7248        self.remember_written(remembered, existing.is_none())?;
7249        Ok(content_id)
7250    }
7251
7252    /// Everything a write does after the item exists: its board fields, its parent, and
7253    /// its dependencies.
7254    ///
7255    /// Split out of `write_item` so there is one place a failure past the point of no
7256    /// return is caught, rather than a tidy-up repeated at each `?` above.
7257    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7258    // so there is one place a failure past the point of no return is caught, and its
7259    // arguments are exactly the values that tail already had in scope. Bundling them into a
7260    // struct would describe no concept — it would be "the arguments of this function" — and
7261    // would put the whole of `write_item`'s locals behind one more indirection.
7262    #[allow(clippy::too_many_arguments)]
7263    async fn finish_write(
7264        &self,
7265        board_id: &str,
7266        incoming: &Incoming<'_>,
7267        content_id: &NativeId,
7268        item_id: &str,
7269        content_kind: ContentKind,
7270        existing: Option<&Resolved>,
7271        origin_field: Option<&str>,
7272        origin: &str,
7273        column: Option<(String, String)>,
7274        status_target: Option<&StatusTarget>,
7275        priority: Option<&PriorityWrite>,
7276        native: &[String],
7277        origin_landed: &mut bool,
7278    ) -> Result<(), SourceError> {
7279        let mut fields = Vec::new();
7280        if let Some(field_id) = origin_field
7281            && existing.map_or(!origin.is_empty(), |item| {
7282                item.origin.as_deref().unwrap_or("") != origin
7283            })
7284        {
7285            fields.push((field_id.to_owned(), json!({"text":origin})));
7286        }
7287        if let Some((field_id, option_id)) = column {
7288            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7289        }
7290        let clear = match priority {
7291            Some(PriorityWrite::Select { field, option }) => {
7292                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7293                None
7294            }
7295            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7296            None => None,
7297        };
7298        self.set_item_fields(board_id, item_id, &fields, clear)
7299            .await?;
7300        *origin_landed = true;
7301
7302        // An existing issue closes in the `updateIssue` its write ends with; one created just
7303        // now closes here, once its option is selected.
7304        if existing.is_none()
7305            && content_kind == ContentKind::Issue
7306            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7307        {
7308            self.update_content(
7309                ContentKind::Issue,
7310                content_id,
7311                json!({"stateInput":state_input(status_target)}),
7312            )
7313            .await?;
7314        }
7315
7316        if content_kind == ContentKind::Issue {
7317            self.reparent(
7318                existing.and_then(|item| item.parent.clone()),
7319                content_id,
7320                incoming.parent,
7321            )
7322            .await?;
7323            // A document takes part in no dependency graph, so writing one neither reads
7324            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7325            // against the empty list a document write carries would *delete* whatever
7326            // relationships a person had made on that issue, which is a write nobody
7327            // asked for.
7328            if incoming.written.kind() != BoardKind::Document {
7329                let issue = match existing {
7330                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7331                    None => Issue::Created,
7332                };
7333                self.reconcile_blocked_by(content_id, native, issue).await?;
7334            }
7335        }
7336        Ok(())
7337    }
7338
7339    /// Delete one issue, which takes its board item with it.
7340    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7341        let data = self
7342            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7343            .await?;
7344        data.pointer("/deleteIssue/repository")
7345            .filter(|value| !value.is_null())
7346            .ok_or_else(|| SourceError::Malformed {
7347                message: "GitHub issue deletion returned no repository".into(),
7348            })?;
7349        self.forget(id)?;
7350        Ok(())
7351    }
7352
7353    /// Remove one item this copy created, so a copy that could not finish leaves the board
7354    /// as it found it.
7355    ///
7356    /// Deleting the issue takes its board item with it, so there is no second mutation to
7357    /// keep in step. An id the board does not hold is not an error: the item is already
7358    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7359    /// by its own id — a listing of the board can still be missing an item it holds, and
7360    /// reading that as *already gone* would leave behind the very item this was asked to
7361    /// take back.
7362    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7363        let Some(item) = self.bound_item(id).await? else {
7364            return Ok(());
7365        };
7366        if item.content_kind == ContentKind::DraftIssue {
7367            return Err(SourceError::Refused {
7368                message: format!(
7369                    "GitHub item {} is a draft, and this source removes an item by deleting \
7370                     its issue; next: remove it from the board by hand",
7371                    id.0
7372                ),
7373            });
7374        }
7375        let data = self
7376            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7377            .await?;
7378        data.pointer("/deleteIssue/repository")
7379            .filter(|value| !value.is_null())
7380            .ok_or_else(|| SourceError::Malformed {
7381                message: "GitHub issue deletion returned no repository".into(),
7382            })?;
7383        self.forget(id)?;
7384        Ok(())
7385    }
7386
7387    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7388    /// task.
7389    ///
7390    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7391    /// read of the task cannot disagree about which ids name one: a project or a document of
7392    /// this board is not a task here either.
7393    ///
7394    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7395    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7396    /// which would read as a task nobody has commented on yet.
7397    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7398        let cached = self.resolved_cache()?.get(task).cloned();
7399        let Some(item) = (match cached {
7400            Some(item) => Some(item),
7401            None => self.item_by_id(task).await?,
7402        })
7403        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7404            return Ok(None);
7405        };
7406        if item.content_kind == ContentKind::DraftIssue {
7407            return Err(self.draft_has_no_comments(task));
7408        }
7409        Ok(Some(item.id))
7410    }
7411
7412    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7413    /// issues, and a draft is not one.
7414    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7415        SourceError::Refused {
7416            message: format!(
7417                "task {} of source {} is a draft item on the board, and GitHub keeps \
7418                 comments on issues alone, so a draft has none to read or write; next: \
7419                 convert the draft to an issue on the board, then comment on the issue it \
7420                 becomes",
7421                task.0, self.name
7422            ),
7423        }
7424    }
7425
7426    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7427    /// request — or `None` when this board holds no task by that id.
7428    ///
7429    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7430    /// is answered with the draft and the refusal, at the price of the draft's own read.
7431    async fn issue_detail(
7432        &self,
7433        id: &NativeId,
7434        page: &PageRequest,
7435    ) -> Result<Option<TaskDetailRead>, SourceError> {
7436        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7437        let asked = self
7438            .graphql(
7439                graphql::ISSUE_DETAIL,
7440                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7441                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7442                       "duplicates":true}),
7443            )
7444            .await;
7445        let data = match asked {
7446            Ok(data) => data,
7447            Err(error) if unresolvable_node(&error) => return Ok(None),
7448            Err(error) => return Err(error),
7449        };
7450        // `node` is null for an id that names nothing, and absent only from an answer this
7451        // source cannot read — never the same thing.
7452        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7453            message: format!("GitHub answered the read of {} with no node", id.0),
7454        })?;
7455        self.detail_of(id, node, true, after).await
7456    }
7457
7458    /// Several tasks, each with the first page of its comments when `comments` is set, read
7459    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7460    /// order.
7461    ///
7462    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7463    /// one item at a time, so that id is answered as missing and the others as themselves; any
7464    /// other refusal is every id of that batch's answer.
7465    async fn issue_details(
7466        &self,
7467        ids: &[NativeId],
7468        comments: Option<&PageRequest>,
7469    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7470        let mut read = Vec::with_capacity(ids.len());
7471        for batch in ids.chunks(DETAIL_BATCH) {
7472            match self
7473                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7474                .await
7475            {
7476                Ok(data) => {
7477                    for (slot, id) in batch.iter().enumerate() {
7478                        // Every alias asked for is answered, null for an id naming nothing;
7479                        // one missing is an answer this source cannot read.
7480                        let read_one = match data.get(format!("i{slot}")) {
7481                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7482                            None => Err(SourceError::Malformed {
7483                                message: format!(
7484                                    "GitHub answered a batch read with no item for {}",
7485                                    id.0
7486                                ),
7487                            }),
7488                        };
7489                        read.push(read_one);
7490                    }
7491                }
7492                Err(error) if unresolvable_node(&error) => {
7493                    for id in batch {
7494                        read.push(match comments {
7495                            Some(page) => self.issue_detail(id, page).await,
7496                            None => self.task_read(id).await,
7497                        });
7498                    }
7499                }
7500                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7501            }
7502        }
7503        read
7504    }
7505
7506    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7507    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7508        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7509            task,
7510            comments: None,
7511        }))
7512    }
7513
7514    /// What one node a detail read reached says: the task this board holds by `id`, with the
7515    /// page of comments the node carries when `commented` — or `None` for a node that is no
7516    /// task of this board.
7517    ///
7518    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7519    /// and an item this process created answers from this process's own record, which a node
7520    /// read taken moments after the write can still be behind.
7521    async fn detail_of(
7522        &self,
7523        id: &NativeId,
7524        node: &Value,
7525        commented: bool,
7526        after: Option<&str>,
7527    ) -> Result<Option<TaskDetailRead>, SourceError> {
7528        if node.is_null() {
7529            return Ok(None);
7530        }
7531        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7532        // An issue answered under one id is that id's, or the answer is not one this source
7533        // can report: reporting another issue's task and comments under the qualified id asked
7534        // for would be the one wrong answer here. A draft's own read checks the same.
7535        if !draft
7536            && optional_str(node, "__typename")? == Some("Issue")
7537            && required_str(node, "id")? != id.0
7538        {
7539            return Err(SourceError::Malformed {
7540                message: format!(
7541                    "GitHub answered the read of {} with issue {}",
7542                    id.0,
7543                    required_str(node, "id")?
7544                ),
7545            });
7546        }
7547        let item = if draft {
7548            self.draft_by_id(id).await?
7549        } else {
7550            self.resolve_issue(node).await?
7551        };
7552        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7553            return Ok(None);
7554        };
7555        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7556        let task = own.unwrap_or(item).task()?;
7557        let comments = match (commented, draft) {
7558            (false, _) => None,
7559            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7560            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7561        };
7562        Ok(Some(TaskDetailRead { task, comments }))
7563    }
7564
7565    /// Whether the comment `comment` is one of `issue`'s own.
7566    ///
7567    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7568    /// comment's id and nothing else: a comment id given against the wrong task would
7569    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7570    /// names something that is not an issue comment, is a comment this task does not have —
7571    /// which is what GitHub refusing to resolve it means too.
7572    async fn comment_is_on(
7573        &self,
7574        issue: &NativeId,
7575        comment: &NativeId,
7576    ) -> Result<bool, SourceError> {
7577        let asked = self
7578            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7579            .await;
7580        let data = match asked {
7581            Ok(data) => data,
7582            Err(error) if unresolvable_node(&error) => return Ok(false),
7583            Err(error) => return Err(error),
7584        };
7585        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7586            return Ok(false);
7587        };
7588        if optional_str(node, "__typename")? != Some("IssueComment") {
7589            return Ok(false);
7590        }
7591        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7592            message: format!("GitHub issue comment {} names no issue", comment.0),
7593        })?;
7594        Ok(required_str(on, "id")? == issue.0)
7595    }
7596
7597    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7598    async fn partition_edges(
7599        &self,
7600        near_kind: BoardKind,
7601        near_content: ContentKind,
7602        carried: Option<&[Value]>,
7603        depends_on: &[DependencyEdge],
7604    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7605        let mut native = Vec::new();
7606        let mut fallback = Vec::new();
7607        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7608            .iter()
7609            .map(|edge| {
7610                let same_source = edge
7611                    .to
7612                    .source()
7613                    .is_none_or(|source| source == self.name.as_str());
7614                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7615                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7616                // colons of its own, so the far end is everything after that one separator.
7617                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7618                let far_id = if edge.to.is_qualified() {
7619                    edge.to
7620                        .id()
7621                        .split_once(':')
7622                        .map_or(edge.to.id(), |(_, native)| native)
7623                } else {
7624                    edge.to.id()
7625                };
7626                // One that already blocks the near issue was answered by that issue's own
7627                // read, which carried each of its blockers' kinds — an issue every one — so it
7628                // is not read again.
7629                let blocking = carried.and_then(|nodes| {
7630                    nodes
7631                        .iter()
7632                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7633                });
7634                (edge, far_id, same_source, blocking)
7635            })
7636            .collect();
7637        // Every other same-source far end is read by its own id, exactly as the item it is a
7638        // far end of is: whether this board holds it is that read's answer, never a listing's.
7639        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7640        let mut unread: Vec<NativeId> = Vec::new();
7641        for (_, far_id, same_source, blocking) in &far_ends {
7642            let id = NativeId((*far_id).to_owned());
7643            if *same_source && blocking.is_none() && !unread.contains(&id) {
7644                unread.push(id);
7645            }
7646        }
7647        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7648            .iter()
7649            .cloned()
7650            .zip(self.items_by_ids(&unread).await?)
7651            .collect();
7652        for (edge, far_id, same_source, blocking) in far_ends {
7653            let far = match (same_source, blocking) {
7654                (false, _) => None,
7655                (true, Some(node)) => Some(FarEnd {
7656                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7657                        BoardKind::Document
7658                    } else {
7659                        BoardKind::Work(related_kind(node)?)
7660                    },
7661                    content_kind: ContentKind::Issue,
7662                }),
7663                (true, None) => {
7664                    let read = read
7665                        .get(&NativeId(far_id.to_owned()))
7666                        .cloned()
7667                        .flatten()
7668                        .ok_or_else(|| SourceError::Refused {
7669                            message: format!("GitHub dependency item {far_id} was not found"),
7670                        })?;
7671                    Some(FarEnd {
7672                        kind: read.kind,
7673                        content_kind: read.content_kind,
7674                    })
7675                }
7676            };
7677            let far = far.as_ref();
7678            // The caller says which kind the far end is, and this board holds the far end
7679            // itself, so a disagreement is settled here rather than stored: recorded, the
7680            // wrong kind would read back as a cross-level edge that never existed; written
7681            // natively, it would name a relationship of a different level than the caller
7682            // asked for.
7683            //
7684            // A far end this board holds as a *document* fails the same comparison and is
7685            // refused by the same sentence: `ItemKind` has no document variant because
7686            // nothing may point at one, so no caller can name it correctly and the refusal
7687            // is the only honest answer.
7688            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7689                return Err(SourceError::Refused {
7690                    message: format!(
7691                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7692                         names it as a {}; record the kind it is",
7693                        disagreeing.kind.describes(),
7694                        edge.to.kind.marker()
7695                    ),
7696                });
7697            }
7698            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7699            // however the far end is spelled — and one classified native here would be
7700            // written nowhere at all, because a draft's native reconciliation never runs.
7701            let native_here = near_content == ContentKind::Issue
7702                && far.is_some_and(|far| {
7703                    far.content_kind == ContentKind::Issue
7704                        && BoardKind::Work(edge.to.kind) == near_kind
7705                });
7706            if native_here {
7707                native.push(far_id.to_owned());
7708            } else {
7709                fallback.push(edge.clone());
7710            }
7711        }
7712        Ok((native, fallback))
7713    }
7714
7715    async fn update_existing(
7716        &self,
7717        item: &Resolved,
7718        incoming: &Incoming<'_>,
7719        body: &Option<String>,
7720        status_target: Option<&StatusTarget>,
7721    ) -> Result<(), SourceError> {
7722        let title = incoming.written_title();
7723        // A terminal status closes the issue here, in the same mutation as its body: its board
7724        // option was selected before this, so a close never lands on an item whose board cannot
7725        // show it.
7726        let fields = match item.content_kind {
7727            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7728            ContentKind::Issue => json!({"title":title,"body":body,
7729                                         "stateInput":state_input(status_target)}),
7730        };
7731        self.update_content(item.content_kind, &item.id, fields)
7732            .await
7733    }
7734
7735    /// Update one board item's content with exactly `fields` beside its id, through the
7736    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7737    /// a draft.
7738    ///
7739    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7740    /// is what lets a narrow write carry the one thing it changes and nothing else.
7741    async fn update_content(
7742        &self,
7743        kind: ContentKind,
7744        id: &NativeId,
7745        fields: Value,
7746    ) -> Result<(), SourceError> {
7747        let (operation, id_key, pointer) = match kind {
7748            ContentKind::DraftIssue => (
7749                graphql::UPDATE_DRAFT,
7750                "draftIssueId",
7751                "/updateProjectV2DraftIssue/draftIssue",
7752            ),
7753            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7754        };
7755        let mut input = fields;
7756        input[id_key] = json!(id.0);
7757        let data = self.graphql(operation, json!({"input":input})).await?;
7758        let returned = data
7759            .pointer(pointer)
7760            .ok_or_else(|| SourceError::Malformed {
7761                message: "GitHub item update returned no item".into(),
7762            })?;
7763        if required_str(returned, "id")? != id.0 {
7764            return Err(SourceError::Malformed {
7765                message: "GitHub item update returned the wrong item".into(),
7766            });
7767        }
7768        Ok(())
7769    }
7770
7771    /// Creates one issue, files it on the board, and reports what a read of it would say:
7772    /// its content id, its board item id, and the web address GitHub gave it.
7773    ///
7774    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7775    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7776    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7777    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7778    /// "Content already exists in this project". A terminal status is not written here:
7779    /// `finish_write` selects its option first and closes the issue after, so a close never
7780    /// lands on an item whose board cannot show it.
7781    ///
7782    /// The address and the number come back here because this is the only place either is
7783    /// known before GitHub's own board read catches up — an item this run created answers
7784    /// the reads that follow it out of the record below, and one remembered without them
7785    /// would report no location and no key for the rest of the run.
7786    async fn create_and_file_issue(
7787        &self,
7788        board_id: &str,
7789        repository: &RepositoryTarget,
7790        incoming: &Incoming<'_>,
7791        body: &Option<String>,
7792    ) -> Result<Landed, SourceError> {
7793        let repository_id = self.repository_id(repository, incoming).await?;
7794        let data = self
7795            .graphql(
7796                graphql::CREATE_ISSUE,
7797                json!({"input":{
7798                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7799                }}),
7800            )
7801            .await?;
7802        let created = data
7803            .pointer("/createIssue/issue")
7804            .filter(|value| !value.is_null())
7805            .ok_or_else(|| SourceError::Malformed {
7806                message: "GitHub issue creation returned no issue".into(),
7807            })?;
7808        let content_id = NativeId(required_str(created, "id")?.to_owned());
7809        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7810        // a response without it is not worth failing a landed write over — the item simply
7811        // reports no location until the board read catches up, which is what it did before.
7812        let url = optional_str(created, "url")?.map(str::to_owned);
7813        // The issue exists from here on, so an unreadable number and a refused board
7814        // filing below each try, best effort, to take it back: an issue in the repository
7815        // that is on no board is an item nobody asked for and nothing here would find again.
7816        //
7817        // Its number is optional on the same terms its address is — a landed write is not
7818        // worth failing over a member that came back missing, and such an item reports no
7819        // handle until a board read catches up. A number that is *present* and is not an
7820        // unsigned integer is still a response this source cannot read.
7821        let number = match created_issue_number(created) {
7822            Ok(number) => number,
7823            Err(error) => {
7824                let _ = self.delete_issue(&content_id).await;
7825                return Err(error);
7826            }
7827        };
7828        let added = match self
7829            .graphql(
7830                graphql::ADD_TO_BOARD,
7831                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7832            )
7833            .await
7834        {
7835            Ok(added) => added,
7836            Err(error) => {
7837                let _ = self.delete_issue(&content_id).await;
7838                return Err(error);
7839            }
7840        };
7841        let item = added
7842            .pointer("/addProjectV2ItemById/item")
7843            .filter(|value| !value.is_null())
7844            .ok_or_else(|| SourceError::Malformed {
7845                message: "GitHub board addition returned no project item".into(),
7846            })?;
7847        Ok(Landed {
7848            content_id,
7849            item_id: required_str(item, "id")?.to_owned(),
7850            url,
7851            number,
7852        })
7853    }
7854
7855    /// Move one issue under the project it now belongs to, or out of the one it left.
7856    async fn reparent(
7857        &self,
7858        held: Option<NativeId>,
7859        child: &NativeId,
7860        wanted: Option<&NativeId>,
7861    ) -> Result<(), SourceError> {
7862        if held.as_ref() == wanted {
7863            return Ok(());
7864        }
7865        if let Some(held) = &held {
7866            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7867                .await?;
7868        }
7869        if let Some(wanted) = wanted {
7870            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7871                .await?;
7872        }
7873        Ok(())
7874    }
7875
7876    async fn sub_issue(
7877        &self,
7878        operation: &str,
7879        parent: &NativeId,
7880        child: &NativeId,
7881        root: &str,
7882    ) -> Result<(), SourceError> {
7883        let data = self
7884            .graphql(
7885                operation,
7886                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7887            )
7888            .await?;
7889        let issue =
7890            data.pointer(&format!("/{root}/issue"))
7891                .ok_or_else(|| SourceError::Malformed {
7892                    message: "GitHub sub-issue update returned no issue".into(),
7893                })?;
7894        let sub =
7895            data.pointer(&format!("/{root}/subIssue"))
7896                .ok_or_else(|| SourceError::Malformed {
7897                    message: "GitHub sub-issue update returned no sub-issue".into(),
7898                })?;
7899        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7900            return Err(SourceError::Malformed {
7901                message: "GitHub sub-issue update returned the wrong issues".into(),
7902            });
7903        }
7904        Ok(())
7905    }
7906
7907    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7908    /// whether there was one.
7909    ///
7910    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7911    /// relationships are not read: there is nothing a read of them could find.
7912    async fn reconcile_blocked_by(
7913        &self,
7914        content_id: &NativeId,
7915        native: &[String],
7916        issue: Issue<'_>,
7917    ) -> Result<bool, SourceError> {
7918        let current = match issue {
7919            Issue::Created => Vec::new(),
7920            Issue::Existing(Some(held)) => held
7921                .iter()
7922                .map(|far| required_str(far, "id").map(str::to_owned))
7923                .collect::<Result<Vec<_>, _>>()?,
7924            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7925        };
7926        let mut changed = false;
7927        for (operation, far_id) in current
7928            .iter()
7929            .filter(|id| !native.contains(id))
7930            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7931            .chain(
7932                native
7933                    .iter()
7934                    .filter(|id| !current.contains(id))
7935                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7936            )
7937        {
7938            let data = self
7939                .graphql(
7940                    operation,
7941                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7942                )
7943                .await?;
7944            let root = if operation == graphql::ADD_BLOCKED_BY {
7945                "addBlockedBy"
7946            } else {
7947                "removeBlockedBy"
7948            };
7949            let issue =
7950                data.pointer(&format!("/{root}/issue"))
7951                    .ok_or_else(|| SourceError::Malformed {
7952                        message: "GitHub dependency update returned no issue".into(),
7953                    })?;
7954            let blocker = data
7955                .pointer(&format!("/{root}/blockingIssue"))
7956                .ok_or_else(|| SourceError::Malformed {
7957                    message: "GitHub dependency update returned no blocking issue".into(),
7958                })?;
7959            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7960            {
7961                return Err(SourceError::Malformed {
7962                    message: "GitHub dependency update returned the wrong issues".into(),
7963                });
7964            }
7965            changed = true;
7966        }
7967        Ok(changed)
7968    }
7969}
7970
7971/// What a write needs to know of one far end it names: which kind of item it is, and whether
7972/// it is an issue a native relationship can name.
7973struct FarEnd {
7974    kind: BoardKind,
7975    content_kind: ContentKind,
7976}
7977
7978/// Whether the issue one write reconciles was created by that write or was already there.
7979#[derive(Clone, Copy, PartialEq, Eq)]
7980enum Issue<'a> {
7981    /// Created by this write, so it holds no relationships yet.
7982    Created,
7983    /// On the board before this write, holding whatever relationships it holds — the far
7984    /// ends of its whole `blockedBy`, when the read that reached it carried them.
7985    Existing(Option<&'a [Value]>),
7986}
7987
7988/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7989enum Reached {
7990    /// An issue this board holds, resolved into everything this source reports about it.
7991    Held(Box<Resolved>),
7992    /// Nothing this board holds: no such node, or a node on some other board.
7993    Nothing,
7994    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7995    /// again by [`GitHubProjectsSource::draft_by_id`].
7996    Draft,
7997}
7998
7999/// What GitHub says when a string is not a node id it can resolve.
8000///
8001/// Matched because it is the ordinary answer to a project selector naming a project by its
8002/// *name*, and reporting that as a failure would make naming one impossible. It is read
8003/// off the refusal GitHub sent, never guessed from the shape of the string: this source
8004/// does not define the syntax of a GitHub node id and would be wrong about it.
8005const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
8006
8007/// `error` with `note` added to the end of what it says, its kind and every other member
8008/// unchanged — so a caller still branches on the failure that happened, and reads beside it
8009/// what that failure left behind.
8010fn noting(error: SourceError, note: &str) -> SourceError {
8011    match error {
8012        SourceError::Config { message } => SourceError::Config {
8013            message: message + note,
8014        },
8015        SourceError::Auth { message } => SourceError::Auth {
8016            message: message + note,
8017        },
8018        SourceError::Refused { message } => SourceError::Refused {
8019            message: message + note,
8020        },
8021        SourceError::RateLimited {
8022            retry_after_seconds,
8023            message,
8024        } => SourceError::RateLimited {
8025            retry_after_seconds,
8026            message: Some(message.unwrap_or_default() + note),
8027        },
8028        SourceError::Unavailable { message } => SourceError::Unavailable {
8029            message: message + note,
8030        },
8031        SourceError::Malformed { message } => SourceError::Malformed {
8032            message: message + note,
8033        },
8034    }
8035}
8036
8037/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
8038/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
8039/// for them.
8040///
8041/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
8042/// is read again at no added price.
8043fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
8044    let mut variables = serde_json::Map::new();
8045    for slot in 0..DETAIL_BATCH {
8046        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
8047        variables.insert(format!("id{slot}"), json!(id));
8048    }
8049    variables.insert(
8050        "first".to_owned(),
8051        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
8052    );
8053    variables.insert("comments".to_owned(), json!(comments.is_some()));
8054    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
8055    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
8056    variables.insert("duplicates".to_owned(), json!(true));
8057    Value::Object(variables)
8058}
8059
8060/// Whether this refusal is GitHub saying the id names no node at all.
8061fn unresolvable_node(error: &SourceError) -> bool {
8062    matches!(error, SourceError::Refused { message }
8063        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
8064}
8065
8066/// One project name, as a search qualifier which filters on it at the server.
8067///
8068/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8069/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8070/// the way it documents. A title matched here is still compared for equality afterwards:
8071/// the qualifier narrows what the server sends, and this source decides what it names.
8072fn title_qualifier(name: &str) -> String {
8073    format!("in:title {}", quoted(name))
8074}
8075
8076/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8077/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8078/// it documents — so a value holding a qualifier's spelling is searched for rather than
8079/// obeyed.
8080fn quoted(value: &str) -> String {
8081    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8082    format!("\"{escaped}\"")
8083}
8084
8085/// The search qualifier for the issues updated at or after `since`.
8086///
8087/// Written to the second, rounded down, which can only widen what the search returns.
8088fn updated_qualifier(since: DateTime<Utc>) -> String {
8089    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8090}
8091
8092/// The search terms that narrow a board-scoped issue search to a task query's text and
8093/// metadata predicates, or `None` when it carries neither.
8094///
8095/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8096/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8097/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8098/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8099/// a metadata value searches both fields for both — wider than asked, never narrower, and
8100/// every candidate is confirmed in process afterwards.
8101///
8102/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8103/// matches whole tokens where a substring rule would match inside a word, so an item holding
8104/// the text only inside a longer word is not returned. A text of nothing but whitespace
8105/// matches every item, so it narrows nothing and is not sent.
8106fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8107    let text = query
8108        .text
8109        .as_ref()
8110        .filter(|text| !text.terms.trim().is_empty());
8111    if text.is_none() && query.metadata.is_empty() {
8112        return None;
8113    }
8114    let (title, body) = match text.map(|text| text.fields) {
8115        None => (false, true),
8116        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8117        Some(TextFields::Content) => (false, true),
8118        Some(TextFields::TitleOrContent) => (true, true),
8119    };
8120    let fields = match (title, body) {
8121        (true, true) => "in:title,body",
8122        (true, false) => "in:title",
8123        _ => "in:body",
8124    };
8125    let phrases = text
8126        .map(|text| text.terms.clone())
8127        .into_iter()
8128        .chain(
8129            query
8130                .metadata
8131                .iter()
8132                .map(|wanted| as_stored(wanted.value())),
8133        )
8134        .map(|phrase| quoted(&phrase))
8135        .collect::<Vec<_>>();
8136    Some(format!("{fields} {}", phrases.join(" ")))
8137}
8138
8139/// The search terms that narrow a board-scoped issue search to a project or document query's
8140/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8141/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8142fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8143    narrowing_qualifiers(&TaskQuery {
8144        text: text.cloned(),
8145        ..TaskQuery::default()
8146    })
8147}
8148
8149/// Refuses a project or document query's text GitHub's issue search cannot find, before
8150/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8151/// query's.
8152fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8153    refuse_unsearchable(&TaskQuery {
8154        text: text.cloned(),
8155        ..TaskQuery::default()
8156    })
8157}
8158
8159/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8160/// before anything is asked of GitHub.
8161///
8162/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8163/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8164/// left out, the search is every issue of the board. So this source says it cannot answer
8165/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8166/// nothing GitHub could search for, and keeps the board read it always had.
8167fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8168    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8169                       letter or digit with a bounded query";
8170    if let Some(text) = &query.text
8171        && !text.terms.trim().is_empty()
8172        && !has_words(&text.terms)
8173    {
8174        return Err(SourceError::Refused {
8175            message: format!(
8176                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8177                text.terms
8178            ),
8179        });
8180    }
8181    if let Some(wanted) = query
8182        .metadata
8183        .iter()
8184        .find(|wanted| !has_words(wanted.value()))
8185    {
8186        return Err(SourceError::Refused {
8187            message: format!(
8188                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8189                wanted.value(),
8190                std::iter::once(wanted.key())
8191                    .chain(wanted.path().iter().map(String::as_str))
8192                    .collect::<Vec<_>>()
8193                    .join("/"),
8194            ),
8195        });
8196    }
8197    Ok(())
8198}
8199
8200/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8201fn has_words(phrase: &str) -> bool {
8202    phrase.chars().any(char::is_alphanumeric)
8203}
8204
8205/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8206///
8207/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8208/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8209/// which GitHub's word match would read as different words.
8210fn as_stored(value: &str) -> String {
8211    let encoded = Value::String(value.to_owned()).to_string();
8212    encoded[1..encoded.len() - 1].to_owned()
8213}
8214
8215/// The one narrower question a task query carrying a text, metadata or origin predicate is
8216/// sent as.
8217enum Narrowing {
8218    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8219    Origin(String),
8220    /// The board-scoped issue search narrowed by these qualifiers.
8221    Search(String),
8222}
8223
8224impl Narrowing {
8225    /// What this question is remembered under for the length of one command.
8226    fn key(&self) -> String {
8227        match self {
8228            Self::Origin(origin) => format!("origin {origin}"),
8229            Self::Search(also) => format!("search {also}"),
8230        }
8231    }
8232}
8233
8234/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8235enum Resumed {
8236    /// It reported another page, which starts after this cursor.
8237    More(String),
8238    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8239    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8240    /// can go on walking the other connection.
8241    Ended(Option<String>),
8242}
8243
8244impl Resumed {
8245    /// Whether the connection has another page.
8246    const fn has_more(&self) -> bool {
8247        matches!(self, Self::More(_))
8248    }
8249
8250    /// The cursor to send this connection next.
8251    fn cursor(self) -> Option<String> {
8252        match self {
8253            Self::More(next) => Some(next),
8254            Self::Ended(last) => last,
8255        }
8256    }
8257}
8258
8259/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8260/// with no cursor to it, or from a cursor that does not advance.
8261fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8262    let info = connection
8263        .get("pageInfo")
8264        .ok_or_else(|| SourceError::Malformed {
8265            message: "GitHub connection has no pageInfo".into(),
8266        })?;
8267    let end = optional_str(info, "endCursor")?;
8268    if required_bool(info, "hasNextPage")? {
8269        let next = end.ok_or_else(|| SourceError::Malformed {
8270            message: "GitHub connection reports another page and no endCursor".into(),
8271        })?;
8272        validate_cursor_progress(after, next)?;
8273        return Ok(Resumed::More(next.to_owned()));
8274    }
8275    Ok(Resumed::Ended(
8276        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8277    ))
8278}
8279
8280/// The board, and every item on it this source reports.
8281#[derive(Clone)]
8282struct Board {
8283    id: String,
8284    fields: Value,
8285    items: Vec<Resolved>,
8286}
8287
8288/// What a write needs of the board and nothing more: its node id and its field
8289/// definitions, in the shape a read of the board's own `fields` gives them.
8290///
8291/// Deliberately no items. A write decides which item it writes, which parent it files
8292/// under and which far ends it names by reading each of them by its own id; this is the
8293/// half of the board those reads cannot carry, and holding no item is what keeps it from
8294/// ever being asked whether an item is there.
8295#[derive(Clone)]
8296struct BoardFields {
8297    id: BoardId,
8298    fields: Value,
8299}
8300
8301/// A board's node id: what a field write and `addProjectV2ItemById` address.
8302///
8303/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8304/// refused where it is read, and one an item names blank is read as not named at all.
8305#[derive(Clone)]
8306struct BoardId(String);
8307
8308/// Where one write left its item, for the record the rest of the command reads it out of.
8309///
8310/// A named record rather than a tuple because the update arm and the create arm each fill
8311/// all four, and two `Option`s of different meaning side by side in a tuple are two
8312/// positions a reader has to count.
8313struct Landed {
8314    /// The issue's own node id, which is the [`NativeId`] this source reports.
8315    content_id: NativeId,
8316    /// The board item's id, which is what a field write addresses.
8317    // 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.
8318    item_id: String,
8319    /// The web address GitHub gave the issue, when it gave one.
8320    // 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.
8321    url: Option<String>,
8322    /// The issue's number on its repository, when GitHub reported one.
8323    number: Option<u64>,
8324}
8325
8326impl BoardId {
8327    fn parse(id: &str) -> Result<Self, SourceError> {
8328        if id.trim().is_empty() {
8329            return Err(SourceError::Malformed {
8330                message: "GitHub named a board with a blank node id".into(),
8331            });
8332        }
8333        Ok(Self(id.to_owned()))
8334    }
8335
8336    fn as_str(&self) -> &str {
8337        &self.0
8338    }
8339}
8340
8341impl Board {
8342    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8343        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8344        let nodes = fields
8345            .get("nodes")
8346            .and_then(Value::as_array)
8347            .ok_or_else(|| SourceError::Malformed {
8348                message: "GitHub project fields.nodes is not an array".into(),
8349            })?;
8350        Ok(nodes
8351            .iter()
8352            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8353    }
8354}
8355
8356/// One board item, resolved into everything this source reports about it.
8357#[derive(Clone)]
8358struct Resolved {
8359    item_id: String,
8360    id: NativeId,
8361    content_kind: ContentKind,
8362    kind: BoardKind,
8363    title: String,
8364    body: Option<String>,
8365    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8366    /// that changes the slot alone has to keep byte for byte outside it.
8367    raw_body: Option<String>,
8368    status: Status,
8369    /// The name of the board `Status` option this item sits in, as the board spells it.
8370    option: Option<String>,
8371    /// What its `Priority` field says, read through this instance's mapping.
8372    priority: HeldPriority,
8373    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8374    closed: bool,
8375    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8376    delivers: Vec<TaskRef>,
8377    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8378    /// task.
8379    delivered_by: Vec<TaskRef>,
8380    labels: Vec<Label>,
8381    parent: Option<NativeId>,
8382    // 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.
8383    origin: Option<String>,
8384    /// The issue's own number on its repository, as GitHub reports it.
8385    ///
8386    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8387    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8388    /// an issue this run created whose creating mutation answered without one, which is a
8389    /// response GitHub's own schema says cannot happen and which a landed write is not
8390    /// worth failing over. An `Issue` read off the board always has one.
8391    number: Option<u64>,
8392    url: Option<String>,
8393    created_at: Option<DateTime<Utc>>,
8394    updated_at: Option<DateTime<Utc>>,
8395    own_repository: Option<Repository>,
8396    repositories: Vec<Repository>,
8397    slot: BTreeMap<String, Value>,
8398    /// The node id of the board this item sits on, when the read that reached it said.
8399    board_id: Option<String>,
8400    /// The definition of every board field this item holds a value of, in the shape a read
8401    /// of the board's own `fields` gives one.
8402    ///
8403    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8404    /// which says nothing about whether the board has it.
8405    fields: Vec<Value>,
8406    /// Every field the board this item sits on defines, as its own read of the board's
8407    /// `fields` gives them — when the read that reached the item carried them, which a read
8408    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8409    /// of the board.
8410    board_fields: Option<Value>,
8411    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8412    /// selects one — when the read that reached it carried the connection to its end, which a
8413    /// read of it by its own id does for any issue blocked by no more than a page. What a
8414    /// write reconciles that relationship against, and what a read of its forward edges in
8415    /// the same command answers with.
8416    blocked_by: Option<Vec<Value>>,
8417}
8418
8419impl Resolved {
8420    /// The board this item's own read names it on, when that read named one this source can
8421    /// address.
8422    fn named_board(&self) -> Option<BoardId> {
8423        self.board_id
8424            .as_deref()
8425            .and_then(|id| BoardId::parse(id).ok())
8426    }
8427
8428    /// The board's id and every field it defines, when the read that reached this item
8429    /// carried both — which a read of it by its own id does.
8430    fn carried_board(&self) -> Option<BoardFields> {
8431        Some(BoardFields {
8432            id: self.named_board()?,
8433            fields: self.board_fields.clone()?,
8434        })
8435    }
8436
8437    /// Whether this item holds a value of the board field called `name`, and so carries
8438    /// that field's definition. `false` says nothing about whether the board has the field.
8439    fn defines(&self, name: &str) -> bool {
8440        self.fields
8441            .iter()
8442            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8443    }
8444
8445    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8446    /// in a field of its own, and none of the five keys that are only an encoding.
8447    ///
8448    /// The two delivery keys are left out for every kind, not only for a task: they are
8449    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8450    /// document carrying one holds nothing a caller's own metadata could mean by it.
8451    fn metadata(&self) -> BTreeMap<String, Value> {
8452        let mut metadata = self.slot.clone();
8453        metadata.remove(Repository::METADATA_KEY);
8454        metadata.remove(DependencyEdge::RECORDED_KEY);
8455        metadata.remove(ItemKind::METADATA_KEY);
8456        metadata.remove(TaskRef::DELIVERS_KEY);
8457        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8458        // The board field is the origin, and the body's copy of it is only a mirror for the
8459        // issue search to find: an item whose field holds none has none, whatever its body
8460        // says, so no reader ever sees two answers.
8461        metadata.remove(ORIGIN_KEY);
8462        if let Some(origin) = &self.origin {
8463            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8464        }
8465        metadata
8466    }
8467
8468    /// Where this item is, as a link a reader can open.
8469    ///
8470    /// A board is a hosted place and every issue on it has a web address, so that address
8471    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8472    /// of place it is, so a reader knows to open it rather than to read a file out. It
8473    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8474    /// reported before, and this says what that address *is*.
8475    ///
8476    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8477    /// rather than a third variant, which is the contract's "the source did not say". An
8478    /// issue this run created is not one of those: its address comes back from the
8479    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8480    /// rather than from whenever the board read catches up.
8481    fn location(&self) -> Option<Location> {
8482        self.url.clone().map(Location::Url)
8483    }
8484
8485    /// The short handle this board's backend shows people for a task: the issue's number
8486    /// alone, as a decimal string.
8487    ///
8488    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8489    /// value for this backend. A draft has no number and so no handle, which is the
8490    /// contract's *absent* rather than a handle of some other shape — and the native
8491    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8492    /// derives from.
8493    fn key(&self) -> Option<String> {
8494        self.number.map(|number| number.to_string())
8495    }
8496
8497    /// Whether its `Priority` field holds a value at all, mapped or not.
8498    fn holds_priority(&self) -> bool {
8499        self.priority != HeldPriority::Read(Priority::None)
8500    }
8501
8502    /// The task this item is.
8503    ///
8504    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8505    /// reading that as a level would be a guess, and reading it as `none` would let the next
8506    /// copy clear a priority a person set.
8507    fn task(&self) -> Result<Task, SourceError> {
8508        let priority = match &self.priority {
8509            HeldPriority::Read(priority) => *priority,
8510            HeldPriority::Unmapped(option) => {
8511                return Err(SourceError::Malformed {
8512                    message: format!(
8513                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8514                         this source's priority_mapping does not name, so its priority cannot be \
8515                         read; next: name {option:?} under priority_mapping, or move the item to \
8516                         a mapped option",
8517                        self.id,
8518                        self.number
8519                            .map(|number| format!(" (#{number})"))
8520                            .unwrap_or_default()
8521                    ),
8522                });
8523            }
8524        };
8525        Ok(Task {
8526            id: self.id.clone(),
8527            key: self.key(),
8528            title: self.title.clone(),
8529            content: self.body.clone(),
8530            status: self.status.clone(),
8531            priority,
8532            labels: self.labels.clone(),
8533            project: self.parent.clone(),
8534            url: self.url.clone(),
8535            location: self.location(),
8536            created_at: self.created_at,
8537            updated_at: self.updated_at,
8538            metadata: self.metadata(),
8539            repositories: self.repositories.clone(),
8540            delivers: self.delivers.clone(),
8541            delivered_by: self.delivered_by.clone(),
8542        })
8543    }
8544
8545    fn project(&self) -> Project {
8546        Project {
8547            id: self.id.clone(),
8548            title: self.title.clone(),
8549            content: self.body.clone(),
8550            status: self.status.clone(),
8551            labels: self.labels.clone(),
8552            url: self.url.clone(),
8553            location: self.location(),
8554            created_at: self.created_at,
8555            updated_at: self.updated_at,
8556            metadata: self.metadata(),
8557            repositories: self.repositories.clone(),
8558        }
8559    }
8560
8561    /// The same issue as a document: the project it is filed under, and no status and no
8562    /// dependencies, because a document is not work.
8563    fn document(&self) -> Document {
8564        Document {
8565            id: self.id.clone(),
8566            title: self.title.clone(),
8567            content: self.body.clone(),
8568            project: self.parent.clone(),
8569            labels: self.labels.clone(),
8570            url: self.url.clone(),
8571            location: self.location(),
8572            created_at: self.created_at,
8573            updated_at: self.updated_at,
8574            metadata: self.metadata(),
8575            repositories: self.repositories.clone(),
8576        }
8577    }
8578}
8579
8580/// Where one targeted update moves an item's status, and which of its two halves move.
8581struct StatusMove {
8582    /// The board the item's `Status` field is on.
8583    board: BoardId,
8584    /// The `Status` field's id.
8585    field: String,
8586    /// The option's id.
8587    option: String,
8588    /// The option's name, as the board spells it.
8589    name: String,
8590    /// What the status asks of the issue's state.
8591    target: StatusTarget,
8592    /// The status the item reads as once it is there.
8593    landed: Status,
8594    /// Which of the status's two halves differ from what the item holds.
8595    moves: Moves,
8596}
8597
8598/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8599/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8600/// and is not a value of this type.
8601#[derive(Clone, Copy, PartialEq, Eq)]
8602enum Moves {
8603    /// The option alone.
8604    Option,
8605    /// The issue's state alone: open, closed, or closed with another reason.
8606    State,
8607    /// Both.
8608    Both,
8609}
8610
8611impl Moves {
8612    /// What differs, or `None` when nothing does.
8613    const fn of(option: bool, state: bool) -> Option<Self> {
8614        match (option, state) {
8615            (true, true) => Some(Self::Both),
8616            (true, false) => Some(Self::Option),
8617            (false, true) => Some(Self::State),
8618            (false, false) => None,
8619        }
8620    }
8621
8622    /// Whether the option moves.
8623    const fn option(self) -> bool {
8624        matches!(self, Self::Option | Self::Both)
8625    }
8626
8627    /// Whether the issue's state moves.
8628    const fn state(self) -> bool {
8629        matches!(self, Self::State | Self::Both)
8630    }
8631}
8632
8633/// What one write is, and the status that comes with being it.
8634///
8635/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8636/// status and a task or a project always has one, so "a document carrying a status" and
8637/// "a task carrying none" are states a write cannot be in rather than states every use
8638/// site below has to defend against.
8639enum Written<'a> {
8640    /// A document, which is not work and so has no status at all.
8641    Document,
8642    /// A task or a project, and the status it is being written with.
8643    Work(ItemKind, &'a Status),
8644}
8645
8646impl Written<'_> {
8647    /// Which of the board's three kinds this write is.
8648    const fn kind(&self) -> BoardKind {
8649        match self {
8650            Self::Document => BoardKind::Document,
8651            Self::Work(kind, _) => BoardKind::Work(*kind),
8652        }
8653    }
8654
8655    /// The status this write carries. A document carries none, so a write of one says
8656    /// nothing about the issue's open or closed state and selects no board `Status`
8657    /// option.
8658    const fn status(&self) -> Option<&Status> {
8659        match self {
8660            Self::Document => None,
8661            Self::Work(_, status) => Some(status),
8662        }
8663    }
8664
8665    /// The status this write carries with the kind whose half of `status_mapping` it is
8666    /// written through.
8667    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8668        match self {
8669            Self::Document => None,
8670            Self::Work(kind, status) => Some((*kind, status)),
8671        }
8672    }
8673}
8674
8675/// The item being written, in the one shape all three write methods reach.
8676struct Incoming<'a> {
8677    written: Written<'a>,
8678    /// The title a person wrote. A document's goes onto the issue with
8679    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8680    title: &'a str,
8681    content: Option<&'a str>,
8682    labels: &'a [Label],
8683    metadata: &'a BTreeMap<String, Value>,
8684    repositories: &'a [Repository],
8685    parent: Option<&'a NativeId>,
8686    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8687    /// what keeps either key out of their slot.
8688    delivers: &'a [TaskRef],
8689    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8690    delivered_by: &'a [TaskRef],
8691    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8692    /// project, a document, and every write to an instance with no `priority_mapping` —
8693    /// which is what keeps such a write's requests exactly what they were before.
8694    priority: Option<Priority>,
8695}
8696
8697/// What one write does to an item's `Priority` field.
8698enum PriorityWrite {
8699    /// Select this option of this field.
8700    Select {
8701        /// The `Priority` field's id.
8702        field: String,
8703        /// The mapped option's id.
8704        option: String,
8705    },
8706    /// Clear the field's value, which is what `none` is.
8707    Clear {
8708        /// The `Priority` field's id.
8709        field: String,
8710    },
8711}
8712
8713impl Incoming<'_> {
8714    /// The title this write puts on the issue.
8715    fn written_title(&self) -> String {
8716        match self.written {
8717            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8718            Written::Work(..) => self.title.to_owned(),
8719        }
8720    }
8721}
8722
8723#[derive(Clone, Copy, PartialEq, Eq)]
8724enum ContentKind {
8725    DraftIssue,
8726    Issue,
8727}
8728
8729/// What one board issue is: a document, or the work an [`ItemKind`] names.
8730///
8731/// A type of this source's own rather than an `ItemKind` with a third variant, because
8732/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8733/// document — the contract keeps a document out of that enum deliberately. Holding the
8734/// board's three answers in one value is what makes every place that asks "which is this?"
8735/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8736/// two thirds of the board.
8737#[derive(Clone, Copy, PartialEq, Eq)]
8738enum BoardKind {
8739    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8740    Document,
8741    /// Every other issue, and every draft.
8742    Work(ItemKind),
8743}
8744
8745impl BoardKind {
8746    /// Whose half of `status_mapping` an item of this kind reads its status through. A
8747    /// document has no status of its own, so the task half stands in for whatever the issue
8748    /// holds; nothing reports it.
8749    const fn status_kind(self) -> ItemKind {
8750        match self {
8751            Self::Document => ItemKind::Task,
8752            Self::Work(kind) => kind,
8753        }
8754    }
8755
8756    /// How a refusal names this kind to the person reading it.
8757    const fn describes(self) -> &'static str {
8758        match self {
8759            Self::Document => "document",
8760            Self::Work(kind) => kind.marker(),
8761        }
8762    }
8763}
8764
8765/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8766///
8767/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8768/// the shared cross-source journeys assert one answer to one question, so two sources
8769/// that disagree about what "carries the label bug" means fail them.
8770fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8771    let holds = |name: &String| {
8772        labels
8773            .iter()
8774            .any(|label| label.name.eq_ignore_ascii_case(name))
8775    };
8776    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8777        && filter.all_of.iter().all(holds)
8778        && !filter.none_of.iter().any(holds)
8779}
8780
8781/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8782/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8783fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8784    statuses.is_empty() || statuses.contains(&category)
8785}
8786
8787/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8788///
8789/// `content` is the item's own prose — the body with this source's trailing metadata
8790/// comment already taken off — so a search never matches an encoding the author of the
8791/// issue never wrote.
8792fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8793    let terms = query.terms.to_lowercase();
8794    let in_title = title.to_lowercase().contains(&terms);
8795    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8796    match query.fields {
8797        TextFields::Title => in_title,
8798        TextFields::Content => in_content,
8799        TextFields::TitleOrContent => in_title || in_content,
8800    }
8801}
8802
8803/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8804///
8805/// The project predicate is passed separately because a read narrowed to one project has
8806/// already answered it by asking *that project* for its own items — and re-applying it
8807/// there would compare the caller's selector, which may be a project's **name**, against
8808/// the id of the project that name resolved to, and keep nothing. Every other read passes
8809/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8810/// source really does apply.
8811fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8812    labels_match(&task.labels, &query.labels)
8813        && status_matches(task.status.category, &query.statuses)
8814        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8815        && match project {
8816            ProjectFilter::Any => true,
8817            ProjectFilter::Orphans => task.project.is_none(),
8818            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8819        }
8820        && query
8821            .text
8822            .as_ref()
8823            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8824        // Against the parsed metadata slot, and against the origin field, which is where
8825        // `Resolved::metadata` reads each of them from.
8826        && query.metadata_matches(&task.metadata)
8827        && query.origin_matches(&task.metadata)
8828}
8829
8830fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8831    labels_match(&project.labels, &query.labels)
8832        && status_matches(project.status.category, &query.statuses)
8833        && query
8834            .text
8835            .as_ref()
8836            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8837}
8838
8839/// The same three predicates a task query carries, minus the status filter.
8840///
8841/// A document is not work, so it has no status for one to compare against and the query
8842/// type carries none. The project predicate is the same one — a design issue filed under a
8843/// project issue is in that project, and one filed under nothing is in none — so it is
8844/// spelled the same way here rather than answered differently.
8845fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8846    labels_match(&document.labels, &query.labels)
8847        && match project {
8848            ProjectFilter::Any => true,
8849            ProjectFilter::Orphans => document.project.is_none(),
8850            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8851        }
8852        && query
8853            .text
8854            .as_ref()
8855            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8856}
8857
8858#[async_trait::async_trait]
8859impl TaskSource for GitHubProjectsSource {
8860    fn kind(&self) -> &'static str {
8861        KIND
8862    }
8863    fn capabilities(&self) -> Capabilities {
8864        Capabilities {
8865            projects: Support::Native,
8866            documents: Support::Native,
8867            comments: Support::Native,
8868            assets: Support::Unsupported,
8869            priority: if self.priorities.is_some() {
8870                Support::Native
8871            } else {
8872                Support::Unsupported
8873            },
8874            filter_by_priority: Support::Native,
8875            filter_by_comment_activity: Support::Native,
8876            filter_by_metadata: Support::Native,
8877            filter_by_origin: Support::Native,
8878            orphan_tasks: Support::Native,
8879            filter_by_label: Support::Native,
8880            filter_by_status: Support::Native,
8881            search_title: Support::Native,
8882            search_content: Support::Native,
8883            task_dependencies: DependencySupport::BothDirections,
8884            project_dependencies: DependencySupport::BothDirections,
8885            max_page_size: MAX_PAGE_SIZE,
8886        }
8887    }
8888    async fn health(&self) -> Result<Health, SourceError> {
8889        let board = self.board_page(None, 1).await?;
8890        Ok(Health {
8891            reachable: true,
8892            detail: Some(format!(
8893                "reading GitHub project {}/{} ({})",
8894                self.owner,
8895                self.project_number,
8896                required_str(&board, "title")?
8897            )),
8898        })
8899    }
8900    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8901        self.item_by_id(id)
8902            .await?
8903            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8904            .map(|item| item.task())
8905            .transpose()
8906    }
8907    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8908        Ok(self
8909            .item_by_id(id)
8910            .await?
8911            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8912            .map(|item| item.project()))
8913    }
8914    async fn query_tasks(
8915        &self,
8916        query: &TaskQuery,
8917        page: &PageRequest,
8918    ) -> Result<Page<Task>, SourceError> {
8919        validate_page(page)?;
8920        refuse_unsearchable(query)?;
8921        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8922            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8923                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8924                (Some(also), None) => Some(also),
8925                (None, Some(since)) => Some(updated_qualifier(since)),
8926                (None, None) => None,
8927            };
8928            if let Some(also) = qualifiers {
8929                return self.search_tasks(query, page, &also).await;
8930            }
8931        }
8932
8933        // A read narrowed to one project asks that project for its own tasks, so nothing
8934        // about it costs what the rest of the board holds. A read carrying a text, metadata
8935        // or origin predicate asks GitHub the narrower question those predicates are, and a
8936        // read narrowed to comment activity alone asks the board's own issue search for the
8937        // issues updated since, which is every issue a comment could have been written or
8938        // edited on since. Every other task read is a question about the whole board and is
8939        // answered by reading it.
8940        let (held, membership) = match (&query.project, query.commented_since) {
8941            (ProjectFilter::Is(project), _) => (
8942                self.project_children(project).await?,
8943                // Answered by where these items came from; see `task_matches`.
8944                &ProjectFilter::Any,
8945            ),
8946            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8947                match (self.narrowed(query).await?, since) {
8948                    (Some(narrowed), _) => (narrowed, &query.project),
8949                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8950                    (None, None) => (self.board().await?.items, &query.project),
8951                }
8952            }
8953        };
8954        // Filtered before paged: a page of a filtered result is a page of the survivors,
8955        // never the survivors of a page.
8956        let mut tasks = Vec::new();
8957        for item in held
8958            .iter()
8959            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8960        {
8961            let task = item.task()?;
8962            if task_matches(&task, query, membership)
8963                && self.commented_since(item, query.commented_since).await?
8964            {
8965                tasks.push(task);
8966            }
8967        }
8968        Ok(offset_page(
8969            tasks,
8970            numeric_cursor(page.cursor.as_ref())?,
8971            page.limit.min(MAX_PAGE_SIZE) as usize,
8972        ))
8973    }
8974    async fn query_projects(
8975        &self,
8976        query: &ProjectQuery,
8977        page: &PageRequest,
8978    ) -> Result<Page<Project>, SourceError> {
8979        validate_page(page)?;
8980        refuse_unsearchable_text(query.text.as_ref())?;
8981        // The projects a board holds are found by an issue search scoped to that board,
8982        // never by walking the board's own item connection: what tells a project from a
8983        // task is the `parent` each issue carries, which costs nothing to read. A query
8984        // carrying a text asks that search for the text too, so it reads the issues that
8985        // hold it rather than every issue of the board.
8986        let held = match self.text_searched(query.text.as_ref()).await? {
8987            Some(searched) => searched,
8988            None => self.board_issues().await?,
8989        };
8990        let projects = held
8991            .iter()
8992            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8993            .map(Resolved::project)
8994            .filter(|project| project_matches(project, query))
8995            .collect();
8996        Ok(offset_page(
8997            projects,
8998            numeric_cursor(page.cursor.as_ref())?,
8999            page.limit.min(MAX_PAGE_SIZE) as usize,
9000        ))
9001    }
9002    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
9003        Ok(self
9004            .item_by_id(id)
9005            .await?
9006            .filter(|item| item.kind == BoardKind::Document)
9007            .map(|item| item.document()))
9008    }
9009    async fn query_documents(
9010        &self,
9011        query: &DocumentQuery,
9012        page: &PageRequest,
9013    ) -> Result<Page<Document>, SourceError> {
9014        validate_page(page)?;
9015        // Narrowed to one project, this is the same sub-issue read a task list scoped to
9016        // that project makes — a document filed under a project is a sub-issue of it too,
9017        // and which of them come back is the kind this caller asked for. Unscoped, a query
9018        // carrying a text asks the board-scoped issue search for it, as a task query does,
9019        // and only one carrying none reads the board.
9020        let (held, membership) = match &query.project {
9021            ProjectFilter::Is(project) => (
9022                self.project_children(project).await?,
9023                // Answered by where these items came from; see `task_matches`.
9024                &ProjectFilter::Any,
9025            ),
9026            ProjectFilter::Any | ProjectFilter::Orphans => {
9027                refuse_unsearchable_text(query.text.as_ref())?;
9028                match self.text_searched(query.text.as_ref()).await? {
9029                    Some(searched) => (searched, &query.project),
9030                    None => (self.board().await?.items, &query.project),
9031                }
9032            }
9033        };
9034        // Filtered before paged, exactly as a task read is: a page of a filtered result is
9035        // a page of the survivors, never the survivors of a page.
9036        let documents = held
9037            .iter()
9038            .filter(|item| item.kind == BoardKind::Document)
9039            .map(Resolved::document)
9040            .filter(|document| document_matches(document, query, membership))
9041            .collect();
9042        Ok(offset_page(
9043            documents,
9044            numeric_cursor(page.cursor.as_ref())?,
9045            page.limit.min(MAX_PAGE_SIZE) as usize,
9046        ))
9047    }
9048    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
9049        validate_page(page)?;
9050        let offset = numeric_cursor(page.cursor.as_ref())?;
9051        let mut labels = self
9052            .board()
9053            .await?
9054            .items
9055            .into_iter()
9056            .flat_map(|item| item.labels)
9057            .fold(Vec::new(), |mut all, label| {
9058                if !all.iter().any(|x: &Label| x.id == label.id) {
9059                    all.push(label);
9060                }
9061                all
9062            });
9063        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
9064        Ok(offset_page(
9065            labels,
9066            offset,
9067            page.limit.min(MAX_PAGE_SIZE) as usize,
9068        ))
9069    }
9070    async fn task_dependencies(
9071        &self,
9072        id: &NativeId,
9073        direction: Direction,
9074        page: &PageRequest,
9075    ) -> Result<Page<DependencyEdge>, SourceError> {
9076        self.dependencies(id, ItemKind::Task, direction, page).await
9077    }
9078    async fn project_dependencies(
9079        &self,
9080        id: &NativeId,
9081        direction: Direction,
9082        page: &PageRequest,
9083    ) -> Result<Page<DependencyEdge>, SourceError> {
9084        self.dependencies(id, ItemKind::Project, direction, page)
9085            .await
9086    }
9087
9088    fn writes(&self) -> WriteSupport {
9089        WriteSupport::Supported
9090    }
9091
9092    /// Create or update one task.
9093    ///
9094    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9095    /// neither may name the task itself or name one task twice — and land in the body's
9096    /// metadata slot under their reserved keys, in place of any caller metadata of those
9097    /// names.
9098    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9099        let near = write.target.as_ref().unwrap_or(&write.item.id);
9100        for (key, entries) in [
9101            (TaskRef::DELIVERS_KEY, &write.item.delivers),
9102            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9103        ] {
9104            TaskRef::listed(key, near, Some(&self.name), entries.clone())
9105                .map_err(|message| SourceError::Refused { message })?;
9106        }
9107        if self.priorities.is_none() && write.item.priority != Priority::None {
9108            return Err(self.holds_no_priority());
9109        }
9110        self.write_item(
9111            &Incoming {
9112                written: Written::Work(ItemKind::Task, &write.item.status),
9113                title: &write.item.title,
9114                content: write.item.content.as_deref(),
9115                labels: &write.item.labels,
9116                metadata: &write.item.metadata,
9117                repositories: &write.item.repositories,
9118                parent: write.item.project.as_ref(),
9119                delivers: &write.item.delivers,
9120                delivered_by: &write.item.delivered_by,
9121                priority: self.priorities.as_ref().map(|_| write.item.priority),
9122            },
9123            write.target.as_ref(),
9124            &write.depends_on,
9125        )
9126        .await
9127    }
9128
9129    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9130        self.write_item(
9131            &Incoming {
9132                written: Written::Work(ItemKind::Project, &write.item.status),
9133                title: &write.item.title,
9134                content: write.item.content.as_deref(),
9135                labels: &write.item.labels,
9136                metadata: &write.item.metadata,
9137                repositories: &write.item.repositories,
9138                parent: None,
9139                delivers: &[],
9140                delivered_by: &[],
9141                priority: None,
9142            },
9143            write.target.as_ref(),
9144            &write.depends_on,
9145        )
9146        .await
9147    }
9148
9149    /// Create or update one document, which is one issue titled the way this board spells
9150    /// a document.
9151    ///
9152    /// Everything else is exactly a task write: caller metadata goes to the same canonical
9153    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9154    /// or a field this board cannot carry is refused by name rather than dropped, a target
9155    /// naming an issue this board does not hold is refused rather than created, and an
9156    /// issue this call created is taken back when the rest of the write fails.
9157    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9158        // A document takes part in no dependency graph, so there is no far end to write
9159        // natively and none to record: a caller naming one is told so rather than having it
9160        // stored under the reserved key, where a later read would report an edge the
9161        // contract says cannot exist.
9162        if !write.depends_on.is_empty() {
9163            return Err(SourceError::Refused {
9164                message: format!(
9165                    "this write names {} dependencies for a document, and a document takes \
9166                     part in no dependency graph; next: put the dependency on the task or \
9167                     project the document is about",
9168                    write.depends_on.len()
9169                ),
9170            });
9171        }
9172        self.write_item(
9173            &Incoming {
9174                written: Written::Document,
9175                title: &write.item.title,
9176                content: write.item.content.as_deref(),
9177                labels: &write.item.labels,
9178                metadata: &write.item.metadata,
9179                repositories: &write.item.repositories,
9180                parent: write.item.project.as_ref(),
9181                delivers: &[],
9182                delivered_by: &[],
9183                priority: None,
9184            },
9185            write.target.as_ref(),
9186            &[],
9187        )
9188        .await
9189    }
9190
9191    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9192    /// which reads nothing; then the board's `Status` option. Over an existing item that is
9193    /// read off the item, as the write reads it, and the item is held among this command's
9194    /// resolved records so the write that follows reuses that read rather than repeating it;
9195    /// an item that does not carry the field takes the board's fields, which are held once
9196    /// read. A create is checked against the board's fields only when this command already
9197    /// holds them, because a create reads them together with its repository, in one request,
9198    /// and refuses a missing option before it writes anything.
9199    async fn check_status_write(
9200        &self,
9201        kind: ItemKind,
9202        category: StatusCategory,
9203        target: Option<&NativeId>,
9204    ) -> Result<(), SourceError> {
9205        let status = self.resolved_target(kind, category)?;
9206        if status.option().is_none() {
9207            return Ok(());
9208        }
9209        let fields = match target {
9210            Some(target) => {
9211                // A target this board does not hold is the write's own refusal to make.
9212                let Some(item) = self.bound_item(target).await? else {
9213                    return Ok(());
9214                };
9215                self.resolved_cache()?.insert(target.clone(), item.clone());
9216                self.fields_for(Some(&item), true, false).await?.fields
9217            }
9218            None => {
9219                let held = self
9220                    .board_cache()?
9221                    .as_ref()
9222                    .map(|board| board.fields.clone());
9223                match held.or_else(|| {
9224                    self.fields_cache()
9225                        .ok()
9226                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9227                }) {
9228                    Some(fields) => fields,
9229                    None => return Ok(()),
9230                }
9231            }
9232        };
9233        self.column_for(&fields, kind, category, &status)
9234            .map(|_| ())
9235    }
9236
9237    /// Set one task's status alone.
9238    ///
9239    /// An open target reopens a closed issue with an `updateIssue` carrying only its
9240    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9241    /// terminal target selects its mapped option, then closes with its fixed reason. No
9242    /// request carries a title, a body or a label. The status
9243    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9244    /// what a re-read reports.
9245    async fn set_task_status(
9246        &self,
9247        id: &NativeId,
9248        category: StatusCategory,
9249    ) -> Result<Option<Status>, SourceError> {
9250        self.set_status(id, category).await
9251    }
9252
9253    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9254    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9255    /// for `none`. Refused by an instance with no `priority_mapping`.
9256    async fn set_task_priority(
9257        &self,
9258        id: &NativeId,
9259        priority: Priority,
9260    ) -> Result<Option<Priority>, SourceError> {
9261        self.set_priority(id, priority).await
9262    }
9263
9264    /// Replace one task's content with a single body update that keeps the metadata slot
9265    /// byte for byte.
9266    async fn set_task_content(
9267        &self,
9268        id: &NativeId,
9269        content: &str,
9270    ) -> Result<Option<()>, SourceError> {
9271        self.replace_content(id, content).await
9272    }
9273
9274    /// Replace one task issue's content and its provenance slot entry with a single body
9275    /// update. The answers are not kept: see `replace_rendering`.
9276    async fn set_task_rendering(
9277        &self,
9278        id: &NativeId,
9279        content: &str,
9280        provenance: &Value,
9281        _answers: &BTreeMap<String, Value>,
9282    ) -> Result<Option<()>, SourceError> {
9283        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9284            .await
9285    }
9286
9287    /// Replace one design-document issue's content and its provenance slot entry, on exactly
9288    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9289    async fn set_document_rendering(
9290        &self,
9291        id: &NativeId,
9292        content: &str,
9293        provenance: &Value,
9294        _answers: &BTreeMap<String, Value>,
9295    ) -> Result<Option<()>, SourceError> {
9296        self.replace_rendering(id, BoardKind::Document, content, provenance)
9297            .await
9298    }
9299
9300    /// Replace one project issue's content and its provenance slot entry, on exactly the
9301    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9302    async fn set_project_rendering(
9303        &self,
9304        id: &NativeId,
9305        content: &str,
9306        provenance: &Value,
9307        _answers: &BTreeMap<String, Value>,
9308    ) -> Result<Option<()>, SourceError> {
9309        self.replace_rendering(id, BoardKind::Work(ItemKind::Project), content, provenance)
9310            .await
9311    }
9312
9313    /// Apply a targeted update with one read of the item and a write only for what differs:
9314    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9315    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9316    async fn update_task(
9317        &self,
9318        id: &NativeId,
9319        update: &TaskUpdate,
9320    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9321        self.targeted_update(id, update).await
9322    }
9323
9324    /// Replace one task's `delivered_by` with a single body update that changes the
9325    /// metadata slot and nothing outside it.
9326    async fn set_delivered_by(
9327        &self,
9328        id: &NativeId,
9329        delivered_by: &[TaskRef],
9330    ) -> Result<Option<()>, SourceError> {
9331        self.replace_delivered_by(id, delivered_by).await
9332    }
9333
9334    /// Set one key of one task issue's metadata with a single body update that changes the
9335    /// metadata slot and nothing outside it — no title, label, state or board field request —
9336    /// and sends nothing when the task already holds that value under the key.
9337    async fn set_task_metadata(
9338        &self,
9339        id: &NativeId,
9340        key: &MetadataKey,
9341        value: &Value,
9342    ) -> Result<Option<Task>, SourceError> {
9343        Ok(self
9344            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9345            .await?
9346            .map(|item| item.task())
9347            .transpose()?)
9348    }
9349
9350    /// Set one key of one project issue's metadata, on exactly the terms of
9351    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9352    async fn set_project_metadata(
9353        &self,
9354        id: &NativeId,
9355        key: &MetadataKey,
9356        value: &Value,
9357    ) -> Result<Option<Project>, SourceError> {
9358        Ok(self
9359            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9360            .await?
9361            .map(|item| item.project()))
9362    }
9363
9364    /// Set one key of one design-document issue's metadata, on exactly the terms of
9365    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9366    async fn set_document_metadata(
9367        &self,
9368        id: &NativeId,
9369        key: &MetadataKey,
9370        value: &Value,
9371    ) -> Result<Option<Document>, SourceError> {
9372        Ok(self
9373            .set_slot_key(id, BoardKind::Document, key, value)
9374            .await?
9375            .map(|item| item.document()))
9376    }
9377
9378    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9379        self.delete_item(id).await
9380    }
9381
9382    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9383        self.delete_item(id).await
9384    }
9385
9386    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9387        self.delete_item(id).await
9388    }
9389
9390    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9391    ///
9392    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9393    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9394    ///
9395    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9396    /// board is the read of its comments. A draft this process already resolved is refused
9397    /// without one.
9398    async fn task_comments(
9399        &self,
9400        task: &NativeId,
9401        page: &PageRequest,
9402    ) -> Result<Option<Page<Comment>>, SourceError> {
9403        validate_page(page)?;
9404        let cached = self.resolved_cache()?.get(task).cloned();
9405        if let Some(item) = cached {
9406            if item.kind != BoardKind::Work(ItemKind::Task) {
9407                return Ok(None);
9408            }
9409            if item.content_kind == ContentKind::DraftIssue {
9410                return Err(self.draft_has_no_comments(task));
9411            }
9412        }
9413        match self.issue_detail(task, page).await? {
9414            Some(TaskDetailRead {
9415                comments: Some(comments),
9416                ..
9417            }) => comments,
9418            _ => Ok(None),
9419        }
9420    }
9421
9422    /// Every id's task, with the first page of its comments when `comments` names it:
9423    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9424    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9425    async fn get_task_details(
9426        &self,
9427        ids: &[NativeId],
9428        comments: Option<&PageRequest>,
9429    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9430        if let Some(page) = comments
9431            && let Err(error) = validate_page(page)
9432        {
9433            return ids.iter().map(|_| Err(error.clone())).collect();
9434        }
9435        match (ids, comments) {
9436            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9437            ([id], None) => vec![self.task_read(id).await],
9438            _ => self.issue_details(ids, comments).await,
9439        }
9440    }
9441
9442    /// Add one comment to the task's issue, as the account the token belongs to.
9443    ///
9444    /// The author is refused before anything is sent — not even the task is read — because
9445    /// no answer GitHub could give would make posting under another name than the one asked
9446    /// for the right outcome.
9447    async fn add_comment(
9448        &self,
9449        task: &NativeId,
9450        comment: &NewComment,
9451    ) -> Result<Option<Comment>, SourceError> {
9452        if let Some(author) = &comment.author {
9453            return Err(SourceError::Refused {
9454                message: format!(
9455                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9456                     the token signs in as the author of every comment; next: leave --author \
9457                     out, and the comment is posted as that account",
9458                    self.name
9459                ),
9460            });
9461        }
9462        let Some(issue) = self.commented_issue(task).await? else {
9463            return Ok(None);
9464        };
9465        let data = self
9466            .graphql(
9467                graphql::ADD_COMMENT,
9468                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9469            )
9470            .await?;
9471        let subject = data
9472            .pointer("/addComment/subject")
9473            .filter(|value| !value.is_null())
9474            .ok_or_else(|| SourceError::Malformed {
9475                message: "GitHub comment addition returned no subject".into(),
9476            })?;
9477        if required_str(subject, "id")? != issue.0 {
9478            return Err(SourceError::Malformed {
9479                message: "GitHub comment addition answered about another issue".into(),
9480            });
9481        }
9482        let added = data
9483            .pointer("/addComment/commentEdge/node")
9484            .filter(|value| !value.is_null())
9485            .ok_or_else(|| SourceError::Malformed {
9486                message: "GitHub comment addition returned no comment".into(),
9487            })?;
9488        let added = comment_from(added)?;
9489        self.remember_commented(&issue)?;
9490        Ok(Some(added))
9491    }
9492
9493    async fn edit_comment(
9494        &self,
9495        task: &NativeId,
9496        comment: &NativeId,
9497        body: &CommentBody,
9498    ) -> Result<Option<Comment>, SourceError> {
9499        let Some(issue) = self.commented_issue(task).await? else {
9500            return Ok(None);
9501        };
9502        if !self.comment_is_on(&issue, comment).await? {
9503            return Ok(None);
9504        }
9505        let data = self
9506            .graphql(
9507                graphql::UPDATE_COMMENT,
9508                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9509            )
9510            .await?;
9511        let edited = data
9512            .pointer("/updateIssueComment/issueComment")
9513            .filter(|value| !value.is_null())
9514            .ok_or_else(|| SourceError::Malformed {
9515                message: "GitHub comment update returned no comment".into(),
9516            })?;
9517        let edited = comment_from(edited)?;
9518        if edited.id != *comment {
9519            return Err(SourceError::Malformed {
9520                message: "GitHub comment update returned the wrong comment".into(),
9521            });
9522        }
9523        self.remember_commented(&issue)?;
9524        Ok(Some(edited))
9525    }
9526
9527    async fn delete_comment(
9528        &self,
9529        task: &NativeId,
9530        comment: &NativeId,
9531    ) -> Result<Option<NativeId>, SourceError> {
9532        let Some(issue) = self.commented_issue(task).await? else {
9533            return Ok(None);
9534        };
9535        if !self.comment_is_on(&issue, comment).await? {
9536            return Ok(None);
9537        }
9538        let data = self
9539            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9540            .await?;
9541        // The payload says nothing about the comment it removed, so what is checked is that
9542        // GitHub answered the mutation at all rather than leaving it unanswered.
9543        data.get("deleteIssueComment")
9544            .filter(|value| !value.is_null())
9545            .ok_or_else(|| SourceError::Malformed {
9546                message: "GitHub comment deletion returned no payload".into(),
9547            })?;
9548        Ok(Some(comment.clone()))
9549    }
9550
9551    /// Every request this source has recorded, and what each of GitHub's two budgets was
9552    /// attributed — read off the same accounting the session report is rendered from, so
9553    /// the two cannot count one request two ways.
9554    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9555        Ok(Some(self.ledger.snapshot().metering()))
9556    }
9557
9558    /// Drop every item, search answer and board read this source holds, so the next command
9559    /// reads the board as a person has since left it.
9560    ///
9561    /// Every one of those is held on the assumption that nothing but this source writes the
9562    /// board while a command runs, which stops being true the moment the command is over: a
9563    /// body a person edited would be overwritten from the record held here, and a card they
9564    /// moved would be read as still where this source left it. The board's own field
9565    /// definitions go too, because a person can add or delete a `Status` option and a write
9566    /// resolved against the held list would not re-read on a miss. What stays is what stays
9567    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9568    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9569    /// accounting [`metering`](TaskSource::metering) answers from.
9570    ///
9571    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9572    /// refused, because clearing it is what puts it right.
9573    async fn end_command(&self) -> Result<(), SourceError> {
9574        fn clear<T: Default>(held: &Mutex<T>) {
9575            *held
9576                .lock()
9577                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9578            held.clear_poison();
9579        }
9580        clear(&self.created);
9581        clear(&self.updated);
9582        clear(&self.commented);
9583        clear(&self.board_cache);
9584        clear(&self.search_cache);
9585        clear(&self.narrowed_cache);
9586        clear(&self.search_next);
9587        clear(&self.resolved_cache);
9588        clear(&self.fields_cache);
9589        Ok(())
9590    }
9591}
9592
9593/// One issue comment as the contract carries it.
9594///
9595/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9596/// and when it answers an actor with no login, because either way the source did not say who
9597/// wrote it — which is what an absent author means, rather than an author called nothing.
9598fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9599    Ok(Comment {
9600        id: NativeId(required_str(value, "id")?.to_owned()),
9601        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9602            .map(str::to_owned),
9603        created_at: optional_time(value, "createdAt")?,
9604        updated_at: optional_time(value, "updatedAt")?,
9605        body: required_str(value, "body")?.to_owned(),
9606        url: optional_str(value, "url")?.map(str::to_owned),
9607    })
9608}
9609
9610/// The page of comments one issue node carries, resumed from `after`.
9611fn comment_page(
9612    node: &Value,
9613    issue: &str,
9614    after: Option<&str>,
9615) -> Result<Page<Comment>, SourceError> {
9616    let connection = node
9617        .get("comments")
9618        .filter(|value| !value.is_null())
9619        .ok_or_else(|| SourceError::Malformed {
9620            message: format!("GitHub issue {issue} answered with no comments connection"),
9621        })?;
9622    let items = optional_nodes(Some(connection), "issue comments")?
9623        .into_iter()
9624        .flatten()
9625        .map(comment_from)
9626        .collect::<Result<Vec<_>, _>>()?;
9627    let next = next_cursor(connection)?;
9628    if let Some(next) = &next {
9629        validate_cursor_progress(after, &next.0)?;
9630    }
9631    Ok(Page { items, next })
9632}
9633
9634/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9635/// end — `None` when it carried none, or a page with more past it.
9636fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9637    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9638        return Ok(None);
9639    };
9640    if next_cursor(connection)?.is_some() {
9641        return Ok(None);
9642    }
9643    Ok(Some(
9644        optional_nodes(Some(connection), "blocked-by issues")?
9645            .into_iter()
9646            .flatten()
9647            .cloned()
9648            .collect(),
9649    ))
9650}
9651
9652/// Where the recorded tail of a dependency walk resumes; see
9653/// [`GitHubProjectsSource::recorded_edges`].
9654const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9655
9656/// The board text field this source keeps a copy's origin in.
9657///
9658/// Named after the key it holds, and held to that name by the guard below rather than by
9659/// a reader noticing.
9660const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9661
9662/// The metadata key that field holds.
9663///
9664/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9665/// constructs or interprets the qualified id it carries. This source names it only to
9666/// route it — a short, typed value belongs in a typed field rather than in the body slot
9667/// a caller's own prose shares.
9668///
9669/// Restated rather than imported, because no plugin crate may depend on the engine. What
9670/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9671/// target in `check`: it reads the engine's own literal and fails naming the file and the
9672/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9673/// that creates a second item every run instead of finding the one it wrote — and that is
9674/// too late to learn it.
9675const ORIGIN_KEY: &str = "onetaskgraph.origin";
9676
9677/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9678///
9679/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9680/// is derived from the far end, never written down on the near item — so only a forward
9681/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9682/// it did not come from, and it is told so rather than answered with an empty page that
9683/// reads as a walk which ended.
9684fn recorded_offset(
9685    cursor: Option<&str>,
9686    direction: Direction,
9687) -> Result<Option<usize>, SourceError> {
9688    cursor
9689        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9690        .map(|offset| {
9691            if direction != Direction::DependsOn {
9692                return Err(SourceError::Config {
9693                    message: format!(
9694                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9695                         reverse dependency read never issues; resume it in the direction \
9696                         that reported it"
9697                    ),
9698                });
9699            }
9700            offset.parse().map_err(|_| SourceError::Config {
9701                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9702            })
9703        })
9704        .transpose()
9705}
9706
9707fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9708    let mut page = offset_page(edges, offset, limit.max(1));
9709    page.next = page
9710        .next
9711        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9712    page
9713}
9714
9715/// The kind of one issue reached through a dependency connection.
9716///
9717/// The same questions the board scan asks, over the fields the dependency document
9718/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9719/// then anything with sub-issues or the marker is a project.
9720///
9721/// # Errors
9722///
9723/// A far end this board holds as a document is refused rather than reported. The two
9724/// answers that are not refusals would both be wrong: reporting it as a task names an id
9725/// no task read of this source can find, and reporting it as a project names one no
9726/// project read can. There is no third value to return — `ItemKind` has no document
9727/// variant, because nothing may point at a document — so the relationship itself is what
9728/// the person is told about.
9729fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9730    let id = required_str(value, "id")?;
9731    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9732        return Err(SourceError::Refused {
9733            message: format!(
9734                "GitHub issue {id} is a document of this board — its title begins \
9735                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9736                 on by one; next: remove that issue's blocking relationship on this board"
9737            ),
9738        });
9739    }
9740    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9741    if parent.is_some() {
9742        return Ok(ItemKind::Task);
9743    }
9744    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9745    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9746        message: format!("GitHub issue {id}: {message}"),
9747    })?;
9748    let sub_issues = sub_issue_total(value)?;
9749    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9750        ItemKind::Project
9751    } else {
9752        ItemKind::Task
9753    })
9754}
9755
9756/// The `IssueStateUpdateInput` one status target asks for.
9757///
9758/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9759/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9760/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9761/// would report a change forever. A document has no status at all, and asks for neither.
9762fn state_input(target: Option<&StatusTarget>) -> Value {
9763    match target {
9764        Some(StatusTarget::Terminal(_, reason)) => {
9765            json!({"value":"CLOSED","stateReason":reason.reason()})
9766        }
9767        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9768        // A document has no status, so a write of one says nothing about the issue's open
9769        // or closed state rather than forcing it open: `stateInput` is what carries that
9770        // instruction, and an explicit null asks for no change to it.
9771        None => Value::Null,
9772    }
9773}
9774
9775/// The metadata one write stores in the item's body slot.
9776///
9777/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9778/// rather than carried: the kind marker so an empty project stays readable, the
9779/// repository list only when it is not exactly the issue's own repository, and the far
9780/// ends no relationship here can name.
9781///
9782/// The copy origin is the one typed field that is also mirrored here, and only as a
9783/// mirror: it lands in the board's origin field as well, which stays the one every reader
9784/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9785/// and catches up with a write in seconds rather than minutes — can find the item by it.
9786/// A reader of the release before this one drops the slot's copy and reads the field, so an
9787/// item written here still reads with exactly one origin there.
9788fn slot_metadata(
9789    incoming: &Incoming<'_>,
9790    own_repository: Option<&Repository>,
9791    fallback: &[DependencyEdge],
9792) -> BTreeMap<String, Value> {
9793    let mut metadata = incoming.metadata.clone();
9794    match metadata.remove(ORIGIN_KEY) {
9795        Some(Value::String(origin)) if !origin.is_empty() => {
9796            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9797        }
9798        _ => {}
9799    }
9800    match incoming.written.kind() {
9801        BoardKind::Work(kind) => metadata.insert(
9802            ItemKind::METADATA_KEY.to_owned(),
9803            Value::String(kind.marker().to_owned()),
9804        ),
9805        // A document is told by its title, so it carries no kind marker: that key names
9806        // what a dependency endpoint points at, and nothing may point at a document.
9807        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9808    };
9809    let derivable = own_repository
9810        .map(|own| incoming.repositories == [own.clone()])
9811        .unwrap_or(incoming.repositories.is_empty());
9812    if derivable {
9813        metadata.remove(Repository::METADATA_KEY);
9814    } else {
9815        metadata.insert(
9816            Repository::METADATA_KEY.to_owned(),
9817            Value::Array(
9818                incoming
9819                    .repositories
9820                    .iter()
9821                    .map(|repository| Value::String(repository.as_str().to_owned()))
9822                    .collect(),
9823            ),
9824        );
9825    }
9826    // The typed lists are what land, whatever the caller's own metadata held under their
9827    // keys: a key of either name travelling beside the field would otherwise be a second
9828    // answer to the same question, and the field is the one the contract names.
9829    for (key, entries) in [
9830        (TaskRef::DELIVERS_KEY, incoming.delivers),
9831        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9832    ] {
9833        set_task_list(&mut metadata, key, entries);
9834    }
9835    record_edges(&mut metadata, fallback);
9836    metadata
9837}
9838
9839/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9840/// one slot's metadata, or no such key when there are none.
9841fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9842    if fallback.is_empty() {
9843        metadata.remove(DependencyEdge::RECORDED_KEY);
9844    } else {
9845        metadata.insert(
9846            DependencyEdge::RECORDED_KEY.to_owned(),
9847            Value::Array(
9848                fallback
9849                    .iter()
9850                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9851                    .collect(),
9852            ),
9853        );
9854    }
9855}
9856
9857/// Every label one item carries, from its content's own connection and nowhere else.
9858///
9859/// There is no second place to read one from: no document this source sends selects the
9860/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9861/// cannot carry one at all. The module documentation records the three schema facts that
9862/// settle it.
9863fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9864    optional_nodes(content.get("labels"), "content labels")?
9865        .into_iter()
9866        .flatten()
9867        .map(|v| {
9868            Ok(Label {
9869                id: NativeId(required_str(v, "id")?.to_owned()),
9870                name: required_str(v, "name")?.to_owned(),
9871                color: optional_str(v, "color")?.map(str::to_owned),
9872            })
9873        })
9874        .collect()
9875}
9876
9877/// The definition of each board field one item's values are values of, in the shape a read
9878/// of the board's own `fields` gives one.
9879///
9880/// A value names its field through a fragment on that field's own type, so the type is
9881/// known from which kind of value it is: a single-select value's field is a
9882/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9883/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9884fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9885    field_values
9886        .iter()
9887        .filter_map(|value| {
9888            let field = value.get("field")?.as_object()?;
9889            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9890            let typename = if value.get("text").is_some() {
9891                "ProjectV2Field"
9892            } else if value.get("name").is_some() {
9893                "ProjectV2SingleSelectField"
9894            } else {
9895                return None;
9896            };
9897            let mut defined = field.clone();
9898            defined.insert("__typename".to_owned(), json!(typename));
9899            Some(Value::Object(defined))
9900        })
9901        .collect()
9902}
9903
9904fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9905    let Some(node) = field_values
9906        .iter()
9907        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9908    else {
9909        return Ok(None);
9910    };
9911    Ok(optional_str(node, "text")?.map(str::to_owned))
9912}
9913
9914fn valid_github_owner(owner: &str) -> bool {
9915    !owner.is_empty()
9916        && owner.len() <= 39
9917        && !owner.starts_with('-')
9918        && !owner.ends_with('-')
9919        && !owner.contains("--")
9920        && owner
9921            .bytes()
9922            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9923}
9924
9925/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9926/// neither of the two names a path segment already means.
9927fn valid_github_repository_name(name: &str) -> bool {
9928    !name.is_empty()
9929        && name.len() <= 100
9930        && name != "."
9931        && name != ".."
9932        && name
9933            .bytes()
9934            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9935}
9936
9937fn valid_environment_name(name: &str) -> bool {
9938    let mut bytes = name.bytes();
9939    bytes
9940        .next()
9941        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9942        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9943}
9944
9945/// How many sub-issues one issue has.
9946///
9947/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9948/// absent or non-integer one is a response this source cannot read — and reading it as
9949/// zero would classify a project as a task, which is exactly the mistake the marker
9950/// exists to keep from happening quietly.
9951fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9952    let summary = issue
9953        .get("subIssuesSummary")
9954        .ok_or_else(|| SourceError::Malformed {
9955            message: "GitHub issue is missing subIssuesSummary".into(),
9956        })?;
9957    summary
9958        .get("total")
9959        .and_then(Value::as_u64)
9960        .ok_or_else(|| SourceError::Malformed {
9961            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9962        })
9963}
9964
9965/// One issue's own `number`.
9966///
9967/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9968/// an issue in this module asks for it. So a read of one that comes back without it, or
9969/// with something that is not an unsigned integer, is a response this source cannot read —
9970/// absence here is **not** "this issue has no number". A draft is the content that has
9971/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9972/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9973fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9974    issue
9975        .get("number")
9976        .and_then(Value::as_u64)
9977        .ok_or_else(|| SourceError::Malformed {
9978            message: "GitHub issue number is missing or is not an unsigned integer".into(),
9979        })
9980}
9981
9982/// The `number` a creating mutation answered with, and `None` when it answered without one;
9983/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9984fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9985    match created.get("number") {
9986        None | Some(Value::Null) => Ok(None),
9987        Some(value) => value
9988            .as_u64()
9989            .map(Some)
9990            .ok_or_else(|| SourceError::Malformed {
9991                message: "GitHub created issue number is not an unsigned integer".into(),
9992            }),
9993    }
9994}
9995
9996fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9997    value
9998        .get(field)
9999        .and_then(Value::as_str)
10000        .ok_or_else(|| SourceError::Malformed {
10001            message: format!("GitHub response is missing string field {field}"),
10002        })
10003}
10004
10005fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10006    let found = required_str(value, field)?;
10007    if found.trim().is_empty() {
10008        return Err(SourceError::Malformed {
10009            message: format!("GitHub response has blank string field {field}"),
10010        });
10011    }
10012    Ok(found)
10013}
10014
10015/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
10016/// needs one — Linear spells them too, in its own description field.
10017///
10018/// Restated rather than shared, because a plugin crate depends on the contract crate and
10019/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
10020/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
10021/// source round-trips its own writes perfectly well under its own spelling.
10022const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
10023const METADATA_CLOSE: &str = "\n-->";
10024
10025/// What the composer puts between a non-empty visible body and the slot, and the one thing
10026/// the parser takes off the visible body when it takes the slot off — exactly once, so every
10027/// other trailing byte of the body comes back as it was written.
10028// 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.
10029const METADATA_SEPARATOR: &str = "\n\n";
10030
10031/// The visible body and the metadata slot at the end of it.
10032///
10033/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
10034/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
10035/// own content and is left alone. The visible body is everything before the slot less the
10036/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
10037fn metadata_body(
10038    body: Option<String>,
10039) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
10040    let Some(body) = body else {
10041        return Ok((None, BTreeMap::new()));
10042    };
10043    let Some(slot) = slot_span(&body)? else {
10044        return Ok((Some(body), BTreeMap::new()));
10045    };
10046    let metadata =
10047        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
10048            SourceError::Malformed {
10049                message: format!(
10050                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
10051                ),
10052            }
10053        })?;
10054    let before = &body[..slot.start];
10055    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
10056    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
10057}
10058
10059/// Where the metadata slot sits in one body, as byte offsets into it.
10060struct SlotSpan {
10061    /// Where [`METADATA_OPEN`] begins.
10062    start: usize,
10063    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
10064    encoded_start: usize,
10065    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
10066    encoded_end: usize,
10067    /// Just past [`METADATA_CLOSE`].
10068    end: usize,
10069}
10070
10071/// The slot at the very end of `body`, or `None` when it has none.
10072///
10073/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
10074/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
10075/// slot.
10076fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
10077    let Some(start) = body.rfind(METADATA_OPEN) else {
10078        return Ok(None);
10079    };
10080    let encoded_start = start + METADATA_OPEN.len();
10081    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
10082        return Err(SourceError::Malformed {
10083            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
10084        });
10085    };
10086    let encoded_end = encoded_start + relative_end;
10087    let end = encoded_end + METADATA_CLOSE.len();
10088    if !body[end..].trim().is_empty() {
10089        return Ok(None);
10090    }
10091    Ok(Some(SlotSpan {
10092        start,
10093        encoded_start,
10094        encoded_end,
10095        end,
10096    }))
10097}
10098
10099/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10100/// slot as it was.
10101///
10102/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10103/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10104/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10105/// or alone in an empty body — and a body with no slot that is given no metadata is
10106/// returned as it is.
10107fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10108    let encoded = if metadata.is_empty() {
10109        None
10110    } else {
10111        Some(
10112            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10113                message: error.to_string(),
10114            })?,
10115        )
10116    };
10117    Ok(match (slot_span(body)?, encoded) {
10118        (Some(slot), Some(encoded)) => format!(
10119            "{}{encoded}{}",
10120            &body[..slot.encoded_start],
10121            &body[slot.encoded_end..]
10122        ),
10123        (Some(slot), None) => {
10124            let before = &body[..slot.start];
10125            format!(
10126                "{}{}",
10127                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10128                &body[slot.end..]
10129            )
10130        }
10131        (None, None) => body.to_owned(),
10132        (None, Some(encoded)) if body.is_empty() => {
10133            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10134        }
10135        (None, Some(encoded)) => {
10136            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10137        }
10138    })
10139}
10140
10141/// `body` with everything before its metadata slot replaced by `content`, and the slot
10142/// itself kept byte for byte.
10143///
10144/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10145/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10146/// `content` is empty — so a read of the result reports `content` as the visible body and
10147/// the slot's metadata exactly as it was.
10148fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10149    let Some(slot) = slot_span(body)? else {
10150        return Ok(content.to_owned());
10151    };
10152    let kept = &body[slot.start..];
10153    Ok(if content.is_empty() {
10154        kept.to_owned()
10155    } else {
10156        format!("{content}{METADATA_SEPARATOR}{kept}")
10157    })
10158}
10159
10160/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10161fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10162    if entries.is_empty() {
10163        metadata.remove(key);
10164    } else {
10165        metadata.insert(
10166            key.to_owned(),
10167            Value::Array(
10168                entries
10169                    .iter()
10170                    .map(|entry| Value::String(entry.as_str().to_owned()))
10171                    .collect(),
10172            ),
10173        );
10174    }
10175}
10176
10177fn compose_body(
10178    content: Option<&str>,
10179    metadata: &BTreeMap<String, Value>,
10180) -> Result<Option<String>, SourceError> {
10181    let visible = content.unwrap_or_default();
10182    if metadata.is_empty() {
10183        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10184    }
10185    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10186        message: error.to_string(),
10187    })?;
10188    Ok(Some(if visible.is_empty() {
10189        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10190    } else {
10191        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10192    }))
10193}
10194
10195fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10196    value
10197        .get(field)
10198        .and_then(Value::as_bool)
10199        .ok_or_else(|| SourceError::Malformed {
10200            message: format!("GitHub response is missing boolean field {field}"),
10201        })
10202}
10203fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10204    match value.get(field) {
10205        None | Some(Value::Null) => Ok(None),
10206        Some(value) => value
10207            .as_str()
10208            .map(Some)
10209            .ok_or_else(|| SourceError::Malformed {
10210                message: format!("GitHub response field {field} is not a string or null"),
10211            }),
10212    }
10213}
10214fn optional_nodes<'a>(
10215    connection: Option<&'a Value>,
10216    name: &str,
10217) -> Result<Option<&'a Vec<Value>>, SourceError> {
10218    match connection {
10219        None | Some(Value::Null) => Ok(None),
10220        Some(value) => value
10221            .get("nodes")
10222            .and_then(Value::as_array)
10223            .map(Some)
10224            .ok_or_else(|| SourceError::Malformed {
10225                message: format!("GitHub {name}.nodes is not an array"),
10226            }),
10227    }
10228}
10229fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10230    let page_info = connection
10231        .get("pageInfo")
10232        .ok_or_else(|| SourceError::Malformed {
10233            message: format!("GitHub {name} has no pageInfo"),
10234        })?;
10235    if required_bool(page_info, "hasNextPage")? {
10236        return Err(SourceError::Malformed {
10237            message: format!(
10238                "GitHub {name} exceeds the supported nested connection size of {size}"
10239            ),
10240        });
10241    }
10242    Ok(())
10243}
10244fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10245    optional_str(value, field)?
10246        .map(|timestamp| {
10247            timestamp.parse().map_err(|error| SourceError::Malformed {
10248                message: format!("GitHub response field {field} is not a timestamp: {error}"),
10249            })
10250        })
10251        .transpose()
10252}
10253fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10254    if page.limit == 0 {
10255        Err(SourceError::Config {
10256            message: "page limit must be at least 1".into(),
10257        })
10258    } else {
10259        Ok(())
10260    }
10261}
10262fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10263    let page = connection
10264        .get("pageInfo")
10265        .filter(|value| value.is_object())
10266        .ok_or_else(|| SourceError::Malformed {
10267            message: "GitHub connection is missing pageInfo".into(),
10268        })?;
10269    if required_bool(page, "hasNextPage")? {
10270        let cursor = required_str(page, "endCursor")?;
10271        validate_cursor_progress(None, cursor)?;
10272        Ok(Some(Cursor(cursor.into())))
10273    } else {
10274        Ok(None)
10275    }
10276}
10277fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10278    if next.is_empty() || previous == Some(next) {
10279        Err(SourceError::Malformed {
10280            message: "GitHub pagination cursor is empty or did not advance".into(),
10281        })
10282    } else {
10283        Ok(())
10284    }
10285}
10286/// The version of this plugin's opaque narrowing-search cursor.
10287pub const SEARCH_CURSOR_VERSION: u32 = 4;
10288
10289#[derive(Serialize, Deserialize)]
10290#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10291enum SearchConnection {
10292    Initial {},
10293    Continuing { after: Cursor },
10294    Exhausted {},
10295}
10296impl SearchConnection {
10297    fn after(&self) -> Option<&str> {
10298        match self {
10299            Self::Continuing { after } => Some(&after.0),
10300            _ => None,
10301        }
10302    }
10303    fn exhausted(&self) -> bool {
10304        matches!(self, Self::Exhausted { .. })
10305    }
10306    /// Whether a cursor naming this position, `offset` rows into its page, is one this
10307    /// plugin could have handed out: a page is resumed only part of the way through it — an
10308    /// offset of a whole page or more would skip rows nobody was given — an initial page
10309    /// only once some of it was handed out, and an exhausted connection has no page to be
10310    /// part of the way through.
10311    fn valid_resume(&self, offset: usize) -> bool {
10312        let within = offset < SEARCH_PAGE_SIZE as usize;
10313        match self {
10314            Self::Initial { .. } => offset > 0 && within,
10315            Self::Continuing { after } => !after.0.is_empty() && within,
10316            Self::Exhausted { .. } => offset == 0,
10317        }
10318    }
10319}
10320
10321/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10322#[derive(Serialize, Deserialize)]
10323#[serde(deny_unknown_fields)]
10324struct SearchPosition {
10325    version: u32,
10326    connection: SearchConnection,
10327    /// How many rows of the page `connection` starts were already handed out.
10328    #[serde(default, skip_serializing_if = "is_zero")]
10329    offset: usize,
10330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10331    seen: Vec<NativeId>,
10332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10333    own: Vec<NativeId>,
10334}
10335impl Default for SearchPosition {
10336    fn default() -> Self {
10337        Self {
10338            version: SEARCH_CURSOR_VERSION,
10339            connection: SearchConnection::Initial {},
10340            offset: 0,
10341            seen: Vec::new(),
10342            own: Vec::new(),
10343        }
10344    }
10345}
10346
10347fn is_zero(offset: &usize) -> bool {
10348    *offset == 0
10349}
10350
10351fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10352    cursor.map_or(Ok(0), |c| {
10353        c.0.parse().map_err(|_| SourceError::Config {
10354            message: "page cursor is invalid".into(),
10355        })
10356    })
10357}
10358fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10359    if offset > items.len() {
10360        return Page::last(vec![]);
10361    }
10362    let tail = items.split_off(offset);
10363    let mut selected = tail;
10364    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10365    selected.truncate(limit);
10366    Page {
10367        items: selected,
10368        next,
10369    }
10370}
10371
10372/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10373/// itself, for the two things no journey can observe.
10374///
10375/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
10376/// with and without the call, that a settlement, a board listing and a metadata search each
10377/// read afresh after it — the resolved records, the written-item overlay, the board and its
10378/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10379/// because a status write naming an option a person deleted is refused the same whether or
10380/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10381/// while one of its locks is held. So these assert those directly, and every other holder
10382/// beside them so a holder added later without a clear in the call fails here.
10383#[cfg(test)]
10384mod end_command_tests {
10385    use super::*;
10386
10387    struct Token;
10388
10389    impl SecretResolver for Token {
10390        fn get(&self, var: &str) -> Option<SecretString> {
10391            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10392        }
10393    }
10394
10395    fn source() -> GitHubProjectsSource {
10396        let config = serde_json::from_value(json!({
10397            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10398            // Nothing here is sent: the source is only built and its state inspected.
10399            "endpoint": "http://127.0.0.1:9/graphql",
10400        }))
10401        .expect("a usable configuration");
10402        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10403            .expect("the source builds")
10404    }
10405
10406    /// One issue as a board read answers it.
10407    fn resolved(source: &GitHubProjectsSource) -> Resolved {
10408        source
10409            .resolve(&json!({
10410                "id": "ITEM-1",
10411                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10412                            "body": "what a person may since have edited", "state": "OPEN",
10413                            "stateReason": null, "url": null, "number": 1,
10414                            "subIssuesSummary": {"total": 0},
10415                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10416                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10417            }))
10418            .expect("the item reads")
10419            .expect("an issue")
10420    }
10421
10422    /// Hold something in every holder the call clears, and the repository id it keeps.
10423    fn fill(source: &GitHubProjectsSource) {
10424        let item = resolved(source);
10425        source.created.lock().unwrap().push(item.clone());
10426        source.updated.lock().unwrap().push(item.clone());
10427        *source.board_cache.lock().unwrap() = Some(Board {
10428            id: "PVT-board".into(),
10429            fields: json!({"nodes": []}),
10430            items: vec![item.clone()],
10431        });
10432        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10433        source
10434            .narrowed_cache
10435            .lock()
10436            .unwrap()
10437            .insert("status:todo".into(), vec![item.clone()]);
10438        source
10439            .search_next
10440            .lock()
10441            .unwrap()
10442            .insert("status:todo".into(), Some("cursor".into()));
10443        source
10444            .resolved_cache
10445            .lock()
10446            .unwrap()
10447            .insert(item.id.clone(), item);
10448        *source.fields_cache.lock().unwrap() = Some(BoardFields {
10449            id: BoardId::parse("PVT-board").unwrap(),
10450            fields: json!({"nodes": []}),
10451        });
10452        source
10453            .repository_cache
10454            .lock()
10455            .unwrap()
10456            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10457    }
10458
10459    fn assert_dropped(source: &GitHubProjectsSource) {
10460        assert!(source.created().unwrap().is_empty(), "created");
10461        assert!(source.updated().unwrap().is_empty(), "updated");
10462        assert!(source.board_cache().unwrap().is_none(), "board");
10463        assert!(source.search_cache.lock().unwrap().is_none(), "search");
10464        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10465        assert!(
10466            source.search_next.lock().unwrap().is_empty(),
10467            "search paging"
10468        );
10469        assert!(
10470            source.resolved_cache().unwrap().is_empty(),
10471            "resolved records"
10472        );
10473        assert!(source.fields_cache().unwrap().is_none(), "board fields");
10474        assert_eq!(
10475            source.repository_cache().unwrap().len(),
10476            1,
10477            "a repository's node id stays valid and is kept"
10478        );
10479    }
10480
10481    fn end(source: &GitHubProjectsSource) {
10482        tokio::runtime::Builder::new_current_thread()
10483            .build()
10484            .unwrap()
10485            .block_on(source.end_command())
10486            .expect("the command ends");
10487    }
10488
10489    #[test]
10490    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10491        let source = source();
10492        fill(&source);
10493        end(&source);
10494        assert_dropped(&source);
10495    }
10496
10497    #[test]
10498    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10499        fn poison<T: Send>(held: &Mutex<T>) {
10500            std::thread::scope(|scope| {
10501                let _ = scope
10502                    .spawn(|| {
10503                        let _guard = held.lock().unwrap();
10504                        panic!("a failure while the lock is held");
10505                    })
10506                    .join();
10507            });
10508            assert!(held.is_poisoned());
10509        }
10510        let source = source();
10511        fill(&source);
10512        poison(&source.created);
10513        poison(&source.updated);
10514        poison(&source.board_cache);
10515        poison(&source.search_cache);
10516        poison(&source.narrowed_cache);
10517        poison(&source.search_next);
10518        poison(&source.resolved_cache);
10519        poison(&source.fields_cache);
10520        assert!(
10521            source.resolved_cache().is_err(),
10522            "a poisoned lock is refused before the call"
10523        );
10524        end(&source);
10525        assert_dropped(&source);
10526    }
10527}