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/tests/e2e/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//! | `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. |
138//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
139//! | `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 neither `ProjectV2.items` nor any issue the search did not name is 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 by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
140//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
141//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
142//! | `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. |
143//! | `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. |
144//! | `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. |
145//! | `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. |
146//! | `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. |
147//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
148//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
149//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
150//!
151//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
152//! source has documents and that its tasks have comments, both of which hold — and the three
153//! facts behind the uniform `Native` on the
154//! predicates beside it are recorded below rather than re-derived, because a reader who
155//! takes `Native` to mean *the remote service filters* will read that uniformity as a
156//! lie.
157//!
158//! First, the plugin contract defines `Support::Native` as *the source applies this
159//! predicate itself*, and says nothing about where it applies it. What the declaration
160//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
161//! — so that the engine may push it down and apply nothing of its own.
162//!
163//! Second, this source can keep that promise for every predicate at no additional API
164//! cost, because whichever of the reads below answers a query has already read every
165//! candidate that query will return before it filters anything. Filtering those items is
166//! in-process work over data already in hand.
167//!
168//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
169//! applied in process over what that question returned. A project filter has a relationship — a
170//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
171//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
172//! a search for metadata values, is the board-scoped issue search carrying the text and each
173//! value as quoted phrases; an origin is the board's own field filter over its origin field
174//! beside the same search for the id. **The text search narrows, and that is this source's
175//! declared semantics:** GitHub matches whole words where the substring rule this source and
176//! the local Markdown source confirm with would match inside one, so an item holding the text
177//! only inside a longer word is never a candidate. Every item returned does contain the text.
178//! A project query's text, and a document query's scoped to no project, is that same search
179//! and narrows on the same terms, its candidates confirmed by their kind as well.
180//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
181//! so those three are applied in process over the candidates, and a query carrying none of
182//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
183//! engine compensate for work this source has already done, and declaring `projects` native
184//! while ignoring the filter (which this source once did) silently returns another project's
185//! tasks, because the engine trusts the declaration and applies nothing locally.
186//!
187//! # The three ways this source reaches an item, and what each costs
188//!
189//! A board read is charged for what its *nested* connections could return rather than for
190//! what was asked, so one whole-board read costs the same whether the question was about
191//! one project or about all of them. That is why a question about one project is never
192//! answered by reading the board:
193//!
194//! | The question | What is sent | What it costs |
195//! | --- | --- | --- |
196//! | 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 |
197//! | 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 |
198//! | 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 |
199//! | 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 |
200//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
201//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
202//! | 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 |
203//! | 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 |
204//! | 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 |
205//! | 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 |
206//! | 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 |
207//! | 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 |
208//!
209//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
210//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
211//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
212//! equal to declared points equal to the row. They include the origin lookup and the
213//! field/repository discovery a create needs. A bound re-copy changes status, priority,
214//! content and metadata; comment recount means a subsequent detail read. Each request here
215//! costs one declared point. A membership beyond the embedded page can additionally require
216//! the one-point membership recovery described above. A bound re-copy of a task filed under a
217//! project adds one read, the engine confirming that project's link by its own id once per
218//! command; and the same-source far ends a write newly names — those that do not already block
219//! the item, whose own read answered for them — are read together by their own ids,
220//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
221//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
222//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
223//!
224//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
225//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
226//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
227//! holds both halves.
228//!
229//! **An existing item is written body last.** A bound re-copy and a `task update` send its
230//! board fields first — the `Status` option and the `Priority` together, in one request — then
231//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
232//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
233//! undoing an earlier field when a later one fails, so that order is what makes a write
234//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
235//! the one piece of metadata written before the body, an origin a copy re-points, is put back
236//! when a later write is refused — and when putting it back is refused too, the write's own
237//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
238//! of those writes in turn, whole and as one aliased field failing after the one before it.
239//!
240//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
241//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
242//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
243//!
244//! - **A board is accepted at creation but its item is not answered, so a create still files
245//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
246//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
247//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
248//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
249//!   against a real board on 2026-10-01 with a create sending the board there and reading the
250//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
251//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
252//!   refused "Content already exists in this project" — GitHub had filed the issue after
253//!   answering, and refuses a second filing rather than answering with the item it holds. A
254//!   create therefore sends no `projectV2Ids` and files the issue with
255//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
256//!   real is the read before it: the board's fields and the repository's id together, in
257//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
258//! - **A comment still reads its target first, so a comment is 2 requests.**
259//!   `AddCommentInput.subjectId: ID!` is declared there with
260//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
261//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
262//!   project's issue, a document's issue, an issue on no board of this source and a pull
263//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
264//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
265//!   refuses those by name.
266//!
267//! | Verb | Requests / points | Documents |
268//! | --- | --- | --- |
269//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
270//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
271//! | 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 |
272//! | 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 |
273//! | 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 |
274//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
275//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
276//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
277//! | recount | 1 | ISSUE_DETAIL |
278//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
279//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
280//! | content | 2 | ISSUE, UPDATE_ISSUE |
281//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
282//! | 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 |
283//! | record only | 1 | ISSUE |
284//!
285//! <!-- github-search-paging:start -->
286//! Board-scoped text, metadata, project-name and comment-activity searches send every
287//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
288//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
289//! needs rows. A page is never resized to the rows still needed: GitHub orders one
290//! search differently at different page sizes, so one fixed size makes a paged walk
291//! send exactly the requests one whole read sends, and the answer's order is the order
292//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
293//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
294//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
295//! returned and fetched pages: a limit is sliced from the pages it needs, and local
296//! confirmation can require more candidates than matching rows. Walking all pages
297//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
298//! cursor and how far into that page the last answer stopped, and resumes in the same
299//! process or a new one, without duplicates or gaps. It carries no rows: one process
300//! sends each page's search once, and a new process re-reads only the page it resumes
301//! in, then sends a further page once, never as a re-read, only when its limit still
302//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
303//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
304//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
305//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
306//! not required to include the original process's writes still omitted by the index.
307//! <!-- github-search-paging:end -->
308//!
309//! The board half of an issue — its board item's id, its `Status` option and this
310//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
311//! an item reached any of those ways resolves through the same
312//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
313//! same status, the same labels and the same qualified id. That connection comes back a
314//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
315//! on the page in hand and — only if that page reports more of the connection — in the
316//! last row's read of that one issue's memberships, resumed from the page's own cursor and
317//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
318//! report, which is what keeps an id naming another repository's issue from being answered
319//! as an item of this board; and because the page is where the search starts rather than
320//! where it ends, that answer is one about a connection read to exhaustion and never about
321//! an unread page. Nothing costs the extra read but an issue on more boards than a page
322//! holds: an issue this board really does not hold reports no next page, so its
323//! memberships are already exhausted where they arrived.
324//!
325//! **No document here selects the board's own `Labels` field, and nothing is lost by
326//! that.** An item's labels are read from its content alone, wherever that content is
327//! reached: the three documents above select `Issue.labels` on the fragment, and
328//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
329//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
330//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
331//! create one, and `ProjectV2FieldValue` — the whole of what
332//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
333//! it from the content, for every content type it exists on, and there is nothing it can
334//! hold that the content does not already say: for an `Issue` it *is* that issue's own
335//! labels, so selecting it beside them unions a set with itself.
336//!
337//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
338//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
339//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
340//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
341//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
342//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
343//! label set, and that set is the fixture issue's own, by
344//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
345//! item whose content is a draft reports an empty set, by
346//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
347//! selection is held over [`graphql::DOCUMENTS`] by
348//! `no_document_selects_the_boards_own_labels_field`.
349//!
350//! The whole-board row is still the board's own item connection, and deliberately: a
351//! **draft** board item is not an issue, so no search can list one, and the reads that have
352//! to answer for the whole board are the ones whose cost is the board's size anyway.
353//!
354//! **A question about one item this source already names by id never lists the board.**
355//! Whether that item is on this board, and what its board fields are, is answered by reading
356//! that item — its own `Issue.projectItems`, walked to exhaustion by
357//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
358//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
359//! holds. That covers a write's destination, the project a new item is filed under, a
360//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
361//! and the delete that takes back an item a copy made. What such a write needs of the board
362//! and the item does not carry — the board's id, the `Status` and origin field definitions —
363//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
364//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
365//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
366//! minutes. Scanning this host's 842-item board has refused a document copy and an update
367//! even though the items' own reads named that board. A scan there gives the wrong answer
368//! as well as paying for every page. So a `board.items` lookup does not belong on any of
369//! those paths.
370//!
371//! **What a read may return is capped too, and that cap is on the document rather than on
372//! the board.** GitHub limits the number of nodes **one query may return** to
373//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
374//! an error naming the connection the count crossed at, not a slow or a partial result.
375//! Every board this source reads is refused the same way, so no board is too big for these
376//! documents and none is small enough to save one that is over.
377//!
378//! The count is arithmetic over the document's own text: each connection contributes the
379//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
380//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
381//! restate them — `github-graphql-node-count` implements them, and
382//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
383//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
384//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
385//! same text on every run and fails naming any that reaches the limit — so a connection
386//! added to a shared fragment is caught there rather than by GitHub.
387//!
388//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
389//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
390//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
391//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
392//! effectively squared there, which is why it is the one the limit is most sensitive to.
393//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
394//! page of memberships misses is recovered by one further read rather than refused, so it
395//! buys a bound every read pays for at the price of a request only a multi-board issue
396//! pays.
397//!
398//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
399//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
400//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
401//! `cost` is rate-limit points, metered per hour across everything one credential does; it
402//! is what the two limiters [`Limiter`] tells apart meter, and a document under
403//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
404//! that second number, and `tests/point_cost.rs` pins every document in
405//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
406//! one under, the pin itself is the check. The credentialed lane reconciles both figures
407//! against GitHub's own, off a probe it already sends.
408//!
409//! **What is pinned that way is a per-document price and never a session's.** The record in
410//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
411//! **requests** and **worst-case nodes** — and neither is points. What one whole session
412//! consumes of the hourly point allowance is observable only from a credentialed run's own
413//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
414//! and what `tests/live.rs` prints at the end of every run.
415//!
416//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
417//!
418//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
419//! enumerations of a board can supply one alone.** Resolving a node id is strongly
420//! consistent, so a read by id and a project's own sub-issues are already current. The
421//! other two are not, and they are behind by different amounts and in different directions:
422//!
423//! - GitHub's **issue search** is an index and answers a write made moments ago with the
424//!   value from before it — usually for a second or two.
425//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
426//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
427//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
428//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
429//!
430//! That second one is a measurement rather than a caution. This repository's own
431//! credentialed journey writes a project and waits for the board to report it, then writes a
432//! task and waits for the same thing seconds later on the same board: the project wait is
433//! answered through the search and converged in two or three attempts in each of three runs,
434//! and the task wait is answered through `ProjectV2.items` and converged in none of them
435//! inside thirty. Separately, an item added to a second and larger board was read back by
436//! `Issue.projectItems` on that board's own id while every one of that connection's nine
437//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
438//! through the lagging one alone is what had a board read deny an issue that had certainly
439//! landed on it.
440//!
441//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
442//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
443//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
444//! search reports what the projection is behind on. What closes the last
445//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
446//! source answers is completed with what this process itself wrote, so an item created
447//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
448//! nothing is written down, and the record dies with the process. **A wait that has to
449//! observe GitHub's own data cannot be answered from that record** — which is why the
450//! credentialed journey asks through a source built afresh, and why the union above rather
451//! than a longer wait is what makes such a wait converge.
452//!
453//! **A narrowed read is the same bargain, stated for each of the three predicates it
454//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
455//! than walking the board, and every such answer is completed with what this process wrote —
456//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
457//! each filtered by the same predicates as the rest — so an item this command wrote a moment
458//! ago is returned by a query that matches it whether or not the index has caught up. An item
459//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
460//! consistent. What is left is stated rather than papered over:
461//!
462//! | Read | Finds | Behind by |
463//! | --- | --- | --- |
464//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
465//! | 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 |
466//! | 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 |
467//! | origin, third read | this process's own writes | nothing |
468//!
469//! So an origin carrier another process added within the last second or two, before either
470//! index has it, can be missing from an origin query, and one written by the release before
471//! this one — its origin in the field alone — can be missing for as long as the board's own
472//! item connection is behind on it. A copy that must not duplicate its own earlier write
473//! relies on the link it records, not on either index. **A board draft is not an issue**, so
474//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
475//! lists one, the origin lookup drops any the board's own field filter names, and one this
476//! process wrote is not added back either.
477//!
478//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
479//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
480//! body's metadata slot, so the issue search can find it in seconds. The field is
481//! authoritative: this source reads an item's origin from the field alone, so a slot that
482//! disagrees with it, or holds one where the field holds none, is never read as a second
483//! origin — and the release before this one reads the slot, drops that key's copy for the
484//! field's, and sees the same one origin.
485//!
486//! Filtering happens before paging, so a page of a filtered result is a page of the
487//! survivors rather than the survivors of a page. Label matching and the substring rule a
488//! text candidate is confirmed by answer the same question the same way the local Markdown
489//! source's do; which candidates a text search has to confirm is GitHub's word match, which
490//! is the one place the two sources can answer the same text differently.
491//!
492//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
493//! has one source, `capabilities`, and the note above is the reasoning behind it rather
494//! than a second copy of it: without the three facts recorded here a reader takes the
495//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
496//! crate's own capabilities test, which pins every field of it against a fully spelled-out
497//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
498//! fails to compile there rather than going unasserted. -->
499//! The fixture-server tests above run wherever this crate is selected; the credentialed
500//! lane runs in the same required check, beside them, and can fail it — it verifies the
501//! 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,
502//! one filed under neither, a label on one of the three and a closed status on another —
503//! because that shape is what tells an honoured predicate from an ignored one: a board
504//! holding a single project answers a project filter the same way whether or not this
505//! source applies it, which is exactly how the defect above went unseen.
506//!
507//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
508//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
509//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
510//! nominated is what keeps a credentialed write lane off a board and a repository nobody
511//! nominated; it never asks GitHub which project was updated most recently. Before it
512//! starts, the lane also clears any item titled — and any repository label named — the way
513//! it titles and names its own artifacts, which is self-healing after an interrupted run:
514//! a process killed between its writes and its cleanup leaves artifacts the next run
515//! removes.
516//!
517//! # What a session of requests costs, and where the report is
518//!
519//! This source records **every** request it sends into [`accounting::Accounting`], at
520//! `send_once` — the one place a request leaves this crate, which is why a read path added
521//! later is counted without anybody remembering to count it. That is the whole of what this
522//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
523//! session's spend is arrived at, and what it deliberately does not know are set out.
524//!
525//! What one whole session of the live journey costs, counted that way against this crate's
526//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
527//! reduction it came out of, and with what it does and does not say about rate-limit points.
528//!
529//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
530//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
531//! code path — no environment variable, no feature, no build configuration — because an
532//! instrument nobody switches on measures nothing, and
533//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
534//! source's counts the whole session rather than this source's share. The credentialed lane
535//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
536//! passed or failed.
537//!
538//! **A live session refuses to start unless the account can afford it.** Before it does any
539//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
540//! GitHub documents as not counting against the REST rate limit and which answers both of
541//! its budgets at once — and starts only if, for each of them, what remains minus this
542//! session's estimated cost is still at least
543//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
544//! allowance. A session that cannot **declines**: it did not run, so it is
545//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
546//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
547//! waiting for the budget to come back. The estimate is derived offline from
548//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
549//! which is also where the published rule that model rests on is cited; the accounting
550//! above records the gate's own read like any other request, and
551//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
552//!
553//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
554//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
555//! text, which is what lets it run on every platform and on a pull request from a fork with
556//! no credential — and that is what actually stops a regression merging. But an offline
557//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
558//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
559//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
560//! number of nodes this query may return"* and whose `cost` is what that document would
561//! spend, both for a document **without executing it**, and the lane asks it for every query
562//! document this source sends, under the largest bindings this source sends, and fails when
563//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
564//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
565//! one; the offline pins still cover it. It records what those calls reported about the
566//! account's own allowance, because whether asking is free is a thing to observe rather than
567//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
568//! and `cost` is metered against an hourly allowance the accounting above reads off a
569//! credentialed run's own response headers.
570//!
571//! **GitHub has two rate limiters and this source is refused by both, so nothing here
572//! treats them as one thing.** The primary budget is the hourly allowance `gh api
573//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
574//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
575//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
576//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
577//! one part of.
578#![deny(missing_docs)]
579
580use std::collections::BTreeMap;
581use std::sync::{Arc, Mutex};
582use std::time::{Duration, Instant};
583
584use chrono::{DateTime, Utc};
585use onetaskgraph_plugin_api::{
586    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
587    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
588    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
589    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
590    SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
591    TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
592    UnmappedStatus, UpdatedField, WriteSupport,
593};
594use reqwest::{Client, StatusCode, Url};
595use schemars::{Schema, schema_for};
596use secrecy::{ExposeSecret, SecretString};
597use serde::{Deserialize, Serialize};
598use serde_json::{Value, json};
599
600pub mod accounting;
601
602use accounting::Accounting;
603
604/// The registry name for this plugin.
605pub const KIND: &str = "github-projects";
606/// GitHub's maximum connection page size.
607pub const MAX_PAGE_SIZE: u32 = 100;
608/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
609/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
610/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
611pub const SEARCH_PAGE_SIZE: u32 = 20;
612/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
613/// its comments: the largest batch the node-count model prices at one point.
614///
615/// Each aliased item is resolved once, and what GitHub charges for it is the connections
616/// under it — its labels, its page of board memberships, the field values of each of those
617/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
618/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
619/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
620/// one point and fails if one item more would still be priced at one.
621pub const DETAIL_BATCH: usize = 24;
622
623/// The most nodes any one document this source sends may be asked to return.
624///
625/// GitHub's own published per-query ceiling, taken from
626/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
627/// workspace cannot hold a stale copy of somebody else's number. A query above it is
628/// **refused before it is executed**, whoever is asking and whatever board they are
629/// asking about — so this is a bound on the documents rather than a budget that runs out.
630///
631/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
632/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
633/// everything the credential does — two numbers against two limits, and this constant
634/// bounds only the first. The second is computed offline too, per document:
635/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
636/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
637/// lane. There is no constant like this one to hold a price under, because points are an
638/// hourly allowance rather than a per-call bound.
639///
640/// Neither is a session's price. What `session-cost.md` records of a whole session is its
641/// **requests** and its **worst-case nodes**; what a whole session spends in points is
642/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
643/// [`accounting`]. The module section on the three ways this source reaches an item says how
644/// the count is arrived at, and which of the page sizes below decide it.
645pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
646
647/// Nested connection size for the connections that hang off one item.
648///
649/// It multiplies through every document that reaches an item under a page — the count
650/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
651/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
652/// every document under these constants and fails naming any that reaches the limit, so
653/// raising this is caught there rather than by GitHub.
654const NESTED_PAGE_SIZE: u32 = 50;
655/// How many of one issue's board memberships are read when an issue is reached directly.
656///
657/// An issue reached through a search or through its own node id carries its board half in
658/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
659/// under a page of issues, so every point of it multiplies through the whole document and
660/// is paid for whether or not any issue is on a second board — which is why it is
661/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
662///
663/// **Three, because what a page misses is now recovered rather than refused**, and the
664/// recovery is what the value is chosen against. An issue whose entry for this board sits
665/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
666/// that page's own cursor — so the value trades a bound every read pays for a request only
667/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
668/// boards would pay that request *per issue*, which is order N against the one page per
669/// hundred issues a read costs today. At three it is only reached by an issue on four or
670/// more boards at once, which keeps the recovery path exceptional rather than routine for
671/// a plausible deployment.
672const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
673/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
674/// its two connections for.
675///
676/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
677/// second is a duplicate a copy already takes the first of. Both connections are walked to
678/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
679/// never what the answer is. It is small because every point of it is paid on every lookup,
680/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
681/// worst-case nodes than the one whole-board read they replaced.
682const ORIGIN_PAGE_SIZE: u32 = 3;
683
684pub use github_graphql_node_count::{NodeCountError, Variables};
685
686/// The largest value this source can bind to each page-size variable its documents name.
687///
688/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
689/// constant above it wherever a caller's own limit could reach it — `$first` at
690/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
691/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
692/// not one configuration of it, which is what makes a bound computed under it a bound on
693/// every read.
694pub fn largest_page_sizes() -> Variables {
695    Variables::from([
696        ("first".to_owned(), MAX_PAGE_SIZE),
697        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
698        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
699        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
700    ])
701}
702
703/// The most nodes `document` could be asked to return, by GitHub's published rules.
704///
705/// Computed offline from the document's own text under [`largest_page_sizes`] — no
706/// network, no credential and no schema — by
707/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
708/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
709/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
710///
711/// # Errors
712///
713/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
714/// no single operation, or binds a page size this source does not name — each of which is
715/// a defect in the document rather than a number.
716pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
717    node_count(document, &largest_page_sizes())
718}
719
720/// The most rate-limit points one call of `document` could spend, by GitHub's published
721/// rules.
722///
723/// Computed offline from the document's own text under [`largest_page_sizes`] — no
724/// network, no credential and no schema — by
725/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
726/// This is `cost`, metered **per hour** against the allowance one credential shares across
727/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
728/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
729/// under, so what `tests/point_cost.rs` does with it is pin every document in
730/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
731/// figures against GitHub's own reported `cost`.
732///
733/// # Errors
734///
735/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
736/// no single operation, or binds a page size this source does not name — each of which is
737/// a defect in the document rather than a number.
738pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
739    github_graphql_node_count::point_cost(document, &largest_page_sizes())
740}
741
742/// The most nodes `document` could be asked to return under `variables`.
743///
744/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
745/// [`accounting`] is this under the bindings one request really sent — one spelling of the
746/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
747/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
748///
749/// # Errors
750///
751/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
752/// no single operation, or binds a page size `variables` does not name.
753pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
754    github_graphql_node_count::node_count(document, variables)
755}
756
757/// The issue-title prefix that makes a board issue a document.
758///
759/// A GitHub Projects board has no document type — it holds issues — so the discriminator
760/// is the title, and this is the whole of it: an issue whose title begins with these bytes
761/// is a document and every other issue is the task or project the sub-issue rule makes it.
762///
763/// It is spelled **once**, here, and read rather than restated everywhere else — including
764/// by the shared journeys, which take it from this constant so a board fixture cannot
765/// drift from what this source reads. `docs/metadata.md` records the two consequences that
766/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
767/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
768/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
769pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
770
771/// Exact GraphQL query documents issued by this plugin.
772///
773/// Keeping the production documents here lets the pinned-schema test validate the same
774/// bytes that are sent to GitHub, rather than a test-only copy which could drift
775/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
776/// field, and its guarded caller always supplies the complete existing option set with ids.
777pub mod graphql {
778    /// The board half of one item: the field values every document here reads it from.
779    ///
780    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
781    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
782    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
783    /// *the same value*, because
784    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
785    /// one path. Three spellings of it is what would drift, so there is one.
786    ///
787    /// The `Status` option and this source's own origin text field are the whole of it. It
788    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
789    /// content, so it holds nothing the content's own `labels` do not already say, and it
790    /// would sit a label connection two page sizes deep.
791    macro_rules! board_item_values {
792        () => {
793            r#"fieldValues(first:$nestedFirst){nodes{
794          ... on ProjectV2ItemFieldSingleSelectValue{name field{
795            ... on ProjectV2SingleSelectField{id name options{id name}}
796          }}
797          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
798        }pageInfo{hasNextPage}}"#
799        };
800    }
801
802    /// Everything this source reads about one issue, wherever it reaches that issue.
803    ///
804    /// A macro rather than a constant so the three documents below can `concat!` it: one
805    /// spelling of these fields is what makes an issue read through the board-scoped
806    /// search, through its own node id, and through its project's sub-issue relationship
807    /// resolve to *the same* item, which is the whole of what
808    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
809    ///
810    /// `projectItems` is what carries the board half of an issue: the board item's own id
811    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
812    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
813    /// issue rather than on the board, which is what makes the cost of a read proportional
814    /// to what was asked for instead of to the board's size.
815    ///
816    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
817    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
818    /// not on that page: a page here is where the search for the entry starts rather than
819    /// where it ends.
820    ///
821    /// It does **not** select the board's `Labels` field value, and that is the whole of
822    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
823    /// a label connection there sits under `fieldValues` under `projectItems` under a page
824    /// of issues, spending `$nestedFirst` twice down one path, and took
825    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
826    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
827    /// above, and that connection is where every label this source reports comes from. No
828    /// document in this module selects the board field any longer, [`BOARD`] included; the
829    /// module documentation records why nothing it could have held is lost.
830    macro_rules! board_issue {
831        () => {
832            concat!(
833                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
834      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
835      projectItems(first:$boardItems){nodes{id project{id number}
836        "#,
837                board_item_values!(),
838                r#"}pageInfo{hasNextPage endCursor}}}"#
839            )
840        };
841    }
842
843    /// Every issue of one board, found by a search scoped to that board.
844    ///
845    /// This is how the projects a board holds are listed, and it selects no `items`
846    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
847    /// container walked page by page, so nothing nested inside a board item is paid for.
848    /// Which of the issues it returns is a project is then read off `parent` — GitHub
849    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
850    /// discriminator has to be applied to the field, which is a scalar on the issue and
851    /// costs nothing.
852    pub const SEARCH_ISSUES: &str = concat!(
853        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
854      search(query:$search,type:$type,first:$first,after:$after){
855        pageInfo{hasNextPage endCursor}
856        nodes{__typename ...BoardIssue}
857      }
858    }"#,
859        board_issue!()
860    );
861
862    /// What a dependency read selects of each far end: enough to say which kind of item it
863    /// is, its body included for the kind marker.
864    macro_rules! related_issue {
865        () => {
866            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
867        };
868    }
869
870    /// One issue by its own node id, which is what a qualified id names here — with what a
871    /// write of it needs and the issue does not carry in `board_issue!`: the field
872    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
873    ///
874    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
875    /// answers a write made moments ago with the value from before it, and resolving a node
876    /// id does not.
877    ///
878    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
879    /// reads it by its own id, and with them that one read answers everything the write
880    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
881    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
882    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
883    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
884    /// they sit under one item, and this read is still one point.
885    pub const ISSUE: &str = concat!(
886        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
887      node(id:$id){__typename ...BoardIssue ... on Issue{
888        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
889          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
890          ... on ProjectV2Field{__typename id name}
891        }pageInfo{hasNextPage}}}}}
892        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
893      }}
894    }"#,
895        board_issue!(),
896        related_issue!()
897    );
898
899    /// One project's tasks: the sub-issues of the issue that project is.
900    ///
901    /// The work this costs is the project's own size. Nothing about it grows as the board
902    /// gains projects, or as those projects gain tasks.
903    pub const SUB_ISSUES: &str = concat!(
904        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
905      node(id:$id){__typename
906        ... on Issue{subIssues(first:$first,after:$after){
907          pageInfo{hasNextPage endCursor}
908          nodes{__typename ...BoardIssue}
909        }}}
910    }"#,
911        board_issue!()
912    );
913
914    /// What a read of the board's own `items` selects of each item's content.
915    ///
916    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
917    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
918    /// content by one spelling.
919    macro_rules! board_item_content {
920        () => {
921            r#" content{
922        ... 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}}}
923        ... on PullRequest{__typename id}
924        ... on DraftIssue{__typename id title body createdAt updatedAt}
925      }"#
926        };
927    }
928
929    /// Reads the board's fields and one page of its items.
930    pub const BOARD: &str = concat!(
931        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
932      owner:repositoryOwner(login:$owner){
933        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
934      }
935    } fragment Board on ProjectV2 { id title
936      fields(first:$nestedFirst){nodes{
937        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
938        ... on ProjectV2Field{__typename id name}
939      }pageInfo{hasNextPage}}
940      items(first:$first,after:$after){nodes{id "#,
941        board_item_values!(),
942        board_item_content!(),
943        r#"} pageInfo{hasNextPage endCursor}}
944    }"#
945    );
946
947    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
948    /// the board.
949    ///
950    /// **`originItems`** is the board's own items narrowed by its own field filter —
951    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
952    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
953    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
954    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
955    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
956    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
957    /// fresh `addProjectV2ItemById` the way that connection does.
958    ///
959    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
960    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
961    /// indexes that comment, and the index catches up with a write in a second or two rather
962    /// than in minutes, so it finds a carrier another process wrote that the first read is
963    /// still behind on.
964    ///
965    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
966    /// — and resumes from its own cursor; a connection already walked to its end is resumed
967    /// from its last cursor, which answers an empty page. Every candidate either read returns
968    /// is confirmed against its own origin field before it is reported, so a token match of
969    /// the search or anything else the filter admits never is.
970    ///
971    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
972    /// own whole reads counts this one among them.
973    pub const ORIGIN_LOOKUP: &str = concat!(
974        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
975      originItems:repositoryOwner(login:$owner){
976        ... on ProjectV2Owner{projectV2(number:$number){
977          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
978        board_item_values!(),
979        board_item_content!(),
980        r#"} pageInfo{hasNextPage endCursor}}
981        }}
982      }
983      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
984        pageInfo{hasNextPage endCursor}
985        nodes{__typename ...BoardIssue}
986      }
987    }"#,
988        board_issue!()
989    );
990
991    /// The board's own id and field definitions, and not one of its items.
992    ///
993    /// What a write needs of the board when the item it writes does not say: the id a field
994    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
995    /// origin fields. It selects no `items`, so what it costs is the board's field list
996    /// however many items the board holds — and it decides nothing about which items those
997    /// are, which is the question a read of one item by its own id answers instead.
998    ///
999    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1000    /// board's item reads by their root counts this one among them.
1001    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1002      boardFields:repositoryOwner(login:$owner){
1003        ... on ProjectV2Owner{projectV2(number:$number){id
1004          fields(first:$nestedFirst){nodes{
1005            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1006            ... on ProjectV2Field{__typename id name}
1007          }pageInfo{hasNextPage}}
1008        }}
1009      }
1010    }"#;
1011
1012    /// One board draft by its own node id, with the board item it sits in.
1013    ///
1014    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1015    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1016    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1017    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1018    /// board listing hands it to, and nothing has to list the board to find one.
1019    pub const DRAFT: &str = concat!(
1020        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1021      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1022        projectV2Items(first:$boardItems){nodes{id project{id number}
1023        "#,
1024        board_item_values!(),
1025        r#"}pageInfo{hasNextPage endCursor}}}}
1026    }"#
1027    );
1028
1029    /// One issue's board memberships alone, walked past the page a read of it carried.
1030    ///
1031    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1032    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1033    /// boards than that page holds may have this board's entry past its end. This asks that
1034    /// one issue for its memberships and nothing else — the caller already holds the issue —
1035    /// so an answer of "this board does not hold it" is only ever given about a connection
1036    /// read to exhaustion.
1037    ///
1038    /// It selects the board item's id, its project number and the same
1039    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1040    /// very same resolver: an issue recovered this way reports the same title, the same
1041    /// status, the same labels and the same qualified id as one whose entry was on the
1042    /// page.
1043    ///
1044    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1045    /// multiplies through it and the membership connection can be walked at
1046    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1047    /// further request for any issue a person really keeps.
1048    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1049        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1050      node(id:$id){
1051        ... on Issue{projectItems(first:$first,after:$after){
1052          nodes{id project{id number}
1053        "#,
1054        board_item_values!(),
1055        r#"}
1056          pageInfo{hasNextPage endCursor}}}
1057      }
1058    }"#
1059    );
1060    /// Resolves the configured repository's node id, which creating an issue requires.
1061    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1062    /// What creating an issue needs and has not read yet: the board's own id and field
1063    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1064    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1065    ///
1066    /// Sent at the point a create knows which repository it is for, when neither half is
1067    /// already known to this process; a create needing only one of them sends that one's own
1068    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1069    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1070    /// status.
1071    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1072      boardFields:repositoryOwner(login:$owner){
1073        ... on ProjectV2Owner{projectV2(number:$number){id
1074          fields(first:$nestedFirst){nodes{
1075            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1076            ... on ProjectV2Field{__typename id name}
1077          }pageInfo{hasNextPage}}
1078        }}
1079      }
1080      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1081    }"#;
1082    /// Reads both dependency directions for one issue, with each far end's own kind — and
1083    /// the issue's own body, which is where an edge to another source is recorded, so that
1084    /// half of a dependency read needs no second read of the issue or of the board.
1085    pub const ISSUE_DEPENDENCIES: &str = concat!(
1086        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1087      ... on Issue{body
1088        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1089        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1090      }}}"#,
1091        related_issue!()
1092    );
1093    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1094    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1095    /// answered when it was.
1096    pub const CREATE_ISSUE: &str =
1097        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1098    /// Puts an existing issue on the configured board.
1099    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1100    /// Updates an issue's visible fields and its open or closed state in one call.
1101    pub const UPDATE_ISSUE: &str =
1102        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1103    /// Updates an existing draft's user-visible fields.
1104    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1105    /// Updates a text or single-select value on one project item.
1106    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}}}}}}}}"#;
1107    /// Writes up to three board fields and an optional clear in one ordered mutation.
1108    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}}}"#;
1109    /// Clears one project item's value of one field, which is what a `none` priority is.
1110    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}}}}}}}}"#;
1111    /// Creates one single-select field with its options. Only the guarded field setup may use
1112    /// this document, and only for a field the board lacks.
1113    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1114    /// Replaces a single-select field's options. Only the guarded field setup — the
1115    /// `status-options` and `fields` operations — may use this document, because GitHub
1116    /// treats the input as the complete option list.
1117    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1118    /// A fresh snapshot of the Status field and every board item's assignment.
1119    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}}}}}}"#;
1120    /// Files one issue under another as a sub-issue, which is what project membership is.
1121    pub const ADD_SUB_ISSUE: &str =
1122        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1123    /// Takes one issue back out of its parent.
1124    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1125    /// Adds GitHub's native issue blocked-by relationship.
1126    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1127    /// Removes one native issue blocked-by relationship.
1128    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1129    /// Deletes one issue, which takes its board item with it.
1130    ///
1131    /// The engine sends this in one situation only: undoing a copy that could not finish,
1132    /// over the items that same copy created. Deleting the issue removes the board item
1133    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1134    pub const DELETE_ISSUE: &str =
1135        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1136
1137    /// Everything this source reads about one issue comment, wherever it reaches one.
1138    ///
1139    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1140    /// and a comment just edited are handed to one mapper, so they are selected by one
1141    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1142    /// longer exists, and `login` is the one member every kind of actor carries.
1143    macro_rules! issue_comment {
1144        () => {
1145            "id author{login} createdAt updatedAt body url"
1146        };
1147    }
1148
1149    /// One task's comments: a page of its issue's own `comments` connection.
1150    ///
1151    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1152    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1153    /// list every time somebody edited it; left unordered the connection answers in the order
1154    /// the comments were written, which is the order GitHub documents for the same collection
1155    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1156    /// node count and the caller's own page size is pushed straight down.
1157    pub const ISSUE_COMMENTS: &str = concat!(
1158        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1159        issue_comment!(),
1160        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1161    );
1162    /// One issue by its own node id, with a page of its comments: what `task show` and a
1163    /// comment listing read, in one request.
1164    ///
1165    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1166    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1167    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1168    /// connection there would multiply through both of those documents' price, and neither
1169    /// needs one.
1170    pub const ISSUE_DETAIL: &str = concat!(
1171        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1172      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173        issue_comment!(),
1174        r#"}pageInfo{hasNextPage endCursor}}}}
1175    }"#,
1176        board_issue!()
1177    );
1178
1179    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1180    /// page of its comments when `$comments` asks for them.
1181    macro_rules! issue_details_alias {
1182        ($n:literal) => {
1183            concat!(
1184                "\n      i",
1185                stringify!($n),
1186                ":node(id:$id",
1187                stringify!($n),
1188                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1189                issue_comment!(),
1190                "}pageInfo{hasNextPage endCursor}}}}"
1191            )
1192        };
1193    }
1194
1195    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1196    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1197    ///
1198    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1199    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1200    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1201    /// neither — so every connection under it would be priced at nothing and the pin in
1202    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1203    /// one-item read the model already prices, so the batch costs what its aliases cost.
1204    ///
1205    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1206    /// slots it has no item for to the last item it does, and reads that item again; the
1207    /// price is the document's, whatever its variables, so a short batch costs what a full
1208    /// one does and nothing more.
1209    pub const ISSUE_DETAILS: &str = concat!(
1210        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!){"#,
1211        issue_details_alias!(0),
1212        issue_details_alias!(1),
1213        issue_details_alias!(2),
1214        issue_details_alias!(3),
1215        issue_details_alias!(4),
1216        issue_details_alias!(5),
1217        issue_details_alias!(6),
1218        issue_details_alias!(7),
1219        issue_details_alias!(8),
1220        issue_details_alias!(9),
1221        issue_details_alias!(10),
1222        issue_details_alias!(11),
1223        issue_details_alias!(12),
1224        issue_details_alias!(13),
1225        issue_details_alias!(14),
1226        issue_details_alias!(15),
1227        issue_details_alias!(16),
1228        issue_details_alias!(17),
1229        issue_details_alias!(18),
1230        issue_details_alias!(19),
1231        issue_details_alias!(20),
1232        issue_details_alias!(21),
1233        issue_details_alias!(22),
1234        issue_details_alias!(23),
1235        "\n    }",
1236        board_issue!()
1237    );
1238
1239    /// Which issue one comment is on, read before that comment is edited or removed.
1240    ///
1241    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1242    /// comment id given against the wrong task would change a comment on another issue.
1243    pub const COMMENT_ISSUE: &str =
1244        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1245    /// Adds one comment to an issue, signed as the account the token belongs to.
1246    pub const ADD_COMMENT: &str = concat!(
1247        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1248        issue_comment!(),
1249        r#"}}}}"#
1250    );
1251    /// Replaces the body of one issue comment.
1252    pub const UPDATE_COMMENT: &str = concat!(
1253        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1254        issue_comment!(),
1255        r#"}}}"#
1256    );
1257    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1258    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1259
1260    /// Every document above, with what this source is doing when it sends one.
1261    ///
1262    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1263    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1264    /// document added later with "talking to GitHub" and never say so.
1265    ///
1266    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1267    /// const` here that this list omits, so the two cannot part — which is the same guard
1268    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1269    pub const DOCUMENTS: [(&str, &str); 33] = [
1270        (SEARCH_ISSUES, "searching this board's issues"),
1271        (ISSUE, "reading one issue"),
1272        (
1273            ISSUE_BOARD_ITEMS,
1274            "reading one issue's board memberships past the page it came with",
1275        ),
1276        (SUB_ISSUES, "reading a project's tasks"),
1277        (BOARD, "reading the board"),
1278        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1279        (BOARD_FIELDS, "reading the board's fields"),
1280        (DRAFT, "reading one draft"),
1281        (REPOSITORY, "reading the destination repository"),
1282        (
1283            CREATION_CONTEXT,
1284            "reading the board's fields and the destination repository",
1285        ),
1286        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1287        (CREATE_ISSUE, "creating an issue"),
1288        (ADD_TO_BOARD, "adding an issue to the board"),
1289        (UPDATE_ISSUE, "updating an issue"),
1290        (UPDATE_DRAFT, "updating a draft item"),
1291        (UPDATE_FIELD, "writing a board field"),
1292        (UPDATE_FIELDS, "writing board fields together"),
1293        (CLEAR_FIELD, "clearing a board field"),
1294        (
1295            CREATE_FIELD,
1296            "creating a board single-select field with its options",
1297        ),
1298        (
1299            STATUS_OPTIONS_SNAPSHOT,
1300            "snapshotting board Status options and assignments",
1301        ),
1302        (
1303            STATUS_OPTIONS_UPDATE,
1304            "safely replacing the board Status option list",
1305        ),
1306        (ADD_SUB_ISSUE, "filing an issue under its project"),
1307        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1308        (ADD_BLOCKED_BY, "recording a dependency"),
1309        (REMOVE_BLOCKED_BY, "removing a dependency"),
1310        (DELETE_ISSUE, "deleting an issue"),
1311        (ISSUE_COMMENTS, "reading a task's comments"),
1312        (ISSUE_DETAIL, "reading one issue with its comments"),
1313        (
1314            ISSUE_DETAILS,
1315            "reading a batch of issues with their comments",
1316        ),
1317        (COMMENT_ISSUE, "reading which issue a comment is on"),
1318        (ADD_COMMENT, "adding a comment"),
1319        (UPDATE_COMMENT, "editing a comment"),
1320        (DELETE_COMMENT, "deleting a comment"),
1321    ];
1322}
1323
1324/// Which of GitHub's two rate limiters refused a request.
1325///
1326/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1327/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1328/// the whole reason this is carried rather than collapsed into "rate limited".
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum Limiter {
1331    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1332    Primary,
1333    /// The burst limiter over content-generating requests, which nothing reports.
1334    Secondary,
1335}
1336
1337/// The wordings GitHub answers a secondary rate limit with.
1338///
1339/// It sends them under a forbidden status, under a too-many-requests status, and inside
1340/// the `errors` of a *successful* response, which is why the text is what this matches on
1341/// rather than the status. `abuse detection` is the wording GitHub used before the
1342/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1343/// what a burst of content creation is refused with.
1344///
1345/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1346/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1347/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1348/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1349pub const SECONDARY_WORDINGS: [&str; 5] = [
1350    "secondary rate limit",
1351    "temporarily blocked from content creation",
1352    "abuse detection",
1353    "submitted too quickly",
1354    "exceeded a secondary",
1355];
1356
1357/// The wordings GitHub answers an exhausted primary budget with.
1358///
1359/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1360/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1361/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1362/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1363/// two phrases is a substring of it, so without it that answer read as a refusal that will
1364/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1365/// one reason.
1366pub const PRIMARY_WORDINGS: [&str; 4] = [
1367    "api rate limit exceeded",
1368    "api rate limit already exceeded",
1369    "rate limit exceeded",
1370    "rate_limited",
1371];
1372
1373/// What a response *says about itself*, which is the only place a refusal can be read.
1374///
1375/// Deliberately not the whole response body. A board is a place people write about their
1376/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1377/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1378/// reported. So the item data is never read: what is read is GitHub's own REST-style
1379/// `message` envelope, which is what a forbidden status carries, and the `message` and
1380/// `type` of each GraphQL error, which is where a *successful* response says it.
1381///
1382/// A body that is not JSON at all has nothing structured to read, so only a failing
1383/// response's own text is taken — a successful response that is not JSON is malformed
1384/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1385fn refusal_wording(status: StatusCode, body: &str) -> String {
1386    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1387        return if status.is_success() {
1388            String::new()
1389        } else {
1390            body.to_owned()
1391        };
1392    };
1393    let mut said: Vec<&str> = parsed
1394        .get("message")
1395        .and_then(Value::as_str)
1396        .into_iter()
1397        .collect();
1398    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1399        for error in errors {
1400            said.extend(
1401                ["message", "type"]
1402                    .into_iter()
1403                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1404            );
1405        }
1406    }
1407    said.join("; ")
1408}
1409
1410impl Limiter {
1411    /// Which limiter refused this response, or `None` when none of them did.
1412    ///
1413    /// The wording is read first and the status only decides what carries none of it,
1414    /// because GitHub answers a secondary limit with a forbidden status far more often
1415    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1416    /// really is a credential this token lacks.
1417    ///
1418    /// A response is a refusal because of its status or its own wording. A spent budget
1419    /// only ever explains one; it never turns an answer into a refusal.
1420    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1421        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1422        if SECONDARY_WORDINGS
1423            .iter()
1424            .any(|wording| normalized.contains(wording))
1425        {
1426            return Some(Self::Secondary);
1427        }
1428        if status == StatusCode::TOO_MANY_REQUESTS {
1429            return Some(Self::Primary);
1430        }
1431        // An exhausted budget *explains* a response that failed; it does not make one that
1432        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1433        // request the budget allowed as well as on the ones it then refuses, so reading
1434        // the header alone threw away a good answer — and, once refusals were retried,
1435        // replayed a request that had already taken effect.
1436        if !status.is_success() && budget_exhausted {
1437            return Some(Self::Primary);
1438        }
1439        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1440        // `errors` of an HTTP 200, where nothing about the status says so at all.
1441        if status.is_success()
1442            && PRIMARY_WORDINGS
1443                .iter()
1444                .any(|wording| normalized.contains(wording))
1445        {
1446            return Some(Self::Primary);
1447        }
1448        None
1449    }
1450
1451    /// What this limiter is called where an operator can look it up.
1452    const fn name(self) -> &'static str {
1453        match self {
1454            Self::Primary => "GitHub's primary API rate limit",
1455            Self::Secondary => "GitHub's secondary rate limit",
1456        }
1457    }
1458
1459    /// What the endpoint an operator would go and check says about this limiter.
1460    const fn where_to_look(self) -> &'static str {
1461        match self {
1462            Self::Primary => {
1463                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1464                 comes back."
1465            }
1466            Self::Secondary => {
1467                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1468                 primary budget and does not report this one, so budget showing there says \
1469                 nothing about this refusal, and every further attempt extends it."
1470            }
1471        }
1472    }
1473
1474    /// The next step this limiter actually calls for.
1475    const fn what_to_do(self) -> &'static str {
1476        match self {
1477            Self::Primary => {
1478                "wait for the reset `gh api rate_limit` reports, then run the command again."
1479            }
1480            Self::Secondary => {
1481                "leave this board alone for a few minutes, then run the command again — or \
1482                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1483            }
1484        }
1485    }
1486}
1487
1488/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1489#[derive(Debug, Clone, Copy)]
1490struct Limited {
1491    limiter: Limiter,
1492    hint: Option<u64>,
1493}
1494
1495impl Limited {
1496    /// What the caller is told once this source has waited as long as it may.
1497    ///
1498    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1499    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1500    /// about *which* limiter it was makes it a different kind of failure. What differs is
1501    /// the operator's next step, and that is what the message carries — a secondary
1502    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1503    /// budget looks fine, and then back to retry the very burst that was refused.
1504    fn exhausted(
1505        self,
1506        doing: &str,
1507        waits: u32,
1508        waited: Duration,
1509        needed: Duration,
1510        budget: Duration,
1511    ) -> SourceError {
1512        SourceError::RateLimited {
1513            retry_after_seconds: self.hint,
1514            message: Some(format!(
1515                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1516                 again, and the next wait of {} would take it past the {} one call may spend \
1517                 waiting. {} next: {}",
1518                self.limiter.name(),
1519                plural(waits, "refusal"),
1520                seconds(waited),
1521                seconds(needed),
1522                seconds(budget),
1523                self.limiter.where_to_look(),
1524                self.limiter.what_to_do(),
1525            )),
1526        }
1527    }
1528}
1529
1530/// One HTTP attempt's result, with what its response said about the rate limit.
1531///
1532/// The two travel together so the record and the outcome are written from the same place:
1533/// what a response said about the budget is only readable while that response is in hand,
1534/// and what the attempt *meant* is only decidable once its body has been read.
1535struct Attempted {
1536    result: Result<Value, Attempt>,
1537    limits: accounting::RateLimit,
1538    /// GitHub's own reported cost for this call, for a document that asked for it.
1539    reported_cost: Option<u64>,
1540}
1541
1542/// One attempt's outcome: an error to report, or a rate limit to wait out.
1543enum Attempt {
1544    Failed(SourceError),
1545    Limited(Limited),
1546}
1547
1548fn plural(count: u32, thing: &str) -> String {
1549    if count == 1 {
1550        format!("{count} {thing}")
1551    } else {
1552        format!("{count} {thing}s")
1553    }
1554}
1555
1556fn seconds(duration: Duration) -> String {
1557    format!("{:.1}s", duration.as_secs_f64())
1558}
1559
1560/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1561///
1562/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1563/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1564/// header, and neither is what makes a response a refusal — so the whole cost of one this
1565/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1566/// instead. Refusing the response over the header would turn a readable refusal into an
1567/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1568fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1569    value
1570        .and_then(|value| value.to_str().ok())
1571        .and_then(|value| value.trim().parse::<u64>().ok())
1572}
1573
1574/// Every mutation this source sends creates content — an issue, a board item, a field of
1575/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1576/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1577/// and what the keyword says are the same set. That is what makes the keyword a sound test
1578/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1579/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1580fn is_mutation(query: &str) -> bool {
1581    query.trim_start().starts_with("mutation")
1582}
1583
1584/// What this source was doing, for a diagnostic that has to say so.
1585///
1586/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1587/// a document added without a description is caught by that list's own gate instead of
1588/// falling through to the vague arm below.
1589fn operation_description(query: &str) -> &'static str {
1590    graphql::DOCUMENTS
1591        .iter()
1592        .find(|(document, _)| *document == query)
1593        .map_or("talking to GitHub", |(_, doing)| *doing)
1594}
1595
1596/// GitHub's published ceiling on content-generating requests, per minute.
1597///
1598/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1599/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1600/// from it, so a pacing value checked only against itself cannot go stale here.
1601pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1602/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1603/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1604/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1605pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1606/// Shortest interval between two content-creating mutations, in milliseconds.
1607///
1608/// GitHub documents two secondary limits on content-generating requests:
1609/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1610/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1611/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1612/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1613/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1614/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1615/// deliberately *not* what this paces at. An installation that wants the hourly bound
1616/// honoured for a long sequence of copies says so through
1617/// `pacing.min_mutation_interval_ms`.
1618pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1619/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1620///
1621/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1622/// own advice for a secondary limit — wait, and wait longer each time — without spending
1623/// the first minute of a transient refusal doing nothing.
1624pub const RETRY_BACKOFF_MS: u64 = 1_000;
1625/// Total time one call may spend waiting out rate limits before it reports a failure.
1626///
1627/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1628/// short enough that a command an operator is watching returns. The bound is what makes
1629/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1630/// the limiter, not in a process nobody can tell from a wedged one.
1631pub const RETRY_BUDGET_MS: u64 = 120_000;
1632
1633fn default_token_env() -> String {
1634    "GH_PROJECTS_TOKEN".to_owned()
1635}
1636fn default_endpoint() -> String {
1637    "https://api.github.com/graphql".to_owned()
1638}
1639
1640/// The name of a `Status` single-select option on the board.
1641///
1642/// Validated on the way in rather than checked later, so a blank option name — which
1643/// nothing on a board can be — is a state this type cannot hold.
1644#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1645#[serde(try_from = "String")]
1646#[schemars(extend("minLength" = 1))]
1647pub struct ColumnName(String);
1648
1649impl ColumnName {
1650    /// The option name, as the board spells it.
1651    fn as_str(&self) -> &str {
1652        &self.0
1653    }
1654}
1655
1656impl TryFrom<String> for ColumnName {
1657    type Error = String;
1658
1659    fn try_from(name: String) -> Result<Self, Self::Error> {
1660        if name.trim().is_empty() {
1661            return Err("a status_mapping option name cannot be blank".to_owned());
1662        }
1663        Ok(Self(name))
1664    }
1665}
1666
1667/// The two closed states this product can mean.
1668///
1669/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1670/// work nor abandoned work, so nothing here ever writes it.
1671#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1672#[serde(rename_all = "kebab-case")]
1673pub enum ClosedState {
1674    /// `COMPLETED` — precisely done.
1675    Completed,
1676    /// `NOT_PLANNED` — precisely cancelled.
1677    NotPlanned,
1678}
1679
1680impl ClosedState {
1681    const fn reason(self) -> &'static str {
1682        match self {
1683            Self::Completed => "COMPLETED",
1684            Self::NotPlanned => "NOT_PLANNED",
1685        }
1686    }
1687}
1688
1689/// Configuration for one GitHub Projects v2 board.
1690#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1691#[serde(default, deny_unknown_fields)]
1692pub struct GitHubProjectsConfig {
1693    /// Login of the user or organization which owns the board.
1694    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1695    /// The project number shown in the board's GitHub URL.
1696    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1697    // 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.
1698    /// `owner/name` of the repository this source creates an issue in when the item's own
1699    /// `repositories` field does not decide it.
1700    ///
1701    /// An item naming exactly one repository is created there; a task or a document naming
1702    /// none or several is created in its parent project's repository; and a project, or a
1703    /// task or document with no parent, naming none or several is created here. A board
1704    /// has no repository of its own and `createIssue` requires one, so a write without
1705    /// this is refused naming the field. Reads never need it.
1706    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1707    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1708    /// Environment variable containing a fine-grained token with Projects and Issues
1709    /// read/write plus Pull requests read-only access for every repository represented on
1710    /// the board.
1711    #[serde(default = "default_token_env")]
1712    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1713    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1714    #[serde(default = "default_endpoint")]
1715    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1716    /// Per-instance mapping from a status category to the option of the board's one
1717    /// `Status` field it lands on, for a task and for a project.
1718    ///
1719    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1720    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1721    /// where a kind left out leaves the category unmapped for that kind. A category this
1722    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1723    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1724    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1725    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1726    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1727    /// either kind. No two categories may name one option for the same kind, ignoring case.
1728    /// `unknown` may name one existing option; every unknown word then lands on it and
1729    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1730    /// each unknown word because it never creates board options.
1731    #[serde(default)]
1732    pub status_mapping: StatusMapping,
1733    /// Per-instance mapping from a task's priority to an option of this board's
1734    /// single-select field named `Priority`.
1735    ///
1736    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1737    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1738    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1739    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1740    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1741    /// no two levels may name one option. Reads and writes never create the field or an
1742    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1743    /// the board lacks is refused pointing there.
1744    #[serde(default)]
1745    pub priority_mapping: Option<PriorityMappingConfig>,
1746    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1747    ///
1748    /// Every field keeps its shipped default when it is absent, and the defaults are
1749    /// GitHub's own published limits rather than taste. See [`Pacing`].
1750    #[serde(default)]
1751    pub pacing: PacingConfig,
1752}
1753
1754/// Which option of the board's `Priority` field each priority lands on.
1755///
1756/// One member per level rather than a map, so a key that is not a level is refused where
1757/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1758/// value in the field, not an option of it.
1759#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1760#[serde(default, deny_unknown_fields)]
1761pub struct PriorityMappingConfig {
1762    /// The option `urgent` lands on; `Urgent` when absent.
1763    pub urgent: Option<PriorityOptionName>,
1764    /// The option `high` lands on; `High` when absent.
1765    pub high: Option<PriorityOptionName>,
1766    /// The option `medium` lands on; `Medium` when absent.
1767    pub medium: Option<PriorityOptionName>,
1768    /// The option `low` lands on; `Low` when absent.
1769    pub low: Option<PriorityOptionName>,
1770}
1771
1772/// The name of an option of the board's `Priority` single-select field.
1773///
1774/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1775/// blank name.
1776#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1777#[serde(try_from = "String")]
1778#[schemars(extend("minLength" = 1))]
1779pub struct PriorityOptionName(String);
1780
1781impl PriorityOptionName {
1782    /// The option name, as the board spells it.
1783    fn as_str(&self) -> &str {
1784        &self.0
1785    }
1786}
1787
1788impl TryFrom<String> for PriorityOptionName {
1789    type Error = String;
1790
1791    fn try_from(name: String) -> Result<Self, Self::Error> {
1792        if name.trim().is_empty() {
1793            return Err("a priority_mapping option name cannot be blank".to_owned());
1794        }
1795        Ok(Self(name))
1796    }
1797}
1798
1799/// The name of the board field a priority is held in.
1800pub const PRIORITY_FIELD: &str = "Priority";
1801
1802/// The four priorities a board option can hold, in the order a new `Priority` field lists
1803/// them. `none` is not among them: it is the field holding no value.
1804///
1805/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1806/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1807/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1808/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1809pub const PRIORITY_LEVELS: [Priority; 4] = [
1810    Priority::Urgent,
1811    Priority::High,
1812    Priority::Medium,
1813    Priority::Low,
1814];
1815
1816/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1817/// see that list for what this pins.
1818#[must_use]
1819pub const fn level_position(priority: Priority) -> Option<usize> {
1820    match priority {
1821        Priority::None => None,
1822        Priority::Urgent => Some(0),
1823        Priority::High => Some(1),
1824        Priority::Medium => Some(2),
1825        Priority::Low => Some(3),
1826    }
1827}
1828
1829/// This instance's complete priority-to-option mapping, read in both directions.
1830///
1831/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1832/// two levels name one option.
1833#[derive(Debug, Clone)]
1834struct PriorityMapping {
1835    options: [PriorityOptionName; 4],
1836}
1837
1838impl PriorityMapping {
1839    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1840        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1841        let mapping = Self {
1842            options: [
1843                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1844                config.high.unwrap_or_else(|| shipped("High")),
1845                config.medium.unwrap_or_else(|| shipped("Medium")),
1846                config.low.unwrap_or_else(|| shipped("Low")),
1847            ],
1848        };
1849        for (index, option) in mapping.options.iter().enumerate() {
1850            if let Some(earlier) = mapping.options[..index]
1851                .iter()
1852                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1853            {
1854                return Err(SourceError::Config {
1855                    message: format!(
1856                        "priority_mapping of source {instance} sends both {} and {} to the board \
1857                         option {:?}; one option cannot read back as two priorities",
1858                        PRIORITY_LEVELS[earlier],
1859                        PRIORITY_LEVELS[index],
1860                        option.as_str()
1861                    ),
1862                });
1863            }
1864        }
1865        Ok(mapping)
1866    }
1867
1868    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1869    fn option(&self, priority: Priority) -> Option<&str> {
1870        level_position(priority).map(|index| self.options[index].as_str())
1871    }
1872
1873    /// The priority a board option name reports, or `None` when nothing maps to it.
1874    fn priority_of(&self, option: &str) -> Option<Priority> {
1875        self.options
1876            .iter()
1877            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1878            .map(|index| PRIORITY_LEVELS[index])
1879    }
1880
1881    /// Every mapped option name, in the order a new `Priority` field lists them.
1882    fn names(&self) -> impl Iterator<Item = &str> {
1883        self.options.iter().map(PriorityOptionName::as_str)
1884    }
1885}
1886
1887/// What one item's `Priority` field says, read through this instance's mapping.
1888#[derive(Debug, Clone, PartialEq, Eq)]
1889enum HeldPriority {
1890    /// A priority this source reports: an option the mapping names, or no value (`none`).
1891    Read(Priority),
1892    /// An option the mapping does not name, which is never read as a level or as `none`.
1893    Unmapped(String),
1894}
1895
1896/// How fast this source writes, and how long it waits out a rate-limit refusal.
1897///
1898/// Configurable because a GitHub Enterprise installation sets its own limits and an
1899/// operator who has already been refused may want to go slower still — not because the
1900/// defaults are guesses.
1901#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1902#[serde(default, deny_unknown_fields)]
1903pub struct PacingConfig {
1904    /// Shortest interval between two content-creating mutations, in milliseconds.
1905    ///
1906    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1907    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1908    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.
1909    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1910    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1911    /// zero while there is a budget to spend, because a schedule of zero-length waits
1912    /// consumes none of it and so never ends.
1913    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.
1914    /// Total time one call may spend waiting out rate limits, in milliseconds.
1915    ///
1916    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1917    /// the bound is what makes this a wait rather than a hang.
1918    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.
1919}
1920
1921/// The largest any pacing setting may be, in milliseconds.
1922///
1923/// One hour. GitHub's own harshest published bound on content-generating requests works
1924/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1925/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1926/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1927/// and an interval beyond it is a command that never sends its second mutation. It also
1928/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1929/// what an `Instant` can hold on every platform.
1930pub const MAX_PACING_MS: u64 = 3_600_000;
1931
1932/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1933/// source holds.
1934#[derive(Debug, Clone, Copy)]
1935struct Pacing {
1936    min_mutation_interval: Duration,
1937    retry_backoff: Duration,
1938    retry_budget: Duration,
1939}
1940
1941impl Pacing {
1942    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1943    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1944        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1945            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1946                message: format!(
1947                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1948                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1949                     GitHub's own harshest published limit"
1950                ),
1951            }),
1952            Some(value) => Ok(Duration::from_millis(value)),
1953            None => Ok(Duration::from_millis(default)),
1954        };
1955        let retry_backoff = bounded(
1956            config.retry_backoff_ms,
1957            RETRY_BACKOFF_MS,
1958            "retry_backoff_ms",
1959        )?;
1960        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1961        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1962            return Err(SourceError::Config {
1963                message: format!(
1964                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1965                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1966                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1967                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1968                     waiting at all",
1969                    retry_budget.as_millis()
1970                ),
1971            });
1972        }
1973        Ok(Self {
1974            min_mutation_interval: bounded(
1975                config.min_mutation_interval_ms,
1976                MIN_MUTATION_INTERVAL_MS,
1977                "min_mutation_interval_ms",
1978            )?,
1979            retry_backoff,
1980            retry_budget,
1981        })
1982    }
1983}
1984
1985/// Factory for [`GitHubProjectsSource`].
1986#[derive(Debug, Clone, Copy, Default)]
1987pub struct Plugin;
1988
1989impl SourcePlugin for Plugin {
1990    fn kind(&self) -> &'static str {
1991        KIND
1992    }
1993    fn config_schema(&self) -> Schema {
1994        schema_for!(GitHubProjectsConfig)
1995    }
1996    fn build(
1997        &self,
1998        name: &SourceName,
1999        config: &Value,
2000        secrets: &dyn SecretResolver,
2001    ) -> Result<Box<dyn TaskSource>, SourceError> {
2002        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2003    }
2004}
2005
2006impl Plugin {
2007    /// Build a source recording every request it sends into an accounting the caller holds.
2008    ///
2009    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2010    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2011    /// session total rather than two — see [`accounting`] and
2012    /// [`GitHubProjectsSource::recording_into`].
2013    ///
2014    /// # Errors
2015    ///
2016    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2017    /// [`SourceError::Config`] for configuration this plugin cannot use and
2018    /// [`SourceError::Auth`] for a credential it cannot find.
2019    pub fn build_recording_into(
2020        &self,
2021        name: &SourceName,
2022        config: &Value,
2023        secrets: &dyn SecretResolver,
2024        ledger: Arc<Accounting>,
2025    ) -> Result<Box<dyn TaskSource>, SourceError> {
2026        let config: GitHubProjectsConfig =
2027            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2028                message: format!("source {name}: {e}"),
2029            })?;
2030        let prefix = format!("source {name}: ");
2031        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2032            |error| match error {
2033                // The shared `StatusMapping::distinct` names the source itself.
2034                SourceError::Config { message } if message.starts_with(&prefix) => {
2035                    SourceError::Config { message }
2036                }
2037                SourceError::Config { message } => SourceError::Config {
2038                    message: format!("{prefix}{message}"),
2039                },
2040                SourceError::Auth { message } => SourceError::Auth {
2041                    message: format!("source {name}: {message}"),
2042                },
2043                other => other,
2044            },
2045        )?;
2046        Ok(Box::new(source))
2047    }
2048}
2049
2050/// Where a status category lands on this board, once configuration is resolved.
2051#[derive(Debug, Clone, PartialEq, Eq)]
2052enum StatusTarget {
2053    /// Not usable against this instance for this kind, and why.
2054    Disabled(UnmappedStatus),
2055    /// The board's `Status` option of this name.
2056    Column(ColumnName),
2057    /// A closed issue, with both its board option and the reason that says which closed it means.
2058    // 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.
2059    Terminal(ColumnName, ClosedState),
2060}
2061
2062/// Every status category, in the order the vocabulary declares them.
2063///
2064/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2065/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2066/// added to the shared vocabulary fails to compile until it is named there, and this
2067/// crate's suite reconciles this list against that enum's own derived schema, which is
2068/// generated from the variants rather than written beside them. The schema is what
2069/// catches a list left one short — a list checking only the positions it already holds
2070/// would pass while every mapping indexed by the new position panicked.
2071pub const CATEGORIES: [StatusCategory; 8] = [
2072    StatusCategory::Draft,
2073    StatusCategory::Backlog,
2074    StatusCategory::Todo,
2075    StatusCategory::Queued,
2076    StatusCategory::InProgress,
2077    StatusCategory::Done,
2078    StatusCategory::Cancelled,
2079    StatusCategory::Unknown,
2080];
2081
2082/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2083#[must_use]
2084pub const fn category_position(category: StatusCategory) -> usize {
2085    match category {
2086        StatusCategory::Draft => 0,
2087        StatusCategory::Backlog => 1,
2088        StatusCategory::Todo => 2,
2089        StatusCategory::Queued => 3,
2090        StatusCategory::InProgress => 4,
2091        StatusCategory::Done => 5,
2092        StatusCategory::Cancelled => 6,
2093        StatusCategory::Unknown => 7,
2094    }
2095}
2096
2097/// The spelling a status category is configured and reported under.
2098fn category_name(category: StatusCategory) -> &'static str {
2099    match category {
2100        StatusCategory::Draft => "draft",
2101        StatusCategory::Backlog => "backlog",
2102        StatusCategory::Todo => "todo",
2103        StatusCategory::Queued => "queued",
2104        StatusCategory::InProgress => "in-progress",
2105        StatusCategory::Done => "done",
2106        StatusCategory::Cancelled => "cancelled",
2107        StatusCategory::Unknown => "unknown",
2108    }
2109}
2110
2111/// A shipped default's option name.
2112///
2113/// The literals below are this file's own and non-blank, and they are validated by the
2114/// one constructor a configured name goes through rather than beside it.
2115fn shipped_column(name: &'static str) -> ColumnName {
2116    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2117}
2118
2119/// The shipped default for one category this instance's `status_mapping` does not mention,
2120/// for either kind.
2121fn shipped_default(category: StatusCategory) -> StatusTarget {
2122    match category {
2123        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2124        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2125        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2126        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2127        StatusCategory::Done => {
2128            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2129        }
2130        StatusCategory::Cancelled => {
2131            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2132        }
2133        StatusCategory::Draft | StatusCategory::Unknown => {
2134            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2135        }
2136    }
2137}
2138
2139/// The two kinds a status is written and read for, each with its own half of the mapping.
2140const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2141
2142/// This instance's complete category-to-target mapping for each kind, read in both
2143/// directions.
2144///
2145/// One target per category per kind, held at that category's own [`category_position`], so
2146/// a category missing from the mapping, named twice in it, or filed out of order is a state
2147/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2148/// targets are options of the board's one `Status` field.
2149#[derive(Debug, Clone)]
2150struct BoardStatuses {
2151    tasks: [StatusTarget; CATEGORIES.len()],
2152    projects: [StatusTarget; CATEGORIES.len()],
2153}
2154
2155impl BoardStatuses {
2156    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2157    /// would read back from one option.
2158    ///
2159    /// A category the mapping does not mention keeps its shipped default for both kinds; one
2160    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2161    /// omits unmapped rather than defaulted.
2162    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2163        let resolve_kind =
2164            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2165                // `CATEGORIES[position] == category` for every category — the crate's suite
2166                // asserts it — so mapping the list in order fills each category's own slot.
2167                let mut targets = CATEGORIES.map(shipped_default);
2168                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2169                    if !configured.mentions(category) {
2170                        continue;
2171                    }
2172                    *slot = match configured.name_for(category, kind) {
2173                        Err(why) => StatusTarget::Disabled(why),
2174                        Ok(name) => {
2175                            let option = ColumnName::try_from(name.as_str().to_owned())
2176                                .map_err(|message| SourceError::Config { message })?;
2177                            match category {
2178                                StatusCategory::Done => {
2179                                    StatusTarget::Terminal(option, ClosedState::Completed)
2180                                }
2181                                StatusCategory::Cancelled => {
2182                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2183                                }
2184                                _ => StatusTarget::Column(option),
2185                            }
2186                        }
2187                    };
2188                }
2189                StatusMapping::distinct(
2190                    instance,
2191                    kind,
2192                    CATEGORIES
2193                        .iter()
2194                        .zip(&targets)
2195                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2196                )?;
2197                Ok(targets)
2198            };
2199        Ok(Self {
2200            tasks: resolve_kind(ItemKind::Task)?,
2201            projects: resolve_kind(ItemKind::Project)?,
2202        })
2203    }
2204
2205    /// Every category's target for `kind`, in category order.
2206    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2207        match kind {
2208            ItemKind::Task => &self.tasks,
2209            ItemKind::Project => &self.projects,
2210        }
2211    }
2212
2213    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2214        &self.targets(kind)[category_position(category)]
2215    }
2216
2217    /// The category a board option name reports for `kind`, or `None` when nothing of that
2218    /// kind maps to it.
2219    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2220        CATEGORIES.into_iter().find(|category| {
2221            self.target(kind, *category)
2222                .option()
2223                .is_some_and(|name| name.eq_ignore_ascii_case(option))
2224        })
2225    }
2226
2227    /// Every option name either kind maps a category to, each once ignoring case, in
2228    /// category order with a task's name before a project's — what the guarded setup asks
2229    /// the `Status` field to hold.
2230    fn wanted(&self) -> Vec<String> {
2231        let mut wanted: Vec<String> = Vec::new();
2232        for category in CATEGORIES {
2233            for kind in STATUS_KINDS {
2234                if let Some(name) = self.target(kind, category).option()
2235                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2236                {
2237                    wanted.push(name.to_owned());
2238                }
2239            }
2240        }
2241        wanted
2242    }
2243
2244    /// The status an item of `kind` reports, from the three things a read of it says: its
2245    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2246    ///
2247    /// The closed state decides the category and the `Status` option decides the name, so
2248    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2249    /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2250    /// a duplicate is not finished work, and calling it done is a lie the next copy would
2251    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2252    /// it is read permissively rather than refused — reads are faithful, and refusals
2253    /// belong on writes. An open item's option reads through its own kind's mapping, and an
2254    /// option that mapping does not name reads as `Unknown` under its own name.
2255    ///
2256    /// One function of those three rather than of a response, so a narrow status write can
2257    /// answer what a re-read would report by applying it to the state it has just written.
2258    fn status(
2259        &self,
2260        kind: ItemKind,
2261        option: Option<&str>,
2262        closed: bool,
2263        reason: Option<&str>,
2264    ) -> Status {
2265        if closed {
2266            let category = match reason {
2267                None | Some("COMPLETED") => StatusCategory::Done,
2268                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2269                Some(_) => StatusCategory::Unknown,
2270            };
2271            let fallback = match category {
2272                StatusCategory::Done => "Done",
2273                StatusCategory::Cancelled => "Cancelled",
2274                _ => "Closed",
2275            };
2276            return Status {
2277                category,
2278                name: option.unwrap_or(fallback).to_owned(),
2279            };
2280        }
2281        let name = option.unwrap_or("Open").to_owned();
2282        Status {
2283            category: self
2284                .category_of(kind, &name)
2285                .unwrap_or(StatusCategory::Unknown),
2286            name,
2287        }
2288    }
2289}
2290
2291impl BoardStatuses {
2292    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2293    /// case; a kind lacking none is left out.
2294    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2295        STATUS_KINDS
2296            .into_iter()
2297            .filter_map(|kind| {
2298                let missing: Vec<String> = self
2299                    .targets(kind)
2300                    .iter()
2301                    .filter_map(StatusTarget::option)
2302                    .filter(|wanted| {
2303                        !existing
2304                            .iter()
2305                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2306                    })
2307                    .map(str::to_owned)
2308                    .collect();
2309                (!missing.is_empty()).then_some(KindMissing { kind, missing })
2310            })
2311            .collect()
2312    }
2313}
2314
2315impl StatusTarget {
2316    /// The board option this target selects, or `None` for an unmapped one.
2317    fn option(&self) -> Option<&str> {
2318        match self {
2319            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2320            Self::Disabled(_) => None,
2321        }
2322    }
2323}
2324
2325// 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.
2326/// One repository this source can create an issue in, as `owner/name`.
2327///
2328/// Every `createIssue` this source sends names one of these: the item's own single
2329/// `repositories` entry, else its parent project issue's repository, else the configured
2330/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2331/// that choice and says what it refuses before `createIssue`.
2332// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2333#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2334struct RepositoryTarget {
2335    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2336    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2337}
2338
2339impl RepositoryTarget {
2340    fn parse(value: &str) -> Result<Self, SourceError> {
2341        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2342            message: format!(
2343                "repository must be spelled owner/name; {value:?} names no repository"
2344            ),
2345        })?;
2346        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2347            return Err(SourceError::Config {
2348                message: format!(
2349                    "repository must be spelled owner/name with a GitHub login and one \
2350                     repository name; {value:?} is not"
2351                ),
2352            });
2353        }
2354        Ok(Self {
2355            owner: owner.to_owned(),
2356            name: name.to_owned(),
2357        })
2358    }
2359
2360    /// The one host whose repositories this source creates issues in, spelled once: it is
2361    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2362    const HOST: &str = "github.com";
2363
2364    fn origin(&self) -> String {
2365        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2366    }
2367
2368    /// The repository a normalized origin names, or why it is none this source can create
2369    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2370    fn from_origin(origin: &Repository) -> Result<Self, String> {
2371        let not_here = || {
2372            format!(
2373                "{} is not a {}/owner/name repository",
2374                origin.as_str(),
2375                Self::HOST
2376            )
2377        };
2378        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2379        if host != Self::HOST {
2380            return Err(not_here());
2381        }
2382        Self::parse(rest).map_err(|_| not_here())
2383    }
2384
2385    fn slug(&self) -> String {
2386        format!("{}/{}", self.owner, self.name)
2387    }
2388}
2389
2390/// A source which reads GitHub afresh for every operation.
2391pub struct GitHubProjectsSource {
2392    /// This source's configured name, used both to tell a far end naming this source
2393    /// from one naming a system it knows nothing about, and to name the instance a
2394    /// status refusal is about.
2395    name: SourceName,
2396    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2397    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2398    repository: Option<RepositoryTarget>,
2399    endpoint: Url,
2400    token: SecretString,
2401    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2402    statuses: BoardStatuses,
2403    /// Where each priority lands on this board, or `None` when this instance holds none.
2404    priorities: Option<PriorityMapping>,
2405    client: Client,
2406    /// Every item this source has created since it was built, in the order it created
2407    /// them.
2408    ///
2409    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2410    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2411    /// a copy resolving a dependency on an item it had just created refused it as not
2412    /// found. A board read is completed from this — an item remembered here and absent from
2413    /// the read is added back, because the board really does hold it and only the read is
2414    /// behind.
2415    ///
2416    /// It is not a cache of a user's work: nothing is remembered that this process did not
2417    /// itself just write, it lives and dies with the process, and it is never consulted for
2418    /// an item this source did not create.
2419    created: Mutex<Vec<Resolved>>,
2420    /// Every item that already existed and that this source has written since it was built,
2421    /// as it wrote it.
2422    ///
2423    /// The other half of [`Self::created`], held on the same terms and for the reason a
2424    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2425    /// filter is an index behind a write this process made moments ago, so a query matching
2426    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2427    /// is remembered that this process did not itself just write.
2428    updated: Mutex<Vec<Resolved>>,
2429    /// How fast this source writes, and how long it waits out a refusal.
2430    pacing: Pacing,
2431    /// When the last content-creating mutation finished, or the moment the furthest-out
2432    /// reserved slot releases the next one, whichever is later — so the one after it can be
2433    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2434    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2435    /// what it is measured from.
2436    last_mutation: Mutex<Option<Instant>>,
2437    /// The board as this process last read it, for the length of one command.
2438    ///
2439    /// A copy of a project used to re-read the whole board, paged, before writing each of
2440    /// its items, which is by far the largest part of a copy's request count and none of
2441    /// its work. Nothing else changes this board while a command runs — this source's own
2442    /// writes are the only writer — so one read answers them all.
2443    ///
2444    /// It is not a store of a user's work and it is not the cache the no-persistence
2445    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2446    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2447    /// an item this command created and then depends on resolves whether or not GitHub's
2448    /// own eventually-consistent read has caught up. A write to an item already on the
2449    /// board updates the entry here too, so what this holds is the last read plus this
2450    /// process's own writes rather than a snapshot taken before them.
2451    board_cache: Mutex<Option<Board>>,
2452    /// Every issue this board's own search reported, for the length of one command.
2453    ///
2454    /// The second half of a board read, and cached for the same reason and on the same
2455    /// terms as the first: it lives and dies with the process, nothing is written down, and
2456    /// a write this process makes updates the entry here exactly as it updates the one in
2457    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2458    /// that lists this board's projects and its tasks pays for one search rather than two.
2459    search_cache: Mutex<Option<Vec<Resolved>>>,
2460    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2461    /// the length of one command.
2462    ///
2463    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2464    /// and dies with the process, nothing is written down, a write this process makes
2465    /// updates the entry here as it updates the other two, and every answer is completed
2466    /// with this process's own writes each time it is given. A command that asks the same
2467    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2468    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2469    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2470    search_next: Mutex<BTreeMap<String, Option<String>>>,
2471    /// Records already resolved in this source instance, reused by writes and
2472    /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2473    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2474    /// The board's own id and field definitions as this process last read them on their
2475    /// own, for the length of one command.
2476    ///
2477    /// What a write needs of the board and its item does not say, read once per command
2478    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2479    /// lives and dies with the process and nothing is written down. It holds no item and so
2480    /// can answer no question about one — see [`Self::board_fields`].
2481    fields_cache: Mutex<Option<BoardFields>>,
2482    /// Each destination repository's node id, resolved once per repository
2483    /// rather than per issue created.
2484    ///
2485    /// A repository's node id does not change, and re-reading it for every issue of a copy
2486    /// spent one request per item on an answer this source already had. It is a map rather
2487    /// than one entry because a copy files each item in the repository its own
2488    /// `repositories` field names, so a plan across five repositories asks GitHub five
2489    /// times and not once per item.
2490    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2491    /// What every request this source sends is recorded into.
2492    ///
2493    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2494    /// a request leaves this crate, so nothing has to be switched on for a session to be
2495    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2496    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2497    /// source's reads and writes — adds up one accounting instead of two. See
2498    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2499    ledger: Arc<Accounting>,
2500}
2501
2502/// GitHub's closed single-select color vocabulary.
2503#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2504#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2505pub enum StatusOptionColor {
2506    /// Gray.
2507    Gray,
2508    /// Blue.
2509    Blue,
2510    /// Green.
2511    Green,
2512    /// Yellow.
2513    Yellow,
2514    /// Purple.
2515    Purple,
2516    /// Red.
2517    Red,
2518    /// Orange.
2519    Orange,
2520    /// Pink.
2521    Pink,
2522}
2523
2524/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2525/// applies its additions.
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2527pub enum SetupMode {
2528    /// Read without mutation.
2529    Plan,
2530    /// Apply and verify.
2531    Apply,
2532}
2533
2534/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2535/// against it goes on compiling.
2536pub type StatusOptionsMode = SetupMode;
2537
2538/// The explicit result of the requested operation.
2539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2540#[serde(rename_all = "kebab-case")]
2541pub enum StatusOptionsOutcome {
2542    /// A read-only plan.
2543    Planned,
2544    /// Apply found nothing missing.
2545    Unchanged,
2546    /// Additions were applied and verified.
2547    Applied,
2548}
2549
2550/// A GitHub single-select option's opaque GraphQL node identifier.
2551#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2552#[serde(transparent)]
2553pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2554
2555impl TryFrom<String> for StatusOptionId {
2556    type Error = String;
2557
2558    fn try_from(id: String) -> Result<Self, Self::Error> {
2559        if id.trim().is_empty() {
2560            return Err("a GitHub Status option id cannot be blank".to_owned());
2561        }
2562        Ok(Self(id))
2563    }
2564}
2565
2566/// One existing or proposed option in a guarded Status-field update.
2567#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2568pub struct StatusOption {
2569    /// GitHub's stable id.
2570    pub id: StatusOptionId,
2571    /// The visible option name.
2572    pub name: ColumnName,
2573    /// GitHub's single-select color token.
2574    pub color: StatusOptionColor,
2575    /// The option description, including an empty one.
2576    pub description: String,
2577}
2578
2579/// One board item's Status assignment, retained as recovery data.
2580#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2581pub struct StatusAssignment {
2582    /// The project item id whose assignment this is.
2583    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2584    // carried verbatim as operator recovery data; introducing a semantic type would claim
2585    // validation rules GitHub does not publish and no operation here interprets.
2586    pub item_id: String,
2587    /// The selected option, absent when the item has no status.
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    pub option: Option<AssignedStatusOption>,
2590}
2591
2592/// The inseparable id and name of an assigned option.
2593#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2594pub struct AssignedStatusOption {
2595    /// GitHub's stable id.
2596    pub id: StatusOptionId,
2597    /// The visible name.
2598    pub name: ColumnName,
2599}
2600
2601/// The plan and verified outcome of reconciling configured Status options.
2602#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2603pub struct StatusOptionsReport {
2604    /// The configured source name.
2605    pub source: SourceName,
2606    /// Configured option names absent before the operation.
2607    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2608    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2609    // serialized string here preserves the report's intentionally simple public contract.
2610    pub missing: Vec<String>,
2611    /// What the requested operation did.
2612    pub outcome: StatusOptionsOutcome,
2613    /// The complete option list observed before any mutation.
2614    pub existing: Vec<StatusOption>,
2615}
2616
2617#[derive(Debug, Clone, PartialEq, Eq)]
2618struct StatusSnapshot {
2619    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2620    // passed back as the mutation's project identity; a newtype could enforce no stronger
2621    // invariant because GitHub publishes no grammar for it.
2622    board_id: String,
2623    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2624    // passed back as the mutation's field identity; a newtype could enforce no stronger
2625    // invariant because GitHub publishes no grammar for it.
2626    field_id: String,
2627    options: Vec<StatusOption>,
2628    assignments: Vec<StatusAssignment>,
2629}
2630
2631/// The name of the board field a status is held in.
2632const STATUS_FIELD: &str = "Status";
2633
2634/// Every item's value of each field `report` names, as it stood before the setup wrote
2635/// anything — what a person puts back when the setup is refused part way.
2636fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2637    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2638        .fields
2639        .iter()
2640        .map(|field| (field.field.name(), before.assignments(field.field)))
2641        .collect();
2642    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2643        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2644    })
2645}
2646
2647/// One board field the guarded setup reads and writes — every one it reads, and the only
2648/// ones it writes.
2649#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2650pub enum BoardField {
2651    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2652    Status,
2653    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2654    Priority,
2655}
2656
2657impl BoardField {
2658    /// The field's name on the board.
2659    #[must_use]
2660    pub const fn name(self) -> &'static str {
2661        match self {
2662            Self::Status => STATUS_FIELD,
2663            Self::Priority => PRIORITY_FIELD,
2664        }
2665    }
2666
2667    /// The field a board calls `name`, or `None` for one this setup does not own.
2668    fn named(name: &str) -> Option<Self> {
2669        [Self::Status, Self::Priority]
2670            .into_iter()
2671            .find(|field| field.name() == name)
2672    }
2673}
2674
2675/// What the guarded setup did to one field.
2676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2677#[serde(rename_all = "kebab-case")]
2678pub enum FieldOutcome {
2679    /// A read-only plan.
2680    Planned,
2681    /// Apply found the field there with every configured option.
2682    Unchanged,
2683    /// Missing options were added to the field that was there, and verified.
2684    Applied,
2685    /// The field was not there; it was created holding the configured options, and verified.
2686    Created,
2687}
2688
2689/// One field's plan, or its verified outcome.
2690#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2691pub struct FieldReport {
2692    /// Which field.
2693    pub field: BoardField,
2694    /// Whether the board had the field before the operation.
2695    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2696    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2697    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2698    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2699    // one constructor, and it derives `outcome` from `exists` in one match.
2700    pub exists: bool,
2701    /// Configured option names the field lacked before the operation — every one of them,
2702    /// in the order a new field lists them, when the field was not there at all.
2703    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2704    // mapping name and has therefore already passed its nonblank validation; the serialized
2705    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2706    pub missing: Vec<String>,
2707    /// For the `Status` field, which item kind each missing name is configured for: one
2708    /// entry per kind `status_mapping` names a missing option for, task before project, each
2709    /// listing that kind's missing names in category order. A name both kinds use is in
2710    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2711    /// `Priority`, which only a task holds.
2712    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2713    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2714    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2715    #[schemars(!skip_serializing_if)]
2716    pub kinds: Vec<KindMissing>,
2717    /// What the requested operation did.
2718    pub outcome: FieldOutcome,
2719    /// The field's complete option list observed before any mutation; empty when the field
2720    /// was not there.
2721    pub existing: Vec<StatusOption>,
2722}
2723
2724/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2725#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2726pub struct KindMissing {
2727    /// The kind these names are configured for.
2728    pub kind: ItemKind,
2729    /// The names that kind maps a category to and the field lacked, in category order.
2730    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2731    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2732    // intentionally simple public contract.
2733    pub missing: Vec<String>,
2734}
2735
2736/// The plan and verified outcome of setting up every field a source's configuration names.
2737#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2738pub struct FieldsReport {
2739    /// The configured source name.
2740    pub source: SourceName,
2741    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2742    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2743    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2744    // per field would change a published JSON shape. The states the list could hold and the
2745    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2746    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2747    pub fields: Vec<FieldReport>,
2748}
2749
2750/// Which options one field is configured with, in the order a new field would list them.
2751struct FieldPlan {
2752    field: BoardField,
2753    wanted: Vec<String>,
2754}
2755
2756/// One single-select field as the guarded setup snapshots it.
2757#[derive(Debug, Clone, PartialEq, Eq)]
2758struct SnapshotField {
2759    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2760    // passed back as the mutation's field identity; a newtype could enforce no stronger
2761    // invariant because GitHub publishes no grammar for it.
2762    field_id: String,
2763    options: Vec<StatusOption>,
2764}
2765
2766/// Every single-select field of a board and every item's value of each.
2767#[derive(Debug, Clone, PartialEq, Eq)]
2768struct BoardSnapshot {
2769    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2770    // passed back as the mutation's project identity; a newtype could enforce no stronger
2771    // invariant because GitHub publishes no grammar for it.
2772    board_id: String,
2773    fields: BTreeMap<BoardField, SnapshotField>,
2774    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2775    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2776}
2777
2778impl BoardSnapshot {
2779    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2780    /// carries.
2781    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2782        self.items
2783            .iter()
2784            .map(|(item_id, values)| StatusAssignment {
2785                item_id: item_id.clone(),
2786                option: values.get(&field).cloned(),
2787            })
2788            .collect()
2789    }
2790}
2791
2792impl GitHubProjectsSource {
2793    /// Report missing configured Status options and, when `apply` is true, add them with
2794    /// a whole-list mutation that preserves every existing id and verifies the result.
2795    ///
2796    /// # Errors
2797    ///
2798    /// Refuses a board without a single-select `Status` field. A post-write difference in
2799    /// any pre-existing option id or item assignment is refused with the complete pre-write
2800    /// assignment snapshot in the diagnostic for recovery.
2801    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2802    // successful mutation, both drift refusals, source selection, missing Status, casing,
2803    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2804    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2805    // responses from entering the defensive malformed-response branches below.
2806    pub async fn status_options(
2807        &self,
2808        mode: StatusOptionsMode,
2809    ) -> Result<StatusOptionsReport, SourceError> {
2810        let before = self.status_snapshot().await?;
2811        // A terminal category's option is as configured as an open one's: a terminal
2812        // write validates it before closing and refuses when the board lacks it. Both
2813        // kinds' names are options of the one field, so both are asked for.
2814        let missing = self
2815            .statuses
2816            .wanted()
2817            .into_iter()
2818            .filter(|wanted| {
2819                !before
2820                    .options
2821                    .iter()
2822                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2823            })
2824            .collect::<Vec<_>>();
2825        let report = StatusOptionsReport {
2826            source: self.name.clone(),
2827            missing: missing.clone(),
2828            outcome: match (mode, missing.is_empty()) {
2829                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2830                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2831                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2832            },
2833            existing: before.options.clone(),
2834        };
2835        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2836            return Ok(report);
2837        }
2838        let mut options = before
2839            .options
2840            .iter()
2841            .map(|option| {
2842                json!({
2843                    "id": option.id, "name": option.name, "color": option.color,
2844                    "description": option.description,
2845                })
2846            })
2847            .collect::<Vec<_>>();
2848        options.extend(missing.iter().map(|name| {
2849            json!({
2850                "name": name, "color": "GRAY", "description": ""
2851            })
2852        }));
2853        self.graphql(
2854            graphql::STATUS_OPTIONS_UPDATE,
2855            json!({"input": {
2856                "projectId": before.board_id, "fieldId": before.field_id,
2857                "singleSelectOptions": options,
2858            }}),
2859        )
2860        .await?;
2861        let after = self.status_snapshot().await?;
2862        let options_preserved = before
2863            .options
2864            .iter()
2865            .all(|old| after.options.iter().any(|new| new == old));
2866        let additions_present = missing.iter().all(|wanted| {
2867            after
2868                .options
2869                .iter()
2870                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2871        });
2872        if !options_preserved || !additions_present || after.assignments != before.assignments {
2873            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2874                SourceError::Malformed {
2875                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2876                }
2877            })?;
2878            return Err(SourceError::Refused {
2879                message: format!(
2880                    "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}"
2881                ),
2882            });
2883        }
2884        Ok(report)
2885    }
2886
2887    /// A fresh snapshot of the Status field and every board item's assignment of it.
2888    ///
2889    /// # Errors
2890    ///
2891    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2892    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2893        // Status alone, as this operation has always read it: a `Priority` field is another
2894        // operation's, so nothing about it can refuse this one.
2895        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2896        let field = board
2897            .fields
2898            .remove(&BoardField::Status)
2899            .ok_or_else(|| self.no_status_field())?;
2900        Ok(StatusSnapshot {
2901            assignments: board.assignments(BoardField::Status),
2902            board_id: board.board_id,
2903            field_id: field.field_id,
2904            options: field.options,
2905        })
2906    }
2907
2908    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2909    fn no_status_field(&self) -> SourceError {
2910        SourceError::Refused {
2911            message: format!("source {} board has no Status field", self.name),
2912        }
2913    }
2914
2915    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2916    // the real CLI loopback journey, including pagination. The individual malformed guards
2917    // are defensive validation of a schema-pinned third-party response, not separate user
2918    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2919    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2920    /// every board item's value of each, walked to the end of the board's items. A field not
2921    /// in `owned` is read past whatever it holds.
2922    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2923        let mut after: Option<String> = None;
2924        let mut snapshot: Option<BoardSnapshot> = None;
2925        loop {
2926            let data = self
2927                .graphql(
2928                    graphql::STATUS_OPTIONS_SNAPSHOT,
2929                    json!({
2930                        "owner": self.owner, "number": self.project_number,
2931                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2932                    }),
2933                )
2934                .await?;
2935            let board = data
2936                .pointer("/owner/projectV2")
2937                .filter(|board| board.is_object())
2938                .ok_or_else(|| SourceError::Refused {
2939                    message: format!(
2940                        "source {} has no accessible GitHub Projects board",
2941                        self.name
2942                    ),
2943                })?;
2944            if board
2945                .pointer("/fields/pageInfo/hasNextPage")
2946                .and_then(Value::as_bool)
2947                != Some(false)
2948            {
2949                return Err(SourceError::Malformed {
2950                    message:
2951                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2952                            .into(),
2953                });
2954            }
2955            let mut fields = BTreeMap::new();
2956            // Only the fields this setup owns, by name: a node the single-select fragment did not
2957            // match carries no name, and a person's own single-select field — a `Size`, a
2958            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2959            // `Status` or `Priority` field without its options is malformed, not absent.
2960            // 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.
2961            for (owned, field) in board
2962                .pointer("/fields/nodes")
2963                .and_then(Value::as_array)
2964                .ok_or_else(|| SourceError::Malformed {
2965                    message: "GitHub project fields.nodes is not an array".into(),
2966                })?
2967                .iter()
2968                .filter_map(|field| {
2969                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2970                    owned.contains(&named).then_some((named, field))
2971                })
2972            {
2973                let options = field
2974                    .get("options")
2975                    .and_then(Value::as_array)
2976                    .ok_or_else(|| SourceError::Malformed {
2977                        message: "GitHub single-select field options is not an array".into(),
2978                    })?
2979                    .iter()
2980                    .map(|option| {
2981                        Ok(StatusOption {
2982                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2983                                .map_err(|message| SourceError::Malformed { message })?,
2984                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2985                                .map_err(|message| SourceError::Malformed {
2986                                    message: format!(
2987                                        "GitHub single-select option name is invalid: {message}"
2988                                    ),
2989                                })?,
2990                            color: serde_json::from_value(
2991                                option.get("color").cloned().unwrap_or(Value::Null),
2992                            )
2993                            .map_err(|error| {
2994                                SourceError::Malformed {
2995                                    message: format!(
2996                                        "GitHub single-select option color is invalid: {error}"
2997                                    ),
2998                                }
2999                            })?,
3000                            description: optional_str(option, "description")?
3001                                .unwrap_or_default()
3002                                .to_owned(),
3003                        })
3004                    })
3005                    .collect::<Result<Vec<_>, SourceError>>()?;
3006                let snapshot = SnapshotField {
3007                    field_id: required_nonblank_str(field, "id")?.to_owned(),
3008                    options,
3009                };
3010                // A board's field names are unique, so a second one is an answer that cannot
3011                // say which field the setup would act on — refused rather than one chosen.
3012                if fields.insert(owned, snapshot).is_some() {
3013                    return Err(SourceError::Malformed {
3014                        message: format!(
3015                            "GitHub answered two {} fields for this board",
3016                            owned.name()
3017                        ),
3018                    });
3019                }
3020            }
3021            let board_id = required_nonblank_str(board, "id")?.to_owned();
3022            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3023                board_id,
3024                fields,
3025                items: Vec::new(),
3026            });
3027            let items = board
3028                .pointer("/items/nodes")
3029                .and_then(Value::as_array)
3030                .ok_or_else(|| SourceError::Malformed {
3031                    message: "GitHub project items.nodes is not an array".into(),
3032                })?;
3033            for item in items {
3034                let field_values =
3035                    item.get("fieldValues")
3036                        .ok_or_else(|| SourceError::Malformed {
3037                            message: "GitHub project item is missing fieldValues".into(),
3038                        })?;
3039                if field_values
3040                    .pointer("/pageInfo/hasNextPage")
3041                    .and_then(Value::as_bool)
3042                    != Some(false)
3043                {
3044                    return Err(SourceError::Malformed {
3045                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3046                    });
3047                }
3048                let values = item
3049                    .pointer("/fieldValues/nodes")
3050                    .and_then(Value::as_array)
3051                    .ok_or_else(|| SourceError::Malformed {
3052                        message: "GitHub project item fieldValues.nodes is not an array".into(),
3053                    })?;
3054                let item_id = required_nonblank_str(item, "id")?;
3055                let mut assigned = BTreeMap::new();
3056                for value in values {
3057                    let Some(field) = value
3058                        .pointer("/field/name")
3059                        .and_then(Value::as_str)
3060                        .and_then(BoardField::named)
3061                        .filter(|field| owned.contains(field))
3062                    else {
3063                        continue;
3064                    };
3065                    let held = assigned.insert(
3066                        field,
3067                        AssignedStatusOption {
3068                            id: StatusOptionId::try_from(
3069                                required_str(value, "optionId")?.to_owned(),
3070                            )
3071                            .map_err(|message| SourceError::Malformed { message })?,
3072                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3073                                .map_err(|message| SourceError::Malformed {
3074                                    message: format!(
3075                                        "GitHub assigned {} name is invalid: {message}",
3076                                        field.name()
3077                                    ),
3078                                })?,
3079                        },
3080                    );
3081                    // An item holds one value of a field, so a second one leaves no way to
3082                    // tell which it holds — and a verification or recovery built on either
3083                    // could restore the wrong one.
3084                    if held.is_some() {
3085                        return Err(SourceError::Malformed {
3086                            message: format!(
3087                                "GitHub answered two {} values for board item {item_id}",
3088                                field.name()
3089                            ),
3090                        });
3091                    }
3092                }
3093                current.items.push((item_id.to_owned(), assigned));
3094            }
3095            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3096                message: "GitHub project is missing items".into(),
3097            })?;
3098            let has_next = page
3099                .pointer("/pageInfo/hasNextPage")
3100                .and_then(Value::as_bool)
3101                .ok_or_else(|| SourceError::Malformed {
3102                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3103                })?;
3104            if !has_next {
3105                break;
3106            }
3107            let next =
3108                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3109            validate_cursor_progress(after.as_deref(), next)?;
3110            after = Some(next.to_owned());
3111        }
3112        snapshot.ok_or_else(|| SourceError::Malformed {
3113            message: "GitHub returned no board field snapshot".into(),
3114        })
3115    }
3116    // llmlint: ignore-end[changed_behavior_has_e2e]
3117
3118    /// Report every board field this source's configuration names and, with
3119    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3120    /// the `Priority` field when the board has none.
3121    ///
3122    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3123    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3124    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3125    /// color and description: the whole option list goes back with every existing id, because
3126    /// a re-minted id clears every item's value.
3127    ///
3128    /// # Errors
3129    ///
3130    /// Refuses a board without a single-select `Status` field. After an apply the board is
3131    /// read again, and a pre-existing option or any item's value of either field that moved is
3132    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3133    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3134    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3135    // with no Status field and a non-github-projects source through the compiled CLI against
3136    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3137    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3138        let owned: Vec<BoardField> = if self.priorities.is_some() {
3139            vec![BoardField::Status, BoardField::Priority]
3140        } else {
3141            vec![BoardField::Status]
3142        };
3143        let before = self.board_snapshot(&owned).await?;
3144        let mut plans = vec![FieldPlan {
3145            field: BoardField::Status,
3146            wanted: self.statuses.wanted(),
3147        }];
3148        if !before.fields.contains_key(&BoardField::Status) {
3149            return Err(self.no_status_field());
3150        }
3151        if let Some(mapping) = &self.priorities {
3152            plans.push(FieldPlan {
3153                field: BoardField::Priority,
3154                wanted: mapping.names().map(str::to_owned).collect(),
3155            });
3156        }
3157        // The snapshot reads single-select fields alone, so a field it did not find may still
3158        // be on the board under the name, of another type: creating one beside it would fail
3159        // part way, or leave two fields of one name. Asked of the board's own field list, and
3160        // only when a field is missing.
3161        if plans
3162            .iter()
3163            .any(|plan| !before.fields.contains_key(&plan.field))
3164        {
3165            let board = self.board_fields().await?;
3166            for plan in plans
3167                .iter()
3168                .filter(|plan| !before.fields.contains_key(&plan.field))
3169            {
3170                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3171                    return Err(SourceError::Refused {
3172                        message: format!(
3173                            "source {}'s board has a {} field that is not a single-select field \
3174                             (it is a {}), so it cannot hold this source's options; next: rename \
3175                             or remove that field, then run this again",
3176                            self.name,
3177                            plan.field.name(),
3178                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3179                        ),
3180                    });
3181                }
3182            }
3183        }
3184        let mut reports = Vec::new();
3185        for plan in &plans {
3186            let held = before.fields.get(&plan.field);
3187            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3188            let mut missing: Vec<String> = Vec::new();
3189            for wanted in &plan.wanted {
3190                let present = existing
3191                    .iter()
3192                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3193                    || missing
3194                        .iter()
3195                        .any(|named| named.eq_ignore_ascii_case(wanted));
3196                if !present {
3197                    missing.push(wanted.clone());
3198                }
3199            }
3200            let kinds = match plan.field {
3201                BoardField::Status => self.statuses.missing_by_kind(&existing),
3202                BoardField::Priority => Vec::new(),
3203            };
3204            reports.push(FieldReport {
3205                field: plan.field,
3206                exists: held.is_some(),
3207                kinds,
3208                outcome: match (mode, held.is_some(), missing.is_empty()) {
3209                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3210                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3211                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3212                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3213                },
3214                missing,
3215                existing,
3216            });
3217        }
3218        let report = FieldsReport {
3219            source: self.name.clone(),
3220            fields: reports,
3221        };
3222        let writes: Vec<&FieldReport> = report
3223            .fields
3224            .iter()
3225            .filter(|field| !field.missing.is_empty() || !field.exists)
3226            .collect();
3227        if mode == SetupMode::Plan || writes.is_empty() {
3228            return Ok(report);
3229        }
3230        let mut landed: Vec<&str> = Vec::new();
3231        for field in &writes {
3232            let added = field
3233                .missing
3234                .iter()
3235                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3236            let sent = match before.fields.get(&field.field) {
3237                Some(held) => {
3238                    let mut options = held
3239                        .options
3240                        .iter()
3241                        .map(|option| {
3242                            json!({
3243                                "id": option.id, "name": option.name, "color": option.color,
3244                                "description": option.description,
3245                            })
3246                        })
3247                        .collect::<Vec<_>>();
3248                    options.extend(added);
3249                    self.graphql(
3250                        graphql::STATUS_OPTIONS_UPDATE,
3251                        json!({"input": {
3252                            "projectId": before.board_id, "fieldId": held.field_id,
3253                            "singleSelectOptions": options,
3254                        }}),
3255                    )
3256                    .await
3257                }
3258                None => {
3259                    self.graphql(
3260                        graphql::CREATE_FIELD,
3261                        json!({"input": {
3262                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3263                            "name": field.field.name(),
3264                            "singleSelectOptions": added.collect::<Vec<_>>(),
3265                        }}),
3266                    )
3267                    .await
3268                }
3269            };
3270            // A mutation that failed does not establish that GitHub left its field as it was,
3271            // so every failure from here on carries the recovery data a drift refusal does.
3272            match sent {
3273                Ok(_) => landed.push(field.field.name()),
3274                Err(error) => {
3275                    let changed = if landed.is_empty() {
3276                        String::new()
3277                    } else {
3278                        format!("changed the {} field and then ", landed.join(" and "))
3279                    };
3280                    return Err(SourceError::Refused {
3281                        message: format!(
3282                            "the guarded field setup {changed}failed on the {} field, which it may \
3283                             have changed part way: {error}; the pre-write item assignments \
3284                             are:\n{}",
3285                            field.field.name(),
3286                            recovery(&report, &before)?
3287                        ),
3288                    });
3289                }
3290            }
3291        }
3292        // The board has been written, so a verification read that fails leaves it unverified
3293        // rather than unchanged, and says what to put back.
3294        let after = match self.board_snapshot(&owned).await {
3295            Ok(after) => after,
3296            Err(error) => {
3297                return Err(SourceError::Refused {
3298                    message: format!(
3299                        "the guarded field setup changed the {} field and then could not read the \
3300                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3301                        landed.join(" and "),
3302                        recovery(&report, &before)?
3303                    ),
3304                });
3305            }
3306        };
3307        let mut moved = Vec::new();
3308        for field in &report.fields {
3309            let name = field.field.name();
3310            let now = after
3311                .fields
3312                .get(&field.field)
3313                .map(|held| held.options.as_slice())
3314                .unwrap_or_default();
3315            if !field.existing.iter().all(|old| now.contains(old)) {
3316                moved.push(format!(
3317                    "a pre-existing {name} option id, name, color or description"
3318                ));
3319            }
3320            if !field.missing.iter().all(|wanted| {
3321                now.iter()
3322                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3323            }) {
3324                moved.push(format!("an added {name} option"));
3325            }
3326            if after.assignments(field.field) != before.assignments(field.field) {
3327                moved.push(format!("an item's {name} value"));
3328            }
3329        }
3330        if !moved.is_empty() {
3331            return Err(SourceError::Refused {
3332                message: format!(
3333                    "GitHub changed {} after the guarded field setup; the pre-write item \
3334                     assignments are:\n{}",
3335                    moved.join(", "),
3336                    recovery(&report, &before)?
3337                ),
3338            });
3339        }
3340        Ok(report)
3341    }
3342
3343    /// Validate configuration and capture the named credential without exposing it.
3344    ///
3345    /// # Errors
3346    ///
3347    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3348    /// [`SourceError::Auth`] when the named credential is missing or empty.
3349    pub fn new(
3350        name: &SourceName,
3351        config: GitHubProjectsConfig,
3352        secrets: &dyn SecretResolver,
3353    ) -> Result<Self, SourceError> {
3354        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3355    }
3356
3357    /// The same, recording every request it sends into an accounting the caller holds too.
3358    ///
3359    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3360    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3361    /// up — passes the one it records those into, so the session total accounts for the
3362    /// whole session rather than for this source's share of it.
3363    ///
3364    /// # Errors
3365    ///
3366    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3367    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3368    pub fn recording_into(
3369        name: &SourceName,
3370        config: GitHubProjectsConfig,
3371        secrets: &dyn SecretResolver,
3372        ledger: Arc<Accounting>,
3373    ) -> Result<Self, SourceError> {
3374        if !valid_github_owner(&config.owner) {
3375            return Err(SourceError::Config {
3376                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3377            });
3378        }
3379        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3380            return Err(SourceError::Config {
3381                message: format!("project_number must be between 1 and {}", i32::MAX),
3382            });
3383        }
3384        if !valid_environment_name(&config.token_env) {
3385            return Err(SourceError::Config {
3386                message: "token_env must be a valid environment-variable name".into(),
3387            });
3388        }
3389        let repository = config
3390            .repository
3391            .as_deref()
3392            .map(RepositoryTarget::parse)
3393            .transpose()?;
3394        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3395            message: format!("endpoint is not a valid URL: {e}"),
3396        })?;
3397        if endpoint.scheme() != "https"
3398            && !(endpoint.scheme() == "http"
3399                && endpoint
3400                    .host_str()
3401                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3402        {
3403            return Err(SourceError::Config {
3404                message:
3405                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3406                        .into(),
3407            });
3408        }
3409        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3410            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),
3411        })?;
3412        Ok(Self {
3413            name: name.clone(),
3414            owner: config.owner,
3415            project_number: config.project_number,
3416            repository,
3417            endpoint,
3418            token,
3419            credential_name: config.token_env,
3420            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3421            priorities: config
3422                .priority_mapping
3423                .map(|mapping| PriorityMapping::resolve(mapping, name))
3424                .transpose()?,
3425            client: Client::builder()
3426                .user_agent("onetaskgraph")
3427                .build()
3428                .map_err(|e| SourceError::Config {
3429                    message: format!("cannot build HTTP client: {e}"),
3430                })?,
3431            created: Mutex::new(Vec::new()),
3432            updated: Mutex::new(Vec::new()),
3433            pacing: Pacing::resolve(config.pacing, name)?,
3434            last_mutation: Mutex::new(None),
3435            board_cache: Mutex::new(None),
3436            search_cache: Mutex::new(None),
3437            narrowed_cache: Mutex::new(BTreeMap::new()),
3438            resolved_cache: Mutex::new(BTreeMap::new()),
3439            search_next: Mutex::new(BTreeMap::new()),
3440            fields_cache: Mutex::new(None),
3441            repository_cache: Mutex::new(BTreeMap::new()),
3442            ledger,
3443        })
3444    }
3445
3446    /// A snapshot of every request this source has sent, and what each cost.
3447    ///
3448    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3449    /// of them can sit side by side. When this source was built with
3450    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3451    /// point of building it that way.
3452    #[must_use]
3453    pub fn accounting(&self) -> accounting::Session {
3454        self.ledger.snapshot()
3455    }
3456
3457    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3458    /// rate limit rather than handing it straight back as an error.
3459    ///
3460    /// Retrying is safe for every document here, including the mutations, and the reason
3461    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3462    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3463    /// this replays has already taken effect. An outcome this source cannot know — the
3464    /// send failed, or the body could not be read, so the mutation may well have landed —
3465    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3466    /// attempt. A duplicate write would come from replaying one of those, and none is
3467    /// replayed.
3468    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3469        if is_mutation(query)
3470            && ![
3471                graphql::ADD_COMMENT,
3472                graphql::UPDATE_COMMENT,
3473                graphql::DELETE_COMMENT,
3474            ]
3475            .contains(&query)
3476        {
3477            let mut cache = self.resolved_cache()?;
3478            for argument in ["input", "second", "third", "clear"] {
3479                if let Some(input) = variables.get(argument) {
3480                    cache.retain(|id, item| {
3481                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3482                            input
3483                                .get(key)
3484                                .and_then(Value::as_str)
3485                                .is_some_and(|value| value == id.0 || value == item.item_id)
3486                        })
3487                    });
3488                }
3489            }
3490        }
3491        let doing = operation_description(query);
3492        let mut waited = Duration::ZERO;
3493        let mut waits = 0_u32;
3494        let mut backoff = self.pacing.retry_backoff;
3495        loop {
3496            if is_mutation(query) {
3497                let spacing = self.reserve_mutation_slot();
3498                if !spacing.is_zero() {
3499                    tokio::time::sleep(spacing).await;
3500                }
3501            }
3502            let attempt = self.send_once(query, &variables).await;
3503            if is_mutation(query) {
3504                self.finish_mutation();
3505            }
3506            let limited = match attempt {
3507                Ok(data) => return Ok(data),
3508                Err(Attempt::Failed(error)) => return Err(error),
3509                Err(Attempt::Limited(limited)) => limited,
3510            };
3511            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3512            // move that extends a secondary limit, so a hint below the schedule's own next
3513            // wait is raised to it.
3514            let wait = match limited.hint {
3515                Some(hint) => Duration::from_secs(hint).max(backoff),
3516                None => backoff,
3517            };
3518            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3519            // A wait of nothing spends none of the budget, so it is exhaustion rather
3520            // than a retry. `Pacing::resolve` rules out every way of configuring one
3521            // except a budget of zero, where reporting the first refusal is the ask.
3522            if wait.is_zero() || wait > remaining {
3523                return Err(limited.exhausted(
3524                    doing,
3525                    waits,
3526                    waited,
3527                    wait,
3528                    self.pacing.retry_budget,
3529                ));
3530            }
3531            tokio::time::sleep(wait).await;
3532            waited += wait;
3533            waits += 1;
3534            backoff = backoff.saturating_mul(2);
3535        }
3536    }
3537
3538    /// The next moment a content-creating mutation may leave this source, as a wait from
3539    /// now.
3540    ///
3541    /// The slot is reserved under the lock and the waiting happens outside it, so two
3542    /// callers take two slots rather than the same one — and no lock is held across an
3543    /// await.
3544    ///
3545    /// The moment it is spaced from is the previous mutation's *completion*, which
3546    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3547    /// own is the wrong thing to measure from.
3548    fn reserve_mutation_slot(&self) -> Duration {
3549        if self.pacing.min_mutation_interval.is_zero() {
3550            return Duration::ZERO;
3551        }
3552        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3553        // it would turn an earlier failure into a second one for no gain.
3554        let mut last = self
3555            .last_mutation
3556            .lock()
3557            .unwrap_or_else(std::sync::PoisonError::into_inner);
3558        let now = Instant::now();
3559        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3560        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3561        let at = last.map_or(now, |previous| {
3562            previous
3563                .checked_add(self.pacing.min_mutation_interval)
3564                .map_or(now, |earliest| earliest.max(now))
3565        });
3566        *last = Some(at);
3567        at.saturating_duration_since(now)
3568    }
3569
3570    /// Record that a content-creating mutation has finished, so the next one is spaced
3571    /// from here rather than from the moment this one was released.
3572    ///
3573    /// This source can only choose when a request *departs*; the limiter counts when it
3574    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3575    /// departure from the last therefore hands the limiter a gap of the interval less that
3576    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3577    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3578    /// slower machine while passing on a quick one.
3579    ///
3580    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3581    /// previous request had already arrived before its response came back, so its arrival
3582    /// is no later than this moment, and the next mutation is released at least the
3583    /// interval after this moment and arrives no earlier than it is released: the gap the
3584    /// limiter measures is therefore at least the interval, whatever transit costs and on
3585    /// whatever platform. The price is that a mutation's own round trip no longer counts
3586    /// towards its spacing, which makes this source slightly slower than the configured
3587    /// rate rather than slightly faster — the safe side of a limit that punishes being
3588    /// wrong by refusing reads for the next fifty minutes.
3589    ///
3590    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3591    /// and one that never left costs only a wait nobody needed.
3592    fn finish_mutation(&self) {
3593        if self.pacing.min_mutation_interval.is_zero() {
3594            return;
3595        }
3596        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3597        let mut last = self
3598            .last_mutation
3599            .lock()
3600            .unwrap_or_else(std::sync::PoisonError::into_inner);
3601        let now = Instant::now();
3602        // `max` rather than an assignment: a concurrent caller may already have reserved a
3603        // slot further out, and completing this request must never pull that slot back in.
3604        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3605    }
3606
3607    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3608    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3609    ///
3610    /// This is the one place a request leaves this crate, which is why the accounting is
3611    /// here rather than at each of the callers: a read path added later is counted without
3612    /// anybody remembering to count it, and
3613    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3614    /// when one is not.
3615    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3616        let Attempted {
3617            result,
3618            limits,
3619            reported_cost,
3620        } = self.attempt(query, variables).await;
3621        // No `otherwise` name: every document this source sends is one of its own, and the
3622        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3623        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3624        let outcome = match &result {
3625            Ok(_) => accounting::Outcome::Answered,
3626            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3627            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3628        };
3629        self.ledger.record(sending.finished(outcome, limits));
3630        result
3631    }
3632
3633    /// The attempt itself, with what its response said about the rate limit alongside.
3634    ///
3635    /// The two are returned together rather than recorded here because every one of the
3636    /// early exits below is a different outcome, and a record written at each of them is a
3637    /// record one of them can be added without.
3638    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3639        let mut limits = accounting::RateLimit::default();
3640        let mut reported_cost = None;
3641        let result = self
3642            .attempted(query, variables, &mut limits, &mut reported_cost)
3643            .await;
3644        Attempted {
3645            result,
3646            limits,
3647            reported_cost,
3648        }
3649    }
3650
3651    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3652    async fn attempted(
3653        &self,
3654        query: &str,
3655        variables: &Value,
3656        limits: &mut accounting::RateLimit,
3657        reported_cost: &mut Option<u64>,
3658    ) -> Result<Value, Attempt> {
3659        let response = self
3660            .client
3661            .post(self.endpoint.clone())
3662            .bearer_auth(self.token.expose_secret())
3663            .json(&json!({"query": query, "variables": variables}))
3664            .send()
3665            .await
3666            .map_err(|e| {
3667                Attempt::Failed(SourceError::Unavailable {
3668                    message: format!("GitHub GraphQL request failed: {e}"),
3669                })
3670            })?;
3671        let status = response.status();
3672        let header = |name: &str| whole_seconds(response.headers().get(name));
3673        *limits = accounting::RateLimit::read(|name| {
3674            response
3675                .headers()
3676                .get(name)
3677                .and_then(|value| value.to_str().ok())
3678                .map(str::to_owned)
3679        });
3680        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3681        // that are not text at all — is "not known to be exhausted". This never makes a
3682        // response a refusal on its own: it says which limiter a refusal is attributed to
3683        // and where its hint comes from, so a value this cannot read costs a hint rather
3684        // than an answer.
3685        let exhausted = response
3686            .headers()
3687            .get("x-ratelimit-remaining")
3688            .and_then(|value| value.to_str().ok())
3689            == Some("0");
3690        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3691        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3692        // which is the same question answered as an absolute time. Nothing else here is a
3693        // hint, and a schedule is what answers a refusal that carries none.
3694        let hint = header("retry-after").or_else(|| {
3695            exhausted
3696                .then(|| header("x-ratelimit-reset"))
3697                .flatten()
3698                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3699        });
3700        // Read before it is parsed, because the evidence which tells a secondary rate
3701        // limit from a rejected credential is in the body of a response whose status says
3702        // only "forbidden" — and a non-success response was never parsed at all.
3703        let body = response.text().await.map_err(|e| {
3704            Attempt::Failed(SourceError::Unavailable {
3705                message: format!("GitHub GraphQL response could not be read: {e}"),
3706            })
3707        })?;
3708        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3709            return Err(Attempt::Limited(Limited { limiter, hint }));
3710        }
3711        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3712            return Err(Attempt::Failed(SourceError::Auth {
3713                message: format!(
3714                    "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"
3715                ),
3716            }));
3717        }
3718        if !status.is_success() {
3719            return Err(Attempt::Failed(SourceError::Unavailable {
3720                message: format!("GitHub GraphQL returned HTTP {status}"),
3721            }));
3722        }
3723        // GitHub reports what a call cost only when the document asked it to, and no
3724        // document this source sends does — so this is `None` here and carries the figure
3725        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3726        // pick up is a `dryRun` probe's cost, which is some other document's.
3727        *reported_cost = serde_json::from_str::<Value>(&body)
3728            .ok()
3729            .as_ref()
3730            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3731            .and_then(Value::as_u64);
3732        self.answer(&body).map_err(Attempt::Failed)
3733    }
3734
3735    /// What one successful HTTP response says, once its GraphQL errors are read.
3736    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3737        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3738            message: format!("GitHub returned invalid JSON: {e}"),
3739        })?;
3740        let errors = body
3741            .get("errors")
3742            .map(|value| {
3743                value.as_array().ok_or_else(|| SourceError::Malformed {
3744                    message: "GitHub response errors is not an array".into(),
3745                })
3746            })
3747            .transpose()?;
3748        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3749            let messages = errors
3750                .iter()
3751                .filter_map(|e| e.get("message").and_then(Value::as_str))
3752                .collect::<Vec<_>>()
3753                .join("; ");
3754            let message = if messages.is_empty() {
3755                "GitHub returned GraphQL errors".into()
3756            } else {
3757                messages
3758            };
3759            let normalized = message.to_ascii_lowercase();
3760            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3761                return Err(SourceError::Auth {
3762                    message: format!(
3763                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3764                        self.credential_name
3765                    ),
3766                });
3767            }
3768            return Err(SourceError::Refused { message });
3769        }
3770        body.get("data")
3771            .filter(|data| data.is_object())
3772            .cloned()
3773            .ok_or_else(|| SourceError::Malformed {
3774                message: "GitHub response has no data object".into(),
3775            })
3776    }
3777
3778    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3779    // GraphQL cannot independently page them inside the outer item page. This source page is
3780    // deliberately bounded at that published maximum; the live drift journey exercises it.
3781    async fn board_page(
3782        &self,
3783        items_after: Option<&str>,
3784        items_first: u32,
3785    ) -> Result<Value, SourceError> {
3786        let data = self
3787            .graphql(
3788                graphql::BOARD,
3789                json!({"owner":self.owner,"number":self.project_number,
3790                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3791                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3792            )
3793            .await?;
3794        data.pointer("/owner/projectV2")
3795            .filter(|v| !v.is_null())
3796            .cloned()
3797            .ok_or_else(|| SourceError::Refused {
3798                message: format!(
3799                    "GitHub project {}/{} was not found or is not visible to the token",
3800                    self.owner, self.project_number
3801                ),
3802            })
3803    }
3804
3805    /// The search that finds the issues of this board, narrowed by `also` when it is
3806    /// given.
3807    ///
3808    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3809    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3810    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3811    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3812    /// from a task by the `parent` field each issue carries rather than by the search.
3813    fn board_search(&self, also: Option<&str>) -> String {
3814        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3815        match also {
3816            Some(also) => format!("{scope} {also}"),
3817            None => scope,
3818        }
3819    }
3820
3821    /// One issue this source reached directly, as the board item a read of the board would
3822    /// have produced — or `None` when this board does not hold it.
3823    ///
3824    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3825    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3826    /// item's own id, that item's field values, and the issue as its content. One resolver
3827    /// for both routes is what makes an issue read through a search, through its own node
3828    /// id, or through its project's sub-issues report the same title, the same status, the
3829    /// same labels and the same qualified id.
3830    ///
3831    /// An issue with no entry for *this* board is not this source's to report, which is
3832    /// what keeps an id naming some other repository's issue from being answered as an item
3833    /// of this board. That answer is given about an **exhausted** connection and never
3834    /// about an unread page: the entry is looked for on the page in hand, and only if that
3835    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3836    /// rest of it.
3837    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3838        if optional_str(issue, "__typename")? != Some("Issue") {
3839            return Ok(None);
3840        }
3841        let memberships = issue
3842            .get("projectItems")
3843            .ok_or_else(|| SourceError::Malformed {
3844                message: "GitHub issue is missing projectItems".into(),
3845            })?;
3846        let nodes = memberships
3847            .get("nodes")
3848            .and_then(Value::as_array)
3849            .ok_or_else(|| SourceError::Malformed {
3850                message: "GitHub issue projectItems.nodes is not an array".into(),
3851            })?;
3852        let held = match self.board_entry(nodes) {
3853            Some(held) => held.clone(),
3854            None => {
3855                let info = memberships
3856                    .get("pageInfo")
3857                    .ok_or_else(|| SourceError::Malformed {
3858                        message: "GitHub issue projectItems has no pageInfo".into(),
3859                    })?;
3860                // The page held no entry for this board. Whether that means the issue is
3861                // not on it is a question about the rest of the connection, and only a
3862                // connection with no rest answers it here.
3863                if !required_bool(info, "hasNextPage")? {
3864                    return Ok(None);
3865                }
3866                let cursor = required_str(info, "endCursor")?;
3867                validate_cursor_progress(None, cursor)?;
3868                let issue_id = required_str(issue, "id")?;
3869                match self.board_membership(issue_id, cursor).await? {
3870                    Some(held) => held,
3871                    None => return Ok(None),
3872                }
3873            }
3874        };
3875        let item = json!({
3876            "id": required_str(&held, "id")?,
3877            "project": held.get("project"),
3878            "fieldValues": held.get("fieldValues"),
3879            "content": issue,
3880        });
3881        self.resolve(&item)
3882    }
3883
3884    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3885    ///
3886    /// One spelling of *which membership is this board's*, so the page a read carries and
3887    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3888    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3889        nodes.iter().find(|node| {
3890            node.pointer("/project/number").and_then(Value::as_u64)
3891                == Some(u64::from(self.project_number))
3892        })
3893    }
3894
3895    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3896    ///
3897    /// The recovery read: a page of memberships that holds no entry for this board says
3898    /// nothing about the memberships past it, so the connection is walked to exhaustion
3899    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3900    /// positive answer — the whole connection was read and no entry named this board —
3901    /// rather than a failure, and the walk is held to
3902    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3903    /// with a cursor that does not advance is refused instead of spun on.
3904    async fn board_membership(
3905        &self,
3906        issue: &str,
3907        after: &str,
3908    ) -> Result<Option<Value>, SourceError> {
3909        let mut after = after.to_owned();
3910        loop {
3911            let data = self
3912                .graphql(
3913                    graphql::ISSUE_BOARD_ITEMS,
3914                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3915                           "nestedFirst":NESTED_PAGE_SIZE}),
3916                )
3917                .await?;
3918            let Some(connection) = data
3919                .pointer("/node/projectItems")
3920                .filter(|value| !value.is_null())
3921            else {
3922                // The id resolved to nothing, or to something with no memberships to walk —
3923                // which is the same answer as a connection holding no entry for this board.
3924                return Ok(None);
3925            };
3926            let nodes = connection
3927                .get("nodes")
3928                .and_then(Value::as_array)
3929                .ok_or_else(|| SourceError::Malformed {
3930                    message: "GitHub issue projectItems.nodes is not an array".into(),
3931                })?;
3932            if let Some(held) = self.board_entry(nodes) {
3933                return Ok(Some(held.clone()));
3934            }
3935            let info = connection
3936                .get("pageInfo")
3937                .ok_or_else(|| SourceError::Malformed {
3938                    message: "GitHub issue projectItems has no pageInfo".into(),
3939                })?;
3940            let next = required_bool(info, "hasNextPage")?
3941                .then(|| required_str(info, "endCursor"))
3942                .transpose()?;
3943            match next {
3944                Some(next) => {
3945                    validate_cursor_progress(Some(&after), next)?;
3946                    after = next.to_owned();
3947                }
3948                None => return Ok(None),
3949            }
3950        }
3951    }
3952
3953    /// One page of a board-scoped issue search, and where the next page resumes.
3954    async fn search_page(
3955        &self,
3956        search: &str,
3957        first: u32,
3958        after: Option<&str>,
3959    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3960        let data = self
3961            .graphql(
3962                graphql::SEARCH_ISSUES,
3963                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3964                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3965                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3966            )
3967            .await?;
3968        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3969            message: "GitHub search response has no search connection".into(),
3970        })?;
3971        let mut found = Vec::new();
3972        for node in connection
3973            .get("nodes")
3974            .and_then(Value::as_array)
3975            .ok_or_else(|| SourceError::Malformed {
3976                message: "GitHub search nodes is not an array".into(),
3977            })?
3978        {
3979            if let Some(resolved) = self.resolve_issue(node).await? {
3980                found.push(resolved);
3981            }
3982        }
3983        let info = connection
3984            .get("pageInfo")
3985            .ok_or_else(|| SourceError::Malformed {
3986                message: "GitHub search connection has no pageInfo".into(),
3987            })?;
3988        let next = required_bool(info, "hasNextPage")?
3989            .then(|| required_str(info, "endCursor"))
3990            .transpose()?
3991            .map(str::to_owned);
3992        if let Some(next) = &next {
3993            validate_cursor_progress(after, next)?;
3994        }
3995        Ok((found, next))
3996    }
3997
3998    /// Every issue this board holds, completed with what this run wrote.
3999    ///
4000    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4001    /// is an index and is eventually consistent, so an issue this run created seconds ago
4002    /// can be absent from it, and a project listed straight after being written would
4003    /// otherwise be missing from its own board. What is added back is only what this
4004    /// process itself wrote, out of [`Self::created`], which lives and dies with the
4005    /// process.
4006    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4007        let found = self.searched_issues().await?;
4008        self.completed_with_written(found, |_| true)
4009    }
4010
4011    /// Every issue this board's own search reports, walked to exhaustion, read once per
4012    /// source.
4013    ///
4014    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4015    /// needs it too and the two would otherwise walk the same search twice in one command.
4016    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4017    /// is.
4018    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4019        let cached = self.search_cache()?.clone();
4020        if let Some(held) = cached {
4021            return Ok(held);
4022        }
4023        let mut after: Option<String> = None;
4024        let mut found = Vec::new();
4025        let search = self.board_search(None);
4026        loop {
4027            let (page, next) = self
4028                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4029                .await?;
4030            found.extend(page);
4031            match next {
4032                Some(next) => after = Some(next),
4033                None => break,
4034            }
4035        }
4036        *self.search_cache()? = Some(found.clone());
4037        Ok(found)
4038    }
4039
4040    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4041    fn search_cache(
4042        &self,
4043    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4044        self.search_cache
4045            .lock()
4046            .map_err(|_| SourceError::Unavailable {
4047                message: "this source's view of the board's issues was left inconsistent by an \
4048                      earlier failure; next: run the command again"
4049                    .into(),
4050            })
4051    }
4052
4053    /// `found`, with everything this run wrote that `keep` accepts and the read did not
4054    /// report.
4055    ///
4056    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4057    /// at all: the search index is behind, and a node read of an item filed moments ago can
4058    /// be too.
4059    fn completed_with_written(
4060        &self,
4061        mut found: Vec<Resolved>,
4062        keep: impl Fn(&Resolved) -> bool,
4063    ) -> Result<Vec<Resolved>, SourceError> {
4064        for own in self.created()?.iter().filter(|own| keep(own)) {
4065            if !found.iter().any(|item| item.id == own.id) {
4066                found.push(own.clone());
4067            }
4068        }
4069        Ok(found)
4070    }
4071
4072    /// What resolving one node id reached.
4073    ///
4074    /// Three answers rather than an `Option`, because a board *draft* is none of the other
4075    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4076    /// is completed by a read of the draft itself rather than reported as nothing.
4077    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4078        let asked = self
4079            .graphql(
4080                graphql::ISSUE,
4081                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4082                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4083            )
4084            .await;
4085        let data = match asked {
4086            Ok(data) => data,
4087            // A string that is not a node id at all is not a failure to report: it is an id
4088            // this board does not hold, which is what every read of one already answers.
4089            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4090            Err(error) => return Err(error),
4091        };
4092        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4093            return Ok(Reached::Nothing);
4094        };
4095        if optional_str(node, "__typename")? == Some("DraftIssue") {
4096            return Ok(Reached::Draft);
4097        }
4098        Ok(match self.resolve_issue(node).await? {
4099            Some(item) => Reached::Held(Box::new(item)),
4100            None => Reached::Nothing,
4101        })
4102    }
4103
4104    /// One item of this board by its own id, whatever kind it is.
4105    ///
4106    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4107    /// run wrote is read first, because a node read of an item created moments ago can
4108    /// still be behind the board field values written onto it — see [`Self::created`].
4109    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4110        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4111            return Ok(Some(own.clone()));
4112        }
4113        match self.reach(id).await? {
4114            Reached::Held(item) => Ok(Some(*item)),
4115            Reached::Nothing => Ok(None),
4116            Reached::Draft => self.draft_by_id(id).await,
4117        }
4118    }
4119
4120    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4121    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4122    /// than one request per id.
4123    ///
4124    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4125    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4126    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4127    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4128    /// is completed by a read of the draft, exactly as there.
4129    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4130        let mut found: Vec<Option<Option<Resolved>>> = {
4131            let created = self.created()?;
4132            ids.iter()
4133                .map(|id| {
4134                    created
4135                        .iter()
4136                        .find(|own| own.id == *id)
4137                        .map(|own| Some(own.clone()))
4138                })
4139                .collect()
4140        };
4141        let unread: Vec<NativeId> = ids
4142            .iter()
4143            .zip(&found)
4144            .filter(|(_, found)| found.is_none())
4145            .map(|(id, _)| id.clone())
4146            .collect();
4147        let mut read = Vec::with_capacity(unread.len());
4148        if let [one] = unread.as_slice() {
4149            read.push(self.item_by_id(one).await?);
4150        } else {
4151            for batch in unread.chunks(DETAIL_BATCH) {
4152                let data = match self
4153                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4154                    .await
4155                {
4156                    Ok(data) => data,
4157                    Err(error) if unresolvable_node(&error) => {
4158                        for id in batch {
4159                            read.push(self.item_by_id(id).await?);
4160                        }
4161                        continue;
4162                    }
4163                    Err(error) => return Err(error),
4164                };
4165                for (slot, id) in batch.iter().enumerate() {
4166                    let node =
4167                        data.get(format!("i{slot}"))
4168                            .ok_or_else(|| SourceError::Malformed {
4169                                message: format!(
4170                                    "GitHub answered a batch read with no item for {}",
4171                                    id.0
4172                                ),
4173                            })?;
4174                    read.push(if node.is_null() {
4175                        None
4176                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4177                        self.draft_by_id(id).await?
4178                    } else {
4179                        if optional_str(node, "__typename")? == Some("Issue")
4180                            && required_str(node, "id")? != id.0
4181                        {
4182                            return Err(SourceError::Malformed {
4183                                message: format!(
4184                                    "GitHub answered the read of {} with issue {}",
4185                                    id.0,
4186                                    required_str(node, "id")?
4187                                ),
4188                            });
4189                        }
4190                        self.resolve_issue(node).await?
4191                    });
4192                }
4193            }
4194        }
4195        let mut read = read.into_iter();
4196        Ok(found
4197            .iter_mut()
4198            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4199            .collect())
4200    }
4201
4202    fn resolved_cache(
4203        &self,
4204    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4205        self.resolved_cache
4206            .lock()
4207            .map_err(|_| SourceError::Unavailable {
4208                message: "resolved item records were left inconsistent; run the command again"
4209                    .into(),
4210            })
4211    }
4212
4213    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4214    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4215    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4216        let cached = self.resolved_cache()?.get(id).cloned();
4217        match cached {
4218            Some(item) => Ok(Some(item)),
4219            None => self.item_by_id(id).await,
4220        }
4221    }
4222
4223    /// One board draft by its own id, with the board item it sits in — or `None` when no
4224    /// item of this board is that draft's.
4225    ///
4226    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4227    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4228    /// links a draft to one board item, so the page this read carries is the whole of that
4229    /// connection, and a page that reports more than it holds is refused rather than read
4230    /// as an answer about memberships nobody read.
4231    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4232        let data = self
4233            .graphql(
4234                graphql::DRAFT,
4235                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4236                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4237            )
4238            .await?;
4239        // Gone between the two reads is an answer — the draft is no longer there. Anything
4240        // else than the draft [`Self::reach`] was just told this id is, is not one.
4241        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4242            return Ok(None);
4243        };
4244        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4245            return Err(SourceError::Malformed {
4246                message: format!(
4247                    "GitHub answered {} as a draft and then as something else",
4248                    id.0
4249                ),
4250            });
4251        }
4252        if required_str(draft, "id")? != id.0 {
4253            return Err(SourceError::Malformed {
4254                message: format!("GitHub answered a different draft for {}", id.0),
4255            });
4256        }
4257        let memberships = draft
4258            .get("projectV2Items")
4259            .ok_or_else(|| SourceError::Malformed {
4260                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4261            })?;
4262        let nodes = memberships
4263            .get("nodes")
4264            .and_then(Value::as_array)
4265            .ok_or_else(|| SourceError::Malformed {
4266                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4267            })?;
4268        let info = memberships
4269            .get("pageInfo")
4270            .ok_or_else(|| SourceError::Malformed {
4271                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4272            })?;
4273        // Read whether or not this board's entry is on the page: a page claiming more than
4274        // the one item GitHub links a draft to is a malformed answer either way.
4275        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4276            return Err(SourceError::Malformed {
4277                message: format!(
4278                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4279                     to",
4280                    id.0
4281                ),
4282            });
4283        }
4284        if let Some(node) = nodes.first()
4285            && node
4286                .pointer("/project/number")
4287                .and_then(Value::as_u64)
4288                .is_none()
4289        {
4290            return Err(SourceError::Malformed {
4291                message: format!(
4292                    "GitHub draft {} board item has no numeric project number",
4293                    id.0
4294                ),
4295            });
4296        }
4297        let Some(held) = self.board_entry(nodes) else {
4298            return Ok(None);
4299        };
4300        if required_str(
4301            held.get("project").ok_or_else(|| SourceError::Malformed {
4302                message: format!("GitHub draft {} board item has no project", id.0),
4303            })?,
4304            "id",
4305        )? != self.board_fields().await?.id.as_str()
4306        {
4307            return Ok(None);
4308        }
4309        let item = json!({
4310            "id": required_str(held, "id")?,
4311            "project": held.get("project"),
4312            "fieldValues": held.get("fieldValues"),
4313            "content": draft,
4314        });
4315        self.resolve(&item)
4316    }
4317
4318    /// The board's own id and field definitions, for a write whose item does not carry
4319    /// them — never its items.
4320    ///
4321    /// A board this command has already listed supplies them, since it read them beside its
4322    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4323    /// is consulted about which items the board holds: see the module documentation for
4324    /// why a question about one known item is answered by reading that item.
4325    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4326        if let Some(board) = self.board_cache()?.as_ref() {
4327            return Ok(BoardFields {
4328                id: BoardId::parse(&board.id)?,
4329                fields: board.fields.clone(),
4330            });
4331        }
4332        if let Some(held) = self.fields_cache()?.clone() {
4333            return Ok(held);
4334        }
4335        let data = self
4336            .graphql(
4337                graphql::BOARD_FIELDS,
4338                json!({"owner":self.owner,"number":self.project_number,
4339                       "nestedFirst":NESTED_PAGE_SIZE}),
4340            )
4341            .await?;
4342        self.fields_read(&data)
4343    }
4344
4345    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4346    /// the rest of this command.
4347    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4348        let board = data
4349            .pointer("/boardFields/projectV2")
4350            .filter(|value| !value.is_null())
4351            .ok_or_else(|| SourceError::Refused {
4352                message: format!(
4353                    "GitHub project {}/{} was not found or is not visible to the token",
4354                    self.owner, self.project_number
4355                ),
4356            })?;
4357        let read = BoardFields {
4358            id: BoardId::parse(required_str(board, "id")?)?,
4359            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4360        };
4361        *self.fields_cache()? = Some(read.clone());
4362        Ok(read)
4363    }
4364
4365    /// Read what creating an issue in `repository` needs and this command has not read yet —
4366    /// the board's fields and the repository's node id — in one request when it needs both.
4367    ///
4368    /// When either is already known this sends nothing, and the other is read by its own
4369    /// document where it is asked for, so no create reads anything twice.
4370    async fn creation_context(
4371        &self,
4372        repository: &RepositoryTarget,
4373        incoming: &Incoming<'_>,
4374    ) -> Result<(), SourceError> {
4375        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4376        if fields_known || self.repository_cache()?.contains_key(repository) {
4377            return Ok(());
4378        }
4379        let data = self
4380            .graphql(
4381                graphql::CREATION_CONTEXT,
4382                json!({"owner":self.owner,"number":self.project_number,
4383                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4384                       "repositoryName":repository.name}),
4385            )
4386            .await?;
4387        self.fields_read(&data)?;
4388        self.repository_read(&data, repository, incoming)?;
4389        Ok(())
4390    }
4391
4392    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4393    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4394        self.fields_cache
4395            .lock()
4396            .map_err(|_| SourceError::Unavailable {
4397                message: "this source's view of the board's fields was left inconsistent by an \
4398                      earlier failure; next: run the command again"
4399                    .into(),
4400            })
4401    }
4402
4403    /// What a write to `item` needs of the board, read off that item when it says enough and
4404    /// off [`Self::board_fields`] when it does not.
4405    ///
4406    /// A node read of an item names its board and carries the definition of every field it
4407    /// holds a value of — so an item naming its board, holding a value of the origin field,
4408    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4409    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4410    /// of may still be on the board, and a view reading it as absent would refuse a write the
4411    /// board can take or skip a field write the board needs, so such an item — and a create,
4412    /// which has no item yet — takes the board's fields from their own read instead.
4413    async fn fields_for(
4414        &self,
4415        item: Option<&Resolved>,
4416        writes_status: bool,
4417        selects_priority: bool,
4418    ) -> Result<BoardFields, SourceError> {
4419        if let Some(board) = item.and_then(Resolved::carried_board) {
4420            return Ok(board);
4421        }
4422        if let Some(item) = item
4423            && let Some(board_id) = item.named_board()
4424            && item.defines(ORIGIN_FIELD)
4425            && (!writes_status || item.defines("Status"))
4426            && (!selects_priority || item.defines(PRIORITY_FIELD))
4427        {
4428            return Ok(BoardFields {
4429                id: board_id,
4430                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4431            });
4432        }
4433        self.board_fields().await
4434    }
4435
4436    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4437    /// when that id names nothing here with a sub-issue relationship to walk.
4438    ///
4439    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4440    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4441    /// empty vector is a project that holds nothing.
4442    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4443        let mut after: Option<String> = None;
4444        let mut children = Vec::new();
4445        loop {
4446            let asked = self
4447                .graphql(
4448                    graphql::SUB_ISSUES,
4449                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4450                           "nestedFirst":NESTED_PAGE_SIZE,
4451                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4452                )
4453                .await;
4454            let data = match asked {
4455                Ok(data) => data,
4456                // A string that is not a node id at all is not a failure to report: it is
4457                // the ordinary answer to a selector naming a project by its name.
4458                Err(error) if unresolvable_node(&error) => return Ok(None),
4459                Err(error) => return Err(error),
4460            };
4461            let Some(connection) = data
4462                .pointer("/node/subIssues")
4463                .filter(|value| !value.is_null())
4464            else {
4465                // No such node, or one with no sub-issue relationship — a board draft is
4466                // the one this board can really hold.
4467                return Ok(None);
4468            };
4469            for node in connection
4470                .get("nodes")
4471                .and_then(Value::as_array)
4472                .ok_or_else(|| SourceError::Malformed {
4473                    message: "GitHub subIssues.nodes is not an array".into(),
4474                })?
4475            {
4476                if let Some(resolved) = self.resolve_issue(node).await? {
4477                    children.push(resolved);
4478                }
4479            }
4480            let info = connection
4481                .get("pageInfo")
4482                .ok_or_else(|| SourceError::Malformed {
4483                    message: "GitHub subIssues connection has no pageInfo".into(),
4484                })?;
4485            let next = required_bool(info, "hasNextPage")?
4486                .then(|| required_str(info, "endCursor"))
4487                .transpose()?;
4488            match next {
4489                Some(next) => {
4490                    validate_cursor_progress(after.as_deref(), next)?;
4491                    after = Some(next.to_owned());
4492                }
4493                None => return Ok(Some(children)),
4494            }
4495        }
4496    }
4497
4498    /// Which issue of this board a project *name* is, or `None` when none is.
4499    ///
4500    /// One bounded query which filters on that name at the server, rather than a walk of
4501    /// every issue the board holds. The name is compared again here: the qualifier narrows
4502    /// what GitHub sends, and this source decides what it names.
4503    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4504        let search = self.board_search(Some(&title_qualifier(name)));
4505        let mut after = None;
4506        loop {
4507            let (candidates, next) = self
4508                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4509                .await?;
4510            if let Some(item) = candidates.into_iter().find(|item| {
4511                item.kind == BoardKind::Work(ItemKind::Project)
4512                    && item.title.eq_ignore_ascii_case(name)
4513            }) {
4514                return Ok(Some(item.id));
4515            }
4516            match next {
4517                Some(next) => after = Some(next),
4518                None => return Ok(None),
4519            }
4520        }
4521    }
4522
4523    /// Everything filed under one project of this board: the sub-issues of the issue that
4524    /// project is.
4525    ///
4526    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4527    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4528    /// gains projects, or as another project gains tasks.
4529    ///
4530    /// A qualified id names the issue and is asked for its sub-issues directly: one
4531    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4532    /// read as a project *name*, which costs the one bounded search
4533    /// [`Self::project_by_name`] makes.
4534    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4535        let (project, children) = match self.sub_issues(selector).await? {
4536            Some(children) => (selector.clone(), children),
4537            None => match self.project_by_name(&selector.0).await? {
4538                Some(project) => {
4539                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4540                    (project, children)
4541                }
4542                None => return Ok(Vec::new()),
4543            },
4544        };
4545        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4546    }
4547
4548    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4549    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4550    ///
4551    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4552    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4553    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4554    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4555    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4556    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4557    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4558    /// rather than silently narrowing a caller's answer.
4559    ///
4560    /// The instant is written to the second, rounded down, which can only widen what the
4561    /// search returns; confirmation against each candidate's own comments is what makes the
4562    /// answer exact. The search is an index that lags a write by a second or two — the module
4563    /// documentation records it — so a caller that asks again from its last instant should
4564    /// overlap the two by more than that.
4565    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4566        let found = self.searched(&updated_qualifier(since)).await?;
4567        self.completed_with_written(found, |_| true)
4568    }
4569
4570    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4571    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4572    ///
4573    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4574    /// own record is the fresher of the two.
4575    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4576        let search = self.board_search(Some(also));
4577        let mut after: Option<String> = None;
4578        let mut found = Vec::new();
4579        loop {
4580            let (page, next) = self
4581                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4582                .await?;
4583            found.extend(page);
4584            match next {
4585                Some(next) => after = Some(next),
4586                None => return Ok(found),
4587            }
4588        }
4589    }
4590
4591    /// A bounded task answer; the versioned cursor carries the connection position, how
4592    /// many rows of the page starting there were already handed out, and the own-write ids
4593    /// already observed, including across a new source instance.
4594    ///
4595    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4596    /// sliced from the pages it needs; why is the module documentation's paging contract.
4597    async fn search_tasks(
4598        &self,
4599        query: &TaskQuery,
4600        page: &PageRequest,
4601        also: &str,
4602    ) -> Result<Page<Task>, SourceError> {
4603        let mut position = match &page.cursor {
4604            None => SearchPosition::default(),
4605            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4606                .ok()
4607                .filter(|position| {
4608                    position.version == SEARCH_CURSOR_VERSION
4609                        && position.connection.valid_resume(position.offset)
4610                })
4611                .ok_or_else(|| SourceError::Config {
4612                    message: "page cursor is invalid".into(),
4613                })?,
4614        };
4615        let search = self.board_search(Some(also));
4616        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4617        let own = self.with_own_writes(Vec::new())?;
4618        for item in &own {
4619            if !position.own.contains(&item.id) {
4620                position.own.push(item.id.clone());
4621            }
4622        }
4623        let mut tasks = Vec::new();
4624        while !position.connection.exhausted() && tasks.len() < limit {
4625            let first = SEARCH_PAGE_SIZE;
4626            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4627            let key =
4628                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4629                    .expect("search page key is serializable");
4630            let cached = if query.commented_since.is_none() {
4631                self.narrowed_cache()?.get(&key).cloned()
4632            } else {
4633                None
4634            };
4635            let (found, next) = match cached {
4636                Some(found) => {
4637                    let next = self
4638                        .search_next
4639                        .lock()
4640                        .map_err(|_| SourceError::Unavailable {
4641                            message:
4642                                "search pagination was left inconsistent; run the command again"
4643                                    .into(),
4644                        })?
4645                        .get(&key)
4646                        .cloned()
4647                        .flatten();
4648                    (found, next)
4649                }
4650                None => {
4651                    let (found, next) = self
4652                        .search_page(&search, first, position.connection.after())
4653                        .await?;
4654                    if query.commented_since.is_none() {
4655                        self.search_next
4656                            .lock()
4657                            .map_err(|_| SourceError::Unavailable {
4658                                message:
4659                                    "search pagination was left inconsistent; run the command again"
4660                                        .into(),
4661                            })?
4662                            .insert(key.clone(), next.clone());
4663                        self.narrowed_cache()?.insert(key, found.clone());
4664                    }
4665                    (found, next)
4666                }
4667            };
4668            let rows = found.len();
4669            for mut item in found.into_iter().skip(position.offset) {
4670                if tasks.len() == limit {
4671                    break;
4672                }
4673                position.offset += 1;
4674                if position.own.contains(&item.id) {
4675                    if position.seen.contains(&item.id) {
4676                        continue;
4677                    }
4678                    position.seen.push(item.id.clone());
4679                    let updated_at = item.updated_at;
4680                    let Some(written) = self.search_written(&own, &item.id).await? else {
4681                        continue;
4682                    };
4683                    item = written;
4684                    item.updated_at = item.updated_at.max(updated_at);
4685                    self.resolved_cache()?.insert(item.id.clone(), item.clone());
4686                }
4687                if item.kind == BoardKind::Work(ItemKind::Task) {
4688                    let task = item.task()?;
4689                    if task_matches(&task, query, &query.project)
4690                        && self.commented_since(&item, query.commented_since).await?
4691                    {
4692                        tasks.push(task);
4693                    }
4694                }
4695            }
4696            if position.offset < rows {
4697                continue;
4698            }
4699            position.offset = 0;
4700            position.connection = match next {
4701                Some(after) => SearchConnection::Continuing {
4702                    after: Cursor(after),
4703                },
4704                None => SearchConnection::Exhausted {},
4705            };
4706        }
4707        if position.connection.exhausted() {
4708            for id in position.own.clone() {
4709                if position.seen.contains(&id) {
4710                    continue;
4711                }
4712                if tasks.len() == limit {
4713                    break;
4714                }
4715                position.seen.push(id.clone());
4716                let Some(item) = self.search_written(&own, &id).await? else {
4717                    continue;
4718                };
4719                if item.kind == BoardKind::Work(ItemKind::Task) {
4720                    let task = item.task()?;
4721                    if task_matches(&task, query, &query.project)
4722                        && self.commented_since(&item, query.commented_since).await?
4723                    {
4724                        tasks.push(task);
4725                    }
4726                }
4727            }
4728        }
4729        let more = !position.connection.exhausted()
4730            || position.own.iter().any(|id| !position.seen.contains(id));
4731        Ok(Page {
4732            items: tasks,
4733            next: more.then(|| {
4734                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4735            }),
4736        })
4737    }
4738
4739    /// A resumed process has the ids but no write records; resolve only a record the
4740    /// current page needs, by its uncached node read rather than the lagging search index.
4741    async fn search_written(
4742        &self,
4743        own: &[Resolved],
4744        id: &NativeId,
4745    ) -> Result<Option<Resolved>, SourceError> {
4746        match own.iter().find(|item| item.id == *id) {
4747            Some(item) => Ok(Some(item.clone())),
4748            None => self.item_by_id(id).await,
4749        }
4750    }
4751
4752    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4753    /// without enumerating the board — or `None` for a query carrying none of the three, which
4754    /// keeps the reads it always had.
4755    ///
4756    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4757    /// because it names at most a handful of items. Text and metadata are answered by one
4758    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4759    /// further by `updated:>=` when the query also asks for comment activity, since both
4760    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4761    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4762    ///
4763    /// Completed with what this process wrote, its own record winning over the index's copy
4764    /// of the same item: see [`Self::with_own_writes`].
4765    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4766        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4767            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4768            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4769                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4770                None => qualifiers,
4771            }),
4772            (None, None) => return Ok(None),
4773        };
4774        // A question about comment activity is asked afresh every time, as it always was: it
4775        // is the one a caller polls from one source while waiting for the index, and an
4776        // answer held from the first poll would be the answer to every later one.
4777        let key = query.commented_since.is_none().then(|| asked.key());
4778        let cached = match &key {
4779            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4780            None => None,
4781        };
4782        let found = match cached {
4783            Some(found) => found,
4784            None => {
4785                let found = match &asked {
4786                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4787                    Narrowing::Search(also) => self.searched(also).await?,
4788                };
4789                if let Some(key) = key {
4790                    self.narrowed_cache()?.insert(key, found.clone());
4791                }
4792                found
4793            }
4794        };
4795        self.with_own_writes(found).map(Some)
4796    }
4797
4798    /// The candidates for a project or unscoped document query carrying a searchable text,
4799    /// read without enumerating the board — or `None` for a query with no text or a blank one,
4800    /// which keeps the read it always had.
4801    ///
4802    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4803    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4804    /// costs is the issues that match and never the board. Its answer is held for the command
4805    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4806    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4807    /// substring rule, exactly as an item of the wider read was, and is completed with what this
4808    /// process wrote: see [`Self::with_own_writes`].
4809    async fn text_searched(
4810        &self,
4811        text: Option<&TextQuery>,
4812    ) -> Result<Option<Vec<Resolved>>, SourceError> {
4813        let Some(also) = text_qualifiers(text) else {
4814            return Ok(None);
4815        };
4816        let key = Narrowing::Search(also.clone()).key();
4817        let cached = self.narrowed_cache()?.get(&key).cloned();
4818        let found = match cached {
4819            Some(found) => found,
4820            None => {
4821                let found = self.searched(&also).await?;
4822                self.narrowed_cache()?.insert(key, found.clone());
4823                found
4824            }
4825        };
4826        self.with_own_writes(found).map(Some)
4827    }
4828
4829    /// Every item of this board that may carry `origin` — a superset of those that do — found
4830    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4831    ///
4832    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4833    /// which reads the field every carrier holds, whichever release wrote it — and the
4834    /// board-scoped issue search for the same id as a phrase in the body, where this source
4835    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4836    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4837    /// query's, exactly.
4838    ///
4839    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4840    /// already ended is sent its last cursor again, which answers an empty page, so the one
4841    /// document serves every page of either. What the two leave is stated in the module
4842    /// documentation: a carrier another process added within the last second or two, before
4843    /// either index has it.
4844    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4845        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4846        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4847        let mut items_after: Option<String> = None;
4848        let mut search_after: Option<String> = None;
4849        let mut found: Vec<Resolved> = Vec::new();
4850        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4851            if !found.iter().any(|held| held.id == resolved.id) {
4852                found.push(resolved);
4853            }
4854        };
4855        loop {
4856            let data = self
4857                .graphql(
4858                    graphql::ORIGIN_LOOKUP,
4859                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4860                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4861                           "itemsAfter":items_after,"searchAfter":search_after,
4862                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4863                           "duplicates":true}),
4864                )
4865                .await?;
4866            let items = data
4867                .pointer("/originItems/projectV2/items")
4868                .filter(|value| !value.is_null())
4869                .ok_or_else(|| SourceError::Refused {
4870                    message: format!(
4871                        "GitHub project {}/{} was not found or is not visible to the token",
4872                        self.owner, self.project_number
4873                    ),
4874                })?;
4875            for item in optional_nodes(Some(items), "project items")?
4876                .into_iter()
4877                .flatten()
4878            {
4879                // The board's own items list its drafts too, and a draft is not an issue: no
4880                // narrowed read answers with one, whatever its origin field holds.
4881                if let Some(resolved) = self.resolve(item)?
4882                    && resolved.content_kind == ContentKind::Issue
4883                {
4884                    keep(resolved, &mut found);
4885                }
4886            }
4887            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4888                message: "GitHub search response has no search connection".into(),
4889            })?;
4890            for node in optional_nodes(Some(searched), "search")?
4891                .into_iter()
4892                .flatten()
4893            {
4894                if let Some(resolved) = self.resolve_issue(node).await? {
4895                    keep(resolved, &mut found);
4896                }
4897            }
4898            let items_next = resumed(items, items_after.as_deref())?;
4899            let search_next = resumed(searched, search_after.as_deref())?;
4900            if !items_next.has_more() && !search_next.has_more() {
4901                return Ok(found);
4902            }
4903            items_after = items_next.cursor();
4904            search_after = search_next.cursor();
4905        }
4906    }
4907
4908    /// `found`, with every item this process created or wrote in its place, and every one of
4909    /// them the read did not report added.
4910    ///
4911    /// This process's own record wins over the read's copy of the same item, because a read
4912    /// of an item written moments ago can still be behind what was written onto it — the
4913    /// origin field included, which is the one a narrowed read is confirmed against — and a
4914    /// read that still names an item under a predicate this process's write moved it out of
4915    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4916    /// last saw the item change, which is what a comment-activity read rules a candidate out
4917    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4918    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4919    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4920        // A board draft is not an issue, so no narrowed read returns one, and this process
4921        // having written one does not make it an answer either.
4922        let own: Vec<Resolved> = self
4923            .created()?
4924            .iter()
4925            .chain(self.updated()?.iter())
4926            .filter(|own| own.content_kind == ContentKind::Issue)
4927            .cloned()
4928            .collect();
4929        for mut own in own {
4930            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4931            match found.iter_mut().find(|read| read.id == own.id) {
4932                Some(read) => {
4933                    own.updated_at = own.updated_at.max(read.updated_at);
4934                    *read = own;
4935                }
4936                None => found.push(own),
4937            }
4938        }
4939        Ok(found)
4940    }
4941
4942    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4943    /// there is no instant to hold it to.
4944    ///
4945    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4946    /// or after the instant moved it there: an issue not updated since holds no such comment,
4947    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4948    /// only as far as the first that matches. A board draft is not an issue and has no
4949    /// comments, so it never matches.
4950    async fn commented_since(
4951        &self,
4952        item: &Resolved,
4953        since: Option<DateTime<Utc>>,
4954    ) -> Result<bool, SourceError> {
4955        let Some(since) = since else {
4956            return Ok(true);
4957        };
4958        if item.content_kind == ContentKind::DraftIssue
4959            || item.updated_at.is_some_and(|updated| updated < since)
4960        {
4961            return Ok(false);
4962        }
4963        let query = TaskQuery {
4964            commented_since: Some(since),
4965            ..TaskQuery::default()
4966        };
4967        let mut after: Option<String> = None;
4968        loop {
4969            let data = self
4970                .graphql(
4971                    graphql::ISSUE_COMMENTS,
4972                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4973                )
4974                .await?;
4975            let Some(connection) = data
4976                .get("node")
4977                .filter(|value| !value.is_null())
4978                .and_then(|node| node.get("comments"))
4979                .filter(|value| !value.is_null())
4980            else {
4981                // Removed since the search reported it: no longer an issue with comments.
4982                return Ok(false);
4983            };
4984            let comments = optional_nodes(Some(connection), "issue comments")?
4985                .into_iter()
4986                .flatten()
4987                .map(comment_from)
4988                .collect::<Result<Vec<_>, _>>()?;
4989            if query.comments_match(&comments) {
4990                return Ok(true);
4991            }
4992            match next_cursor(connection)? {
4993                Some(next) => {
4994                    validate_cursor_progress(after.as_deref(), &next.0)?;
4995                    after = Some(next.0);
4996                }
4997                None => return Ok(false),
4998            }
4999        }
5000    }
5001
5002    /// Every item on the board: the union of both enumerations GitHub offers of one.
5003    ///
5004    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5005    /// board **draft** and reads the board's own fields beside its items, and only the search
5006    /// reports an item that connection is behind on. The module documentation is where the lag and the
5007    /// measurements behind it are written down.
5008    ///
5009    /// A search result is admitted on the same terms as any other issue this source reaches
5010    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5011    /// names *this* board — so an issue the index still believes is here after it was taken
5012    /// off is refused rather than reported.
5013    ///
5014    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5015    /// which is what the cache could otherwise have broken.
5016    async fn board(&self) -> Result<Board, SourceError> {
5017        let cached = self.board_cache()?.clone();
5018        let mut board = match cached {
5019            Some(board) => board,
5020            None => {
5021                let read = self.read_board().await?;
5022                *self.board_cache()? = Some(read.clone());
5023                read
5024            }
5025        };
5026        for held in self.searched_issues().await? {
5027            if !board.items.iter().any(|item| item.id == held.id) {
5028                board.items.push(held);
5029            }
5030        }
5031        for own in self.created()?.iter() {
5032            if !board.items.iter().any(|item| item.id == own.id) {
5033                board.items.push(own.clone());
5034            }
5035        }
5036        Ok(board)
5037    }
5038
5039    /// This process's own view of the board, or the refusal a poisoned lock is.
5040    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5041        self.board_cache
5042            .lock()
5043            .map_err(|_| SourceError::Unavailable {
5044                message: "this source's view of the board was left inconsistent by an earlier \
5045                      failure; next: run the command again"
5046                    .into(),
5047            })
5048    }
5049
5050    /// Bring this process's own view of the board up to an item it has just written.
5051    ///
5052    /// A created item goes to `created`, which is what completes a board read GitHub's own
5053    /// eventual consistency has left behind. An item that was already there is replaced
5054    /// where it sits, so a second write of it in the same command reads its real parent
5055    /// rather than the one it had before the first write.
5056    ///
5057    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5058    /// that wins: an item this same run created is held in `created` and not in the cached
5059    /// board, and `board` completes the cached board *from* `created`, so replacing only
5060    /// the cached copy of such an item replaces nothing and the read still reports the
5061    /// title it was created with. The search is the third, and it is the one an item the
5062    /// board's own projection is behind on sits in *alone* — which is exactly the item this
5063    /// source is least able to re-read, so leaving it out would put the stale title back on
5064    /// the only items the completion in [`Self::board`] exists for.
5065    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5066        self.resolved_cache()?.insert(item.id.clone(), item.clone());
5067        if created {
5068            self.created()?.push(item);
5069            return Ok(());
5070        }
5071        {
5072            let mut own = self.created()?;
5073            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5074                *held = item;
5075                return Ok(());
5076            }
5077        }
5078        {
5079            let mut own = self.updated()?;
5080            match own.iter_mut().find(|held| held.id == item.id) {
5081                Some(held) => *held = item.clone(),
5082                None => own.push(item.clone()),
5083            }
5084        }
5085        if let Some(board) = self.board_cache()?.as_mut()
5086            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5087        {
5088            *held = item.clone();
5089        }
5090        if let Some(found) = self.search_cache()?.as_mut()
5091            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5092        {
5093            *held = item.clone();
5094        }
5095        for found in self.narrowed_cache()?.values_mut() {
5096            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5097                *held = item.clone();
5098            }
5099        }
5100        Ok(())
5101    }
5102
5103    /// Forget one item this process has just deleted, from every half of its own view.
5104    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5105        self.resolved_cache()?.remove(id);
5106        self.created()?.retain(|own| own.id != *id);
5107        self.updated()?.retain(|own| own.id != *id);
5108        if let Some(board) = self.board_cache()?.as_mut() {
5109            board.items.retain(|item| item.id != *id);
5110        }
5111        if let Some(found) = self.search_cache()?.as_mut() {
5112            found.retain(|item| item.id != *id);
5113        }
5114        for found in self.narrowed_cache()?.values_mut() {
5115            found.retain(|item| item.id != *id);
5116        }
5117        Ok(())
5118    }
5119
5120    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5121    fn narrowed_cache(
5122        &self,
5123    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5124        self.narrowed_cache
5125            .lock()
5126            .map_err(|_| SourceError::Unavailable {
5127                message: "this source's view of a narrowed read was left inconsistent by an \
5128                      earlier failure; next: run the command again"
5129                    .into(),
5130            })
5131    }
5132
5133    /// Every page of the board, read from GitHub.
5134    async fn read_board(&self) -> Result<Board, SourceError> {
5135        let mut after: Option<String> = None;
5136        let mut items = Vec::new();
5137        let mut board;
5138        loop {
5139            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5140            for item in page
5141                .pointer("/items/nodes")
5142                .and_then(Value::as_array)
5143                .ok_or_else(|| SourceError::Malformed {
5144                    message: "GitHub project items.nodes is not an array".into(),
5145                })?
5146            {
5147                if let Some(resolved) = self.resolve(item)? {
5148                    items.push(resolved);
5149                }
5150            }
5151            let info = page
5152                .pointer("/items/pageInfo")
5153                .ok_or_else(|| SourceError::Malformed {
5154                    message: "GitHub project items have no pageInfo".into(),
5155                })?;
5156            let has_next = required_bool(info, "hasNextPage")?;
5157            let next = has_next
5158                .then(|| required_str(info, "endCursor"))
5159                .transpose()?;
5160            board = page.clone();
5161            match next {
5162                Some(next) => {
5163                    validate_cursor_progress(after.as_deref(), next)?;
5164                    after = Some(next.to_owned());
5165                }
5166                None => break,
5167            }
5168        }
5169        Ok(Board {
5170            id: required_str(&board, "id")?.to_owned(),
5171            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5172            items,
5173        })
5174    }
5175
5176    /// The existing items this source has written, for completing a narrowed read that is
5177    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5178    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5179        self.updated.lock().map_err(|_| SourceError::Unavailable {
5180            message: "this source's record of what it wrote in this run was left inconsistent \
5181                      by an earlier failure; next: run the command again"
5182                .into(),
5183        })
5184    }
5185
5186    /// The items this source has created, for completing a board read that is behind.
5187    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5188        self.created.lock().map_err(|_| SourceError::Unavailable {
5189            message: "this source's record of what it created in this run was left \
5190                      inconsistent by an earlier failure; next: run the command again"
5191                .into(),
5192        })
5193    }
5194
5195    /// One board item as this source reports it, or `None` for content it ignores.
5196    ///
5197    /// A pull request is neither a project nor a task — it is somebody's change, not a
5198    /// unit of plan — and an item whose content the token cannot see has nothing to
5199    /// report at all.
5200    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5201        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5202            message: "GitHub project item is missing content".into(),
5203        })?;
5204        if content.is_null() {
5205            return Ok(None);
5206        }
5207        let content_kind = match required_str(content, "__typename")? {
5208            "Issue" => ContentKind::Issue,
5209            "DraftIssue" => ContentKind::DraftIssue,
5210            _ => return Ok(None),
5211        };
5212        let field_values = item
5213            .get("fieldValues")
5214            .ok_or_else(|| SourceError::Malformed {
5215                message: "GitHub project item is missing fieldValues".into(),
5216            })?;
5217        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5218        let nodes = field_values
5219            .get("nodes")
5220            .and_then(Value::as_array)
5221            .ok_or_else(|| SourceError::Malformed {
5222                message: "GitHub project item fieldValues.nodes is not an array".into(),
5223            })?;
5224        if let Some(labels) = content.get("labels") {
5225            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5226        }
5227        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5228        let (body, slot) = metadata_body(raw_body.clone())?;
5229        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5230            .map(|id| NativeId(id.to_owned()));
5231        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5232        // to read one from; it is a task, and never a project.
5233        let sub_issues = match content_kind {
5234            ContentKind::Issue => sub_issue_total(content)?,
5235            ContentKind::DraftIssue => 0,
5236        };
5237        let content_id = required_str(content, "id")?;
5238        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5239            message: format!("GitHub issue {content_id}: {message}"),
5240        })?;
5241        let raw_title = required_str(content, "title")?;
5242        // The design prefix is read *first*, before either of the two rules that separate
5243        // a project from a task. A document is not work whatever sub-issues it has and
5244        // whatever marker it carries, and reading the prefix later would make a design
5245        // issue with none of either an empty project.
5246        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5247            BoardKind::Document
5248        } else if parent.is_some() {
5249            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5250            // under a project is that project's task even when it has sub-issues of its
5251            // own.
5252            BoardKind::Work(ItemKind::Task)
5253        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5254            BoardKind::Work(ItemKind::Project)
5255        } else {
5256            BoardKind::Work(ItemKind::Task)
5257        };
5258        // The title a person wrote, which for a document is the one without the prefix —
5259        // the same way `content` above is the body without this source's metadata slot.
5260        let title = match kind {
5261            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5262            BoardKind::Work(_) => raw_title.to_owned(),
5263        };
5264        let own_repository = content
5265            .pointer("/repository/nameWithOwner")
5266            .and_then(Value::as_str)
5267            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5268            .transpose()
5269            .map_err(|message| SourceError::Malformed { message })?;
5270        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5271            Repository::from_metadata(&slot)
5272                .map_err(|message| SourceError::Malformed { message })?
5273        } else {
5274            own_repository.clone().into_iter().collect()
5275        };
5276        let id = NativeId(content_id.to_owned());
5277        // Read only for a task, because only a task has either list: a project or a
5278        // document holding one of these keys holds nothing this source reports, and the
5279        // keys are left out of its caller-visible metadata all the same.
5280        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5281            let listed = |key: &str| {
5282                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5283                    .map_err(|message| SourceError::Malformed { message })
5284            };
5285            (
5286                listed(TaskRef::DELIVERS_KEY)?,
5287                listed(TaskRef::DELIVERED_BY_KEY)?,
5288            )
5289        } else {
5290            (Vec::new(), Vec::new())
5291        };
5292        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5293        let priority = self.held_priority(nodes)?;
5294        // Present when the item was reached through its own issue, whose board entry
5295        // names the board; a read of the board's own items has the board already. An
5296        // empty id names nothing a field write could address, so it is read as absent and
5297        // the write goes back to reading the board.
5298        let board_id = item
5299            .pointer("/project/id")
5300            .and_then(Value::as_str)
5301            .filter(|id| !id.is_empty());
5302        let resolved = Resolved {
5303            item_id: required_str(item, "id")?.to_owned(),
5304            id,
5305            content_kind,
5306            kind,
5307            title,
5308            body: body.filter(|value| !value.is_empty()),
5309            raw_body,
5310            status: self
5311                .statuses
5312                .status(kind.status_kind(), option, closed, reason),
5313            option: option.map(str::to_owned),
5314            priority,
5315            closed,
5316            delivers,
5317            delivered_by,
5318            labels: labels(content)?,
5319            parent,
5320            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5321            number: match content_kind {
5322                ContentKind::Issue => Some(issue_number(content)?),
5323                // A draft is filed in no repository, so nothing ever numbered it:
5324                // `DraftIssue` declares no `number` at all, exactly as it declares no
5325                // `subIssuesSummary` the branch above reads.
5326                ContentKind::DraftIssue => None,
5327            },
5328            url: optional_str(content, "url")?.map(str::to_owned),
5329            created_at: optional_time(content, "createdAt")?,
5330            updated_at: optional_time(content, "updatedAt")?,
5331            own_repository,
5332            repositories,
5333            slot,
5334            board_id: board_id.map(str::to_owned),
5335            fields: field_definitions(nodes),
5336            board_fields: Self::carried_board_fields(content, board_id)?,
5337            blocked_by: carried_blocked_by(content)?,
5338        };
5339        self.resolved_cache()?
5340            .insert(resolved.id.clone(), resolved.clone());
5341        Ok(Some(resolved))
5342    }
5343
5344    /// The field definitions of the board `board_id` names — the project this issue's own
5345    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5346    /// `None` when the read carried none, carried no entry for that board, or the board item
5347    /// named no board, which a write then answers by reading the board's fields itself.
5348    ///
5349    /// Matched by the board's node id and never by its number alone: a project number is
5350    /// unique only within its owner, so another owner's board numbered alike can sit on the
5351    /// same page, and its field and option ids address nothing on this one.
5352    fn carried_board_fields(
5353        content: &Value,
5354        board_id: Option<&str>,
5355    ) -> Result<Option<Value>, SourceError> {
5356        let (Some(nodes), Some(board_id)) = (
5357            content.pointer("/boards/nodes").and_then(Value::as_array),
5358            board_id,
5359        ) else {
5360            return Ok(None);
5361        };
5362        let Some(board) = nodes.iter().find_map(|node| {
5363            let project = node.get("project")?;
5364            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5365        }) else {
5366            return Ok(None);
5367        };
5368        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5369            return Ok(None);
5370        };
5371        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5372        Ok(Some(fields.clone()))
5373    }
5374
5375    /// What one board item's `Priority` field says, through this instance's mapping.
5376    ///
5377    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5378    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5379    /// option the mapping does not name is kept as itself — never read as a level or as
5380    /// `none` — for a read of the task to report by name.
5381    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5382        let Some(mapping) = &self.priorities else {
5383            return Ok(HeldPriority::Read(Priority::None));
5384        };
5385        // A value of the field that names no option — a text field someone called `Priority` —
5386        // is malformed rather than `none`: reading it as no priority would let the next copy
5387        // clear one a person set.
5388        let Some(option) = field_values
5389            .iter()
5390            .find(|value| {
5391                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5392            })
5393            .map(|value| required_str(value, "name"))
5394            .transpose()?
5395        else {
5396            return Ok(HeldPriority::Read(Priority::None));
5397        };
5398        Ok(mapping.priority_of(option).map_or_else(
5399            || HeldPriority::Unmapped(option.to_owned()),
5400            HeldPriority::Read,
5401        ))
5402    }
5403
5404    /// What one board item's status is read from: its `Status` option, whether its issue
5405    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5406    /// three into the status it reports.
5407    fn status_parts<'a>(
5408        field_values: &'a [Value],
5409        content: &'a Value,
5410    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5411        let option = field_values
5412            .iter()
5413            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5414            .map(|value| required_str(value, "name"))
5415            .transpose()?;
5416        let closed = optional_str(content, "state")? == Some("CLOSED");
5417        Ok((option, closed, optional_str(content, "stateReason")?))
5418    }
5419
5420    /// The board Status option this write selects, or the refusal that says why not.
5421    ///
5422    /// The mapped option is required for both open and terminal targets. A terminal write
5423    /// validates it before changing either representation, so it can never fall back to
5424    /// closing an issue whose board cannot display the matching status.
5425    ///
5426    /// Answers the field's id, the option's id, and the option's name as the board spells
5427    /// it — which is the name a read of the item reports once it sits there.
5428    fn column_for(
5429        &self,
5430        fields: &Value,
5431        kind: ItemKind,
5432        category: StatusCategory,
5433        target: &StatusTarget,
5434    ) -> Result<Option<(String, String, String)>, SourceError> {
5435        let Some(wanted) = target.option() else {
5436            return Ok(None);
5437        };
5438        let missing = |detail: &str| SourceError::Refused {
5439            message: format!(
5440                "{} status {} of source {} needs the board Status option {wanted:?}, and \
5441                 {detail}; next: add that option to the board, which `onetaskgraph sources \
5442                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5443                 it has",
5444                kind.marker(),
5445                category_name(category),
5446                self.name,
5447                self.name,
5448                category_name(category),
5449                kind.marker()
5450            ),
5451        };
5452        let Some(field) = Board::field(fields, "Status")? else {
5453            return Err(missing("this board has no Status field"));
5454        };
5455        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5456            return Err(missing(
5457                "this board's Status field is not a single-select field",
5458            ));
5459        }
5460        let option = field
5461            .get("options")
5462            .and_then(Value::as_array)
5463            .and_then(|options| {
5464                options.iter().find(|option| {
5465                    option
5466                        .get("name")
5467                        .and_then(Value::as_str)
5468                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5469                })
5470            });
5471        match option {
5472            None => Err(missing("this board does not have it")),
5473            Some(option) => Ok(Some((
5474                required_str(field, "id")?.to_owned(),
5475                required_str(option, "id")?.to_owned(),
5476                required_str(option, "name")?.to_owned(),
5477            ))),
5478        }
5479    }
5480
5481    /// The refusal a status that closes an issue is answered with over a board draft.
5482    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5483        SourceError::Refused {
5484            message: format!(
5485                "status {} of source {} closes the item's issue, and GitHub draft items have \
5486                 no open or closed state",
5487                category_name(category),
5488                self.name
5489            ),
5490        }
5491    }
5492
5493    /// What a status write to one item needs of the board: the board's id and the
5494    /// definition of its `Status` field, read off the item when the item says both.
5495    ///
5496    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5497    /// and its `Status` value carries that field's definition, options and all. An item that
5498    /// does not say — no board id, or no `Status` value to read the field off — takes them
5499    /// from [`Self::board_fields`], which reads no item.
5500    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5501        if let Some(board) = item.carried_board() {
5502            return Ok(board);
5503        }
5504        if item.defines("Status")
5505            && let Some(board_id) = item.named_board()
5506        {
5507            return Ok(BoardFields {
5508                id: board_id,
5509                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5510            });
5511        }
5512        self.board_fields().await
5513    }
5514
5515    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5516    async fn set_status(
5517        &self,
5518        id: &NativeId,
5519        category: StatusCategory,
5520    ) -> Result<Option<Status>, SourceError> {
5521        // Refused before anything is read, in the words a write of the same status is.
5522        let target = self.resolved_target(ItemKind::Task, category)?;
5523        let Some(mut item) = self
5524            .bound_item(id)
5525            .await?
5526            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5527        else {
5528            return Ok(None);
5529        };
5530        let board = self.status_board(&item).await?;
5531        let (field, option, name) = self
5532            .column_for(&board.fields, ItemKind::Task, category, &target)?
5533            .ok_or_else(|| SourceError::Malformed {
5534                message: format!(
5535                    "status {} of source {} names no board Status option",
5536                    category_name(category),
5537                    self.name
5538                ),
5539            })?;
5540        if item.status.category == category && item.option.as_deref() == Some(&name) {
5541            return Ok(Some(item.status));
5542        }
5543        match &target {
5544            StatusTarget::Terminal(_, reason) => {
5545                if item.content_kind == ContentKind::DraftIssue {
5546                    return Err(self.closes_a_draft(category));
5547                }
5548                self.set_item_field(
5549                    board.id.as_str(),
5550                    &item.item_id,
5551                    &field,
5552                    json!({"singleSelectOptionId": option}),
5553                )
5554                .await?;
5555                self.update_content(
5556                    ContentKind::Issue,
5557                    &item.id,
5558                    json!({"stateInput": state_input(Some(&target))}),
5559                )
5560                .await?;
5561                item.closed = true;
5562                item.status =
5563                    self.statuses
5564                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5565                item.option = Some(name);
5566            }
5567            StatusTarget::Column(_) => {
5568                // An option is what an open item's status is, so a closed issue is reopened
5569                // first — sitting closed in the column, it would read back as closed. A draft has
5570                // no state to reopen.
5571                if item.content_kind == ContentKind::Issue && item.closed {
5572                    self.update_content(
5573                        ContentKind::Issue,
5574                        &item.id,
5575                        json!({"stateInput": state_input(Some(&target))}),
5576                    )
5577                    .await?;
5578                    item.closed = false;
5579                }
5580                self.set_item_field(
5581                    board.id.as_str(),
5582                    &item.item_id,
5583                    &field,
5584                    json!({"singleSelectOptionId": option}),
5585                )
5586                .await?;
5587                item.status = self
5588                    .statuses
5589                    .status(ItemKind::Task, Some(&name), false, None);
5590                item.option = Some(name);
5591            }
5592            StatusTarget::Disabled(_) => {
5593                unreachable!("resolved_target refused a disabled status")
5594            }
5595        }
5596        let status = item.status.clone();
5597        self.remember_written(item, false)?;
5598        Ok(Some(status))
5599    }
5600
5601    /// Replace one task's `delivered_by` and nothing else; see
5602    /// [`TaskSource::set_delivered_by`].
5603    ///
5604    /// One update of the body, which differs from the body GitHub holds only inside the
5605    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5606    async fn replace_delivered_by(
5607        &self,
5608        id: &NativeId,
5609        delivered_by: &[TaskRef],
5610    ) -> Result<Option<()>, SourceError> {
5611        let entries = TaskRef::listed(
5612            TaskRef::DELIVERED_BY_KEY,
5613            id,
5614            Some(&self.name),
5615            delivered_by.to_vec(),
5616        )
5617        .map_err(|message| SourceError::Refused { message })?;
5618        let Some(mut item) = self
5619            .bound_item(id)
5620            .await?
5621            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5622        else {
5623            return Ok(None);
5624        };
5625        let mut slot = item.slot.clone();
5626        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5627        self.write_slot(&mut item, &slot).await?;
5628        item.delivered_by = entries;
5629        self.remember_written(item, false)?;
5630        Ok(Some(()))
5631    }
5632
5633    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5634    /// see [`TaskSource::set_task_metadata`].
5635    ///
5636    /// `None` when this board holds no item by that id, or holds one of another kind. The
5637    /// answer is the item as this source now reads it, so what a caller is told the key
5638    /// holds is what the slot holds.
5639    ///
5640    /// A key already holding the value is answered without a write, compared as JSON rather
5641    /// than as the body's bytes: a slot a person spelled with other whitespace would
5642    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5643    async fn set_slot_key(
5644        &self,
5645        id: &NativeId,
5646        kind: BoardKind,
5647        key: &MetadataKey,
5648        value: &Value,
5649    ) -> Result<Option<Resolved>, SourceError> {
5650        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5651            return Ok(None);
5652        };
5653        if item.slot.get(key.as_str()) == Some(value) {
5654            return Ok(Some(item));
5655        }
5656        let mut slot = item.slot.clone();
5657        slot.insert(key.as_str().to_owned(), value.clone());
5658        self.write_slot(&mut item, &slot).await?;
5659        self.remember_written(item.clone(), false)?;
5660        Ok(Some(item))
5661    }
5662
5663    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5664    /// `item` up to what that write left.
5665    ///
5666    /// The body sent differs from the body GitHub holds only inside the slot — see
5667    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5668    /// the mutation the item's content takes, so a board draft's body is written with
5669    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5670    async fn write_slot(
5671        &self,
5672        item: &mut Resolved,
5673        slot: &BTreeMap<String, Value>,
5674    ) -> Result<(), SourceError> {
5675        let held = item.raw_body.clone().unwrap_or_default();
5676        let body = with_slot(&held, slot)?;
5677        if body != held {
5678            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5679                .await?;
5680        }
5681        let (visible, slot) = metadata_body(Some(body.clone()))?;
5682        item.body = visible.filter(|value| !value.is_empty());
5683        item.raw_body = Some(body);
5684        item.slot = slot;
5685        Ok(())
5686    }
5687
5688    /// This instance's target for a category written to an item of `kind`, refusing one
5689    /// that kind has no option for — before anything is read or written.
5690    ///
5691    /// Nothing here mutates the board's option set to make room for a status. GitHub
5692    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5693    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5694    /// field and every item's status.
5695    fn resolved_target(
5696        &self,
5697        kind: ItemKind,
5698        category: StatusCategory,
5699    ) -> Result<StatusTarget, SourceError> {
5700        let target = self.statuses.target(kind, category).clone();
5701        let StatusTarget::Disabled(why) = target else {
5702            return Ok(target);
5703        };
5704        let refusal = why.refusal(&self.name, category, kind);
5705        // Why there is no shipped default, which is the question a person meeting this
5706        // refusal on a source that never mentioned the category asks.
5707        let shipped_none = match category {
5708            StatusCategory::Draft => Some(
5709                "draft has no shipped default because GitHub draft issues cannot have \
5710                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5711            ),
5712            StatusCategory::Unknown => Some(
5713                "unknown has no shipped default because this board keeps no open-ended status \
5714                 word: every word classified unknown is written to the one board Status option \
5715                 status_mapping.unknown names",
5716            ),
5717            _ => None,
5718        };
5719        Err(match (refusal, shipped_none, why) {
5720            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5721                SourceError::Refused {
5722                    message: format!("{message}; {note}"),
5723                }
5724            }
5725            (refusal, _, _) => refusal,
5726        })
5727    }
5728
5729    /// What writing `priority` does to one item's `Priority` field on this board, or the
5730    /// refusal naming what the board lacks.
5731    ///
5732    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5733    /// none already, or of an item not created yet. Every other priority selects the option
5734    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5735    /// without that option, is refused rather than given one: reads and writes never create
5736    /// a field or an option.
5737    fn priority_write(
5738        &self,
5739        fields: &Value,
5740        existing: Option<&Resolved>,
5741        priority: Priority,
5742    ) -> Result<Option<PriorityWrite>, SourceError> {
5743        let Some(mapping) = &self.priorities else {
5744            return Err(self.holds_no_priority());
5745        };
5746        let Some(wanted) = mapping.option(priority) else {
5747            if !existing.is_some_and(Resolved::holds_priority) {
5748                return Ok(None);
5749            }
5750            let field =
5751                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5752                    message: format!(
5753                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5754                    ),
5755                })?;
5756            return Ok(Some(PriorityWrite::Clear {
5757                field: required_str(field, "id")?.to_owned(),
5758            }));
5759        };
5760        let missing = |detail: &str| SourceError::Refused {
5761            message: format!(
5762                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5763                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5764                 it, or point priority_mapping.{priority} of this source at an option the board \
5765                 has",
5766                self.name, self.name
5767            ),
5768        };
5769        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5770            return Err(missing(&format!(
5771                "this board has no {PRIORITY_FIELD} field"
5772            )));
5773        };
5774        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5775            return Err(missing(&format!(
5776                "this board's {PRIORITY_FIELD} field is not a single-select field"
5777            )));
5778        }
5779        // An options list that is absent or not a list is an answer this source cannot read,
5780        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5781        let option = field
5782            .get("options")
5783            .and_then(Value::as_array)
5784            .ok_or_else(|| SourceError::Malformed {
5785                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5786            })?
5787            .iter()
5788            .find(|option| {
5789                option
5790                    .get("name")
5791                    .and_then(Value::as_str)
5792                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5793            })
5794            .ok_or_else(|| missing("this board does not have it"))?;
5795        Ok(Some(PriorityWrite::Select {
5796            field: required_str(field, "id")?.to_owned(),
5797            option: required_str(option, "id")?.to_owned(),
5798        }))
5799    }
5800
5801    /// Apply one priority write to one board item.
5802    async fn write_priority(
5803        &self,
5804        board_id: &str,
5805        item_id: &str,
5806        write: &PriorityWrite,
5807    ) -> Result<(), SourceError> {
5808        match write {
5809            PriorityWrite::Select { field, option } => {
5810                self.set_item_field(
5811                    board_id,
5812                    item_id,
5813                    field,
5814                    json!({"singleSelectOptionId": option}),
5815                )
5816                .await
5817            }
5818            PriorityWrite::Clear { field } => {
5819                let data = self
5820                    .graphql(
5821                        graphql::CLEAR_FIELD,
5822                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5823                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5824                    )
5825                    .await?;
5826                let returned = data
5827                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5828                    .ok_or_else(|| SourceError::Malformed {
5829                        message: "GitHub field clear returned no project item".into(),
5830                    })?;
5831                if required_str(returned, "id")? != item_id {
5832                    return Err(SourceError::Malformed {
5833                        message: "GitHub field clear returned the wrong project item".into(),
5834                    });
5835                }
5836                Ok(())
5837            }
5838        }
5839    }
5840
5841    /// The refusal a priority is answered with by an instance configured with no
5842    /// `priority_mapping`, which holds none.
5843    fn holds_no_priority(&self) -> SourceError {
5844        SourceError::Refused {
5845            message: format!(
5846                "source {} holds no task priority: its configuration sets no priority_mapping; \
5847                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5848                 fields {} --apply` to set its board up",
5849                self.name, self.name
5850            ),
5851        }
5852    }
5853
5854    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5855    ///
5856    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5857    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5858    async fn set_priority(
5859        &self,
5860        id: &NativeId,
5861        priority: Priority,
5862    ) -> Result<Option<Priority>, SourceError> {
5863        if self.priorities.is_none() {
5864            return Err(self.holds_no_priority());
5865        }
5866        let Some(mut item) = self
5867            .bound_item(id)
5868            .await?
5869            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5870        else {
5871            return Ok(None);
5872        };
5873        if priority == Priority::None && !item.holds_priority() {
5874            return Ok(Some(priority));
5875        }
5876        // The item's own read carries the field's definition whenever it holds a value of
5877        // it, which a clear always does; a select onto an item holding none reads the board.
5878        let board = match (item.carried_board(), item.named_board()) {
5879            (Some(board), _) => board,
5880            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5881                id,
5882                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5883            },
5884            _ => self.board_fields().await?,
5885        };
5886        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5887            return Ok(Some(priority));
5888        };
5889        let (document, root, input) = match write {
5890            PriorityWrite::Select { field, option } => (
5891                graphql::UPDATE_FIELD,
5892                "updateProjectV2ItemFieldValue",
5893                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5894            ),
5895            PriorityWrite::Clear { field } => (
5896                graphql::CLEAR_FIELD,
5897                "clearProjectV2ItemFieldValue",
5898                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5899            ),
5900        };
5901        let data = self
5902            .graphql(
5903                document,
5904                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5905            )
5906            .await?;
5907        let returned = data
5908            .get(root)
5909            .and_then(|value| value.get("projectV2Item"))
5910            .ok_or_else(|| SourceError::Malformed {
5911                message: "GitHub priority write returned no project item".into(),
5912            })?;
5913        if required_str(returned, "id")? != item.item_id {
5914            return Err(SourceError::Malformed {
5915                message: "GitHub priority write returned the wrong project item".into(),
5916            });
5917        }
5918        let value = returned
5919            .get("fieldValueByName")
5920            .ok_or_else(|| SourceError::Malformed {
5921                message: "GitHub priority write returned no priority read-back".into(),
5922            })?;
5923        if !value.is_null()
5924            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5925        {
5926            return Err(SourceError::Malformed {
5927                message: "GitHub priority read-back is not a Priority field value".into(),
5928            });
5929        }
5930        let values = if value.is_null() {
5931            Vec::new()
5932        } else {
5933            vec![value.clone()]
5934        };
5935        item.priority = self.held_priority(&values)?;
5936        let answer = item.task()?.priority;
5937        self.remember_written(item, false)?;
5938        Ok(Some(answer))
5939    }
5940
5941    /// Replace one task's visible body and nothing else; see
5942    /// [`TaskSource::set_task_content`].
5943    ///
5944    /// One update of the body, which differs from the body GitHub holds only outside the
5945    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5946    /// this source keeps there reads back as it was. A body that would not change is not
5947    /// sent at all.
5948    async fn replace_content(
5949        &self,
5950        id: &NativeId,
5951        content: &str,
5952    ) -> Result<Option<()>, SourceError> {
5953        let Some(mut item) = self
5954            .bound_item(id)
5955            .await?
5956            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5957        else {
5958            return Ok(None);
5959        };
5960        let held = item.raw_body.clone().unwrap_or_default();
5961        let body = with_content(&held, content)?;
5962        // Checked before anything is sent: content ending in what this source reads as its own
5963        // metadata slot would read back as metadata rather than as the content it was.
5964        let (visible, slot) = metadata_body(Some(body.clone()))?;
5965        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5966            return Err(SourceError::Refused {
5967                message: format!(
5968                    "this content ends in what source {} reads as its own metadata slot \
5969                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5970                     as content; next: remove that trailing block from the content",
5971                    self.name
5972                ),
5973            });
5974        }
5975        if body != held {
5976            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5977                .await?;
5978        }
5979        item.body = visible.filter(|value| !value.is_empty());
5980        item.raw_body = Some(body);
5981        item.slot = slot;
5982        self.remember_written(item, false)?;
5983        Ok(Some(()))
5984    }
5985
5986    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5987    ///
5988    /// One read of the item — which carries the board's field definitions and the issue's
5989    /// `blockedBy`, so neither is read again — and then only what differs from it: the
5990    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5991    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5992    /// the title, the body — visible content and metadata slot together — and a state change.
5993    /// So an update naming any of title, body, metadata, status and priority is one read and
5994    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5995    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5996    /// as a whole write does; an open one selects its option and then reopens. The origin
5997    /// field is never written: an update is of an item that already exists, whose origin is
5998    /// what it is.
5999    ///
6000    /// The task answered is the item as those writes left it, built from the read and what was
6001    /// sent rather than read again — the same record a later read in this run answers from.
6002    async fn targeted_update(
6003        &self,
6004        id: &NativeId,
6005        update: &TaskUpdate,
6006    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6007        // Everything this source can refuse without reading the item is refused first, in the
6008        // words a whole write of the same fields is refused with.
6009        update.consistent()?;
6010        if update
6011            .title
6012            .as_deref()
6013            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6014        {
6015            return Err(SourceError::Refused {
6016                message: format!(
6017                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6018                     spells a document, so it would read back as one rather than as a task; \
6019                     retitle it",
6020                    self.name
6021                ),
6022            });
6023        }
6024        if let Some(delivers) = &update.delivers {
6025            TaskRef::listed(
6026                TaskRef::DELIVERS_KEY,
6027                id,
6028                Some(&self.name),
6029                delivers.clone(),
6030            )
6031            .map_err(|message| SourceError::Refused { message })?;
6032        }
6033        if self.priorities.is_none()
6034            && update
6035                .priority
6036                .is_some_and(|priority| priority != Priority::None)
6037        {
6038            return Err(self.holds_no_priority());
6039        }
6040        let target = update
6041            .status
6042            .as_ref()
6043            .map(|status| self.resolved_target(ItemKind::Task, status.category))
6044            .transpose()?;
6045        let Some(mut item) = self
6046            .bound_item(id)
6047            .await?
6048            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6049        else {
6050            return Ok(None);
6051        };
6052        let before = item.task()?;
6053
6054        let mut status_move = None;
6055        if let (Some(status), Some(target)) = (&update.status, target) {
6056            let board = self.status_board(&item).await?;
6057            let (field, option, name) = self
6058                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6059                .ok_or_else(|| SourceError::Malformed {
6060                    message: format!(
6061                        "status {} of source {} names no board Status option",
6062                        category_name(status.category),
6063                        self.name
6064                    ),
6065                })?;
6066            let terminal = matches!(target, StatusTarget::Terminal(_, _));
6067            if terminal && item.content_kind == ContentKind::DraftIssue {
6068                return Err(self.closes_a_draft(status.category));
6069            }
6070            let landed = match &target {
6071                StatusTarget::Terminal(_, reason) => {
6072                    self.statuses
6073                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6074                }
6075                _ => self
6076                    .statuses
6077                    .status(ItemKind::Task, Some(&name), false, None),
6078            };
6079            let option_moves = item
6080                .option
6081                .as_deref()
6082                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6083            let state_moves = item.content_kind == ContentKind::Issue
6084                && (item.closed != terminal || (terminal && item.status != landed));
6085            if let Some(moves) = Moves::of(option_moves, state_moves) {
6086                status_move = Some(StatusMove {
6087                    board: board.id,
6088                    field,
6089                    option,
6090                    name,
6091                    target,
6092                    landed,
6093                    moves,
6094                });
6095            }
6096        }
6097
6098        let mut priority_move = None;
6099        if let Some(priority) = update.priority
6100            && self.priorities.is_some()
6101            && item.priority != HeldPriority::Read(priority)
6102        {
6103            let board = match (item.carried_board(), item.named_board()) {
6104                (Some(board), _) => board,
6105                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6106                    id: board,
6107                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6108                },
6109                _ => self.board_fields().await?,
6110            };
6111            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6112                priority_move = Some((board.id, write, priority));
6113            }
6114        }
6115
6116        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6117        // recorded in the slot, and the slot travels in the one body update below.
6118        let edges = match &update.depends_on {
6119            Some(edges) => Some(
6120                self.partition_edges(
6121                    BoardKind::Work(ItemKind::Task),
6122                    item.content_kind,
6123                    item.blocked_by.as_deref(),
6124                    edges,
6125                )
6126                .await?,
6127            ),
6128            None => None,
6129        };
6130
6131        let mut slot = item.slot.clone();
6132        for (key, value) in &update.metadata_set {
6133            slot.insert(key.as_str().to_owned(), value.clone());
6134        }
6135        for key in &update.metadata_remove {
6136            slot.remove(key.as_str());
6137        }
6138        if let Some(delivers) = &update.delivers {
6139            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6140        }
6141        if let Some((_, recorded)) = &edges {
6142            record_edges(&mut slot, recorded);
6143        }
6144        let held = item.raw_body.clone().unwrap_or_default();
6145        let content = match &update.content {
6146            Some(content) => with_content(&held, content)?,
6147            None => held.clone(),
6148        };
6149        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6150        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6151        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6152        let body = if slot == item.slot {
6153            content
6154        } else {
6155            with_slot(&content, &slot)?
6156        };
6157        // Checked before anything is sent, as a content write checks it: content ending in
6158        // what this source reads as its own slot would read back as metadata.
6159        let (visible, read) = metadata_body(Some(body.clone()))?;
6160        let wanted = update.content.as_deref().or(item.body.as_deref());
6161        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6162            return Err(SourceError::Refused {
6163                message: format!(
6164                    "this content ends in what source {} reads as its own metadata slot \
6165                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6166                     as content; next: remove that trailing block from the content",
6167                    self.name
6168                ),
6169            });
6170        }
6171        let recorded_moves =
6172            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6173
6174        // One `updateIssue` carries all three, because every mutation spends the secondary
6175        // limiter and the title, body and state are one mutation's inputs.
6176        let mut fields = serde_json::Map::new();
6177        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6178            fields.insert("title".to_owned(), json!(title));
6179        }
6180        if body != held {
6181            fields.insert("body".to_owned(), json!(body));
6182        }
6183        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6184            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6185        }
6186        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6187        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6188        // without undoing an earlier field when a later one fails — so a body written before a
6189        // board field the board then refused would be left changed. Written after every other
6190        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6191        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6192        // first, together in one request — a terminal option selected before the issue
6193        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6194        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6195        let mut clear: Option<(&BoardId, &str)> = None;
6196        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6197            board_writes.push((
6198                &moving.board,
6199                (
6200                    moving.field.clone(),
6201                    json!({"singleSelectOptionId": moving.option}),
6202                ),
6203            ));
6204        }
6205        match &priority_move {
6206            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6207                board,
6208                (field.clone(), json!({"singleSelectOptionId": option})),
6209            )),
6210            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6211            None => {}
6212        }
6213        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6214        boards.extend(clear.map(|(board, _)| board));
6215        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6216        for board in boards {
6217            let writes = board_writes
6218                .iter()
6219                .filter(|(on, _)| on.as_str() == board.as_str())
6220                .map(|(_, write)| write.clone())
6221                .collect::<Vec<_>>();
6222            let cleared = clear
6223                .filter(|(on, _)| on.as_str() == board.as_str())
6224                .map(|(_, field)| field);
6225            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6226                .await?;
6227        }
6228        let mut blocked_by_moved = false;
6229        if let Some((native, _)) = &edges
6230            && item.content_kind == ContentKind::Issue
6231        {
6232            blocked_by_moved = self
6233                .reconcile_blocked_by(
6234                    &item.id,
6235                    native,
6236                    Issue::Existing(item.blocked_by.as_deref()),
6237                )
6238                .await?;
6239        }
6240        if !fields.is_empty() {
6241            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6242                .await?;
6243        }
6244
6245        if let Some(title) = &update.title {
6246            item.title.clone_from(title);
6247        }
6248        item.body = visible.filter(|value| !value.is_empty());
6249        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6250        item.slot = slot;
6251        if let Some(delivers) = &update.delivers {
6252            item.delivers.clone_from(delivers);
6253        }
6254        if let Some(moving) = status_move {
6255            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6256                && item.content_kind == ContentKind::Issue;
6257            item.status = moving.landed;
6258            item.option = Some(moving.name);
6259        }
6260        if let Some((_, _, priority)) = priority_move {
6261            item.priority = HeldPriority::Read(priority);
6262        }
6263        let task = item.task()?;
6264        let mut written = update.changed(&before, &task);
6265        if blocked_by_moved || recorded_moves {
6266            written.insert(UpdatedField::DependsOn);
6267        }
6268        self.remember_written(item, false)?;
6269        Ok(Some(TaskUpdateOutcome {
6270            task,
6271            written,
6272            delivers_before: before.delivers,
6273        }))
6274    }
6275
6276    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6277    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6278    ///
6279    /// One update of the body: the content outside the slot, and inside it that one entry,
6280    /// every other entry kept as it was. This source keeps no template answers — an issue has
6281    /// no room beside itself that is not its body, and answers written there would duplicate
6282    /// what the content already says and count against GitHub's body limit — so `answers`
6283    /// reaches nothing here. A body that would not change is not sent at all.
6284    async fn replace_rendering(
6285        &self,
6286        id: &NativeId,
6287        kind: BoardKind,
6288        content: &str,
6289        provenance: &Value,
6290    ) -> Result<Option<()>, SourceError> {
6291        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6292            return Ok(None);
6293        };
6294        let held = item.raw_body.clone().unwrap_or_default();
6295        let mut slot = item.slot.clone();
6296        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6297        let body = with_slot(&with_content(&held, content)?, &slot)?;
6298        // Checked before anything is sent, as a content write checks it.
6299        let (visible, read) = metadata_body(Some(body.clone()))?;
6300        if visible.as_deref().unwrap_or_default() != content || read != slot {
6301            return Err(SourceError::Refused {
6302                message: format!(
6303                    "this content ends in what source {} reads as its own metadata slot \
6304                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6305                     as content; next: remove that trailing block from the template",
6306                    self.name
6307                ),
6308            });
6309        }
6310        if body != held {
6311            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6312                .await?;
6313        }
6314        item.body = visible.filter(|value| !value.is_empty());
6315        item.raw_body = Some(body);
6316        item.slot = read;
6317        self.remember_written(item, false)?;
6318        Ok(Some(()))
6319    }
6320
6321    async fn set_item_field(
6322        &self,
6323        board_id: &str,
6324        item_id: &str,
6325        field_id: &str,
6326        value: Value,
6327    ) -> Result<(), SourceError> {
6328        let data = self
6329            .graphql(
6330                graphql::UPDATE_FIELD,
6331                json!({"input":{
6332                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6333                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6334            )
6335            .await?;
6336        let returned = data
6337            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6338            .ok_or_else(|| SourceError::Malformed {
6339                message: "GitHub field update returned no project item".into(),
6340            })?;
6341        if required_str(returned, "id")? != item_id {
6342            return Err(SourceError::Malformed {
6343                message: "GitHub field update returned the wrong project item".into(),
6344            });
6345        }
6346        Ok(())
6347    }
6348
6349    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6350    /// one request. Every returned item id is checked, including optional aliases.
6351    async fn set_item_fields(
6352        &self,
6353        board: &str,
6354        item: &str,
6355        fields: &[(String, Value)],
6356        clear: Option<&str>,
6357    ) -> Result<(), SourceError> {
6358        if fields.len() <= 1 && clear.is_none() {
6359            if let Some((field, value)) = fields.first() {
6360                self.set_item_field(board, item, field, value.clone())
6361                    .await?;
6362            }
6363            return Ok(());
6364        }
6365        if fields.is_empty() {
6366            if let Some(field) = clear {
6367                self.write_priority(
6368                    board,
6369                    item,
6370                    &PriorityWrite::Clear {
6371                        field: field.to_owned(),
6372                    },
6373                )
6374                .await?;
6375            }
6376            return Ok(());
6377        }
6378        let input = |index: usize| {
6379            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6380            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6381        };
6382        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6383            "input":input(0),"second":input(1),"third":input(2),
6384            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6385            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6386        })).await?;
6387        for alias in [
6388            Some("updateProjectV2ItemFieldValue"),
6389            (fields.len() > 1).then_some("second"),
6390            (fields.len() > 2).then_some("third"),
6391            clear.map(|_| "cleared"),
6392        ]
6393        .into_iter()
6394        .flatten()
6395        {
6396            let returned = data
6397                .get(alias)
6398                .and_then(|value| value.get("projectV2Item"))
6399                .ok_or_else(|| SourceError::Malformed {
6400                    message: format!("GitHub field update {alias} returned no project item"),
6401                })?;
6402            if required_str(returned, "id")? != item {
6403                return Err(SourceError::Malformed {
6404                    message: format!("GitHub field update {alias} returned the wrong project item"),
6405                });
6406            }
6407        }
6408        Ok(())
6409    }
6410
6411    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6412        let mut after: Option<String> = None;
6413        let mut ids = Vec::new();
6414        loop {
6415            let data = self
6416                .graphql(
6417                    graphql::ISSUE_DEPENDENCIES,
6418                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6419                )
6420                .await?;
6421            let connection =
6422                data.pointer("/node/blockedBy")
6423                    .ok_or_else(|| SourceError::Malformed {
6424                        message: "GitHub dependency response has no blockedBy connection".into(),
6425                    })?;
6426            ids.extend(
6427                connection
6428                    .get("nodes")
6429                    .and_then(Value::as_array)
6430                    .ok_or_else(|| SourceError::Malformed {
6431                        message: "GitHub dependency response nodes is not an array".into(),
6432                    })?
6433                    .iter()
6434                    .map(|value| required_str(value, "id").map(str::to_owned))
6435                    .collect::<Result<Vec<_>, _>>()?,
6436            );
6437            let next = next_cursor(connection)?;
6438            if let Some(next) = &next {
6439                validate_cursor_progress(after.as_deref(), &next.0)?;
6440            }
6441            after = next.map(|cursor| cursor.0);
6442            if after.is_none() {
6443                return Ok(ids);
6444            }
6445        }
6446    }
6447
6448    async fn dependencies(
6449        &self,
6450        id: &NativeId,
6451        near_kind: ItemKind,
6452        direction: Direction,
6453        page: &PageRequest,
6454    ) -> Result<Page<DependencyEdge>, SourceError> {
6455        validate_page(page)?;
6456        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6457        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6458        let recorded = recorded_offset(cursor, direction)?;
6459        // What this issue is blocked by, when a read of it by its own id in this command
6460        // already carried the whole connection — a copy reads the item it writes before it
6461        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6462        // after it. Answered from that read, in the shape the dependency read answers in;
6463        // anything else is asked of GitHub.
6464        let carried = match direction {
6465            Direction::DependsOn => self
6466                .resolved_cache()?
6467                .get(id)
6468                .filter(|item| item.content_kind == ContentKind::Issue)
6469                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6470            Direction::DependedOnBy => None,
6471        }
6472        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6473        // Asked for even in the recorded phase, whose page reads nothing from the
6474        // connection: `__typename` is what says whether this item has a native
6475        // relationship at all, and that is what decides which far ends the reserved key is
6476        // allowed to hold.
6477        let data = match carried {
6478            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6479                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6480            None => {
6481                self.graphql(
6482                    graphql::ISSUE_DEPENDENCIES,
6483                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6484                           "after":if recorded.is_some() {None} else {cursor}}),
6485                )
6486                .await?
6487            }
6488        };
6489        let node =
6490            data.get("node")
6491                .filter(|v| !v.is_null())
6492                .ok_or_else(|| SourceError::Refused {
6493                    message: format!(
6494                        "GitHub item {} was not found or does not support dependencies",
6495                        id.0
6496                    ),
6497                })?;
6498        let connection_name = match direction {
6499            Direction::DependsOn => "blockedBy",
6500            Direction::DependedOnBy => "blocking",
6501        };
6502        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6503        // named natively and the reserved key may hold any far end. An issue's connections
6504        // hold issues, and this source reads them at the near item's own level.
6505        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6506        if let Some(offset) = recorded {
6507            return Ok(recorded_page(
6508                self.recorded_edges(id, near_kind, direction, natively_names, node)
6509                    .await?,
6510                offset,
6511                limit,
6512            ));
6513        }
6514        if natively_names.is_none() {
6515            return Ok(recorded_page(
6516                self.recorded_edges(id, near_kind, direction, natively_names, node)
6517                    .await?,
6518                0,
6519                limit,
6520            ));
6521        }
6522        let connection = node
6523            .get(connection_name)
6524            .ok_or_else(|| SourceError::Malformed {
6525                message: "GitHub dependency response is missing its connection".into(),
6526            })?;
6527        let nodes = connection
6528            .get("nodes")
6529            .and_then(Value::as_array)
6530            .ok_or_else(|| SourceError::Malformed {
6531                message: "GitHub dependency response nodes is not an array".into(),
6532            })?;
6533        // `from` depends on `to`, always. GitHub spells the same relationship from either
6534        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6535        // it — so the near item is `from` in one direction and `to` in the other.
6536        let items = nodes
6537            .iter()
6538            .map(|value| {
6539                let related = NativeId(required_str(value, "id")?.into());
6540                let related_kind = related_kind(value)?;
6541                let (from, to) = match direction {
6542                    Direction::DependsOn => (
6543                        DependencyEndpoint::from_native(id.clone(), near_kind),
6544                        DependencyEndpoint::from_native(related, related_kind),
6545                    ),
6546                    Direction::DependedOnBy => (
6547                        DependencyEndpoint::from_native(related, related_kind),
6548                        DependencyEndpoint::from_native(id.clone(), near_kind),
6549                    ),
6550                };
6551                Ok(DependencyEdge {
6552                    from,
6553                    to,
6554                    kind: DependencyKind::Blocks,
6555                })
6556            })
6557            .collect::<Result<Vec<_>, SourceError>>()?;
6558        let mut next = next_cursor(connection)?;
6559        if let Some(next) = &next {
6560            validate_cursor_progress(cursor, &next.0)?;
6561        }
6562        if next.is_none()
6563            && !self
6564                .recorded_edges(id, near_kind, direction, natively_names, node)
6565                .await?
6566                .is_empty()
6567        {
6568            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6569        }
6570        Ok(Page { items, next })
6571    }
6572
6573    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6574    /// a far end in another source has to live: no GitHub issue relationship can name one.
6575    ///
6576    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6577    /// source never writes one down.
6578    ///
6579    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6580    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6581    /// request beyond the read already made, and reading the board for them would be a
6582    /// walk of every item for one field of one. A draft has no body in that answer, because
6583    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6584    /// listing of the board, which can be behind on the very item asked about.
6585    async fn recorded_edges(
6586        &self,
6587        id: &NativeId,
6588        near_kind: ItemKind,
6589        direction: Direction,
6590        natively_names: Option<ItemKind>,
6591        node: &Value,
6592    ) -> Result<Vec<DependencyEdge>, SourceError> {
6593        if direction != Direction::DependsOn {
6594            return Ok(Vec::new());
6595        }
6596        let slot = match node.get("body") {
6597            Some(body) if natively_names.is_some() => {
6598                metadata_body(body.as_str().map(str::to_owned))?.1
6599            }
6600            _ => {
6601                let Some(item) = self.bound_item(id).await? else {
6602                    return Ok(Vec::new());
6603                };
6604                item.slot
6605            }
6606        };
6607        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6608            .map_err(|message| SourceError::Malformed { message })
6609    }
6610
6611    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6612        self.repository
6613            .as_ref()
6614            .ok_or_else(|| SourceError::Refused {
6615                message: format!(
6616                    "source {} has no repository configured, and a GitHub Projects board has no \
6617                 repository of its own to create an issue in; set repository: owner/name on \
6618                 this source",
6619                    self.name
6620                ),
6621            })
6622    }
6623
6624    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6625    /// states.
6626    ///
6627    /// The fallback is demanded first, whichever arm answers: a write without a configured
6628    /// repository is refused naming the field exactly as it was before the rule existed,
6629    /// so a source that could not write before cannot write now, rather than writing for
6630    /// the one item whose own field happens to decide it.
6631    ///
6632    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6633    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6634    /// entry owned by someone other than the owner of the parent issue's repository —
6635    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6636    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6637    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6638    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6639    /// and is visible to the token is checked where its node id is resolved, still before
6640    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6641    /// looked up in a listing of the board, which can be minutes behind an issue its own
6642    /// `projectItems` already places on it — and that read answers first from this process's
6643    /// own record, so a project created moments ago in this command answers though GitHub
6644    /// has not caught up.
6645    async fn creation_target(
6646        &self,
6647        incoming: &Incoming<'_>,
6648    ) -> Result<RepositoryTarget, SourceError> {
6649        let fallback = self.configured_repository()?;
6650        let what = |incoming: &Incoming<'_>| {
6651            format!(
6652                "{} {:?}",
6653                incoming.written.kind().describes(),
6654                incoming.title
6655            )
6656        };
6657        let parent = match incoming.parent {
6658            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6659                SourceError::Refused {
6660                    message: format!(
6661                        "GitHub project issue {} was not found on the board of source {}, so {} \
6662                         cannot be filed under it",
6663                        parent.0,
6664                        self.name,
6665                        what(incoming)
6666                    ),
6667                }
6668            })?),
6669            None => None,
6670        };
6671        let parents_repository = parent
6672            .as_ref()
6673            .map(|parent| {
6674                // A draft is on the board and so is found, but it has no repository to
6675                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6676                // would refuse the task only once `createIssue` had made it.
6677                if parent.content_kind == ContentKind::DraftIssue {
6678                    return Err(SourceError::Refused {
6679                        message: format!(
6680                            "GitHub project item {} on the board of source {} is a draft, \
6681                             which cannot have sub-issues, so {} cannot be filed under it",
6682                            parent.id.0,
6683                            self.name,
6684                            what(incoming)
6685                        ),
6686                    });
6687                }
6688                // An issue's repository is where a sub-issue is placed and whose owner it
6689                // is compared against, so a parent whose repository this source cannot
6690                // spell as `owner/name` — GitHub's login grammar is wider than this
6691                // source's floor — is one nothing can be filed under.
6692                parent
6693                    .own_repository
6694                    .as_ref()
6695                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6696                    .ok_or_else(|| SourceError::Malformed {
6697                        message: format!(
6698                            "GitHub project issue {} on the board of source {} is in {}, which \
6699                             is not a {}/owner/name repository this source can place {} in",
6700                            parent.id.0,
6701                            self.name,
6702                            parent
6703                                .own_repository
6704                                .as_ref()
6705                                .map_or("no repository", Repository::as_str),
6706                            RepositoryTarget::HOST,
6707                            what(incoming)
6708                        ),
6709                    })
6710            })
6711            .transpose()?;
6712        match incoming.repositories {
6713            [named] => {
6714                let target =
6715                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6716                        message: format!(
6717                            "{} names repository {}, which is not a {}/owner/name repository \
6718                             source {} can create an issue in; name one that is, or name none",
6719                            what(incoming),
6720                            named.as_str(),
6721                            RepositoryTarget::HOST,
6722                            self.name
6723                        ),
6724                    })?;
6725                if let Some(parents) = &parents_repository
6726                    && parents.owner != target.owner
6727                {
6728                    return Err(SourceError::Refused {
6729                        message: format!(
6730                            "{} names repository {}, owned by {}, but its project's issue is in \
6731                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6732                             of the same owner as its parent issue; name a repository of {}, or \
6733                             name none",
6734                            what(incoming),
6735                            target.slug(),
6736                            target.owner,
6737                            parents.slug(),
6738                            parents.owner,
6739                            parents.owner
6740                        ),
6741                    });
6742                }
6743                Ok(target)
6744            }
6745            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6746        }
6747    }
6748
6749    /// The node id of the repository `incoming` is being created in, or the refusal naming
6750    /// the item and the repository the token cannot see.
6751    ///
6752    /// Resolved once per command per repository; see [`Self::repository_cache`].
6753    async fn repository_id(
6754        &self,
6755        repository: &RepositoryTarget,
6756        incoming: &Incoming<'_>,
6757    ) -> Result<String, SourceError> {
6758        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6759            return Ok(id);
6760        }
6761        let data = self
6762            .graphql(
6763                graphql::REPOSITORY,
6764                json!({"owner":repository.owner,"name":repository.name}),
6765            )
6766            .await?;
6767        self.repository_read(&data, repository, incoming)
6768    }
6769
6770    /// The repository's node id out of an answer carrying the `repository` root, held for
6771    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6772    fn repository_read(
6773        &self,
6774        data: &Value,
6775        repository: &RepositoryTarget,
6776        incoming: &Incoming<'_>,
6777    ) -> Result<String, SourceError> {
6778        let node = data
6779            .get("repository")
6780            .filter(|value| !value.is_null())
6781            .ok_or_else(|| SourceError::Refused {
6782                message: format!(
6783                    "GitHub repository {} was not found or is not visible to the token, so {} \
6784                     {:?} cannot be created in it",
6785                    repository.slug(),
6786                    incoming.written.kind().describes(),
6787                    incoming.title
6788                ),
6789            })?;
6790        let id = required_str(node, "id")?.to_owned();
6791        self.repository_cache()?
6792            .insert(repository.clone(), id.clone());
6793        Ok(id)
6794    }
6795
6796    fn repository_cache(
6797        &self,
6798    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6799        self.repository_cache
6800            .lock()
6801            .map_err(|_| SourceError::Unavailable {
6802                message: "this source's record of the destination repository was left \
6803                          inconsistent by an earlier failure; next: run the command again"
6804                    .into(),
6805            })
6806    }
6807
6808    /// Create or update one board item, whichever kind it is.
6809    async fn write_item(
6810        &self,
6811        incoming: &Incoming<'_>,
6812        target: Option<&NativeId>,
6813        depends_on: &[DependencyEdge],
6814    ) -> Result<NativeId, SourceError> {
6815        // Refused before anything is read or written: a task or a project titled the way
6816        // this board spells a document would land as an issue this same source reads back
6817        // as a document, so the field this destination cannot carry is named rather than
6818        // written and silently reclassified.
6819        if let Written::Work(kind, _) = incoming.written
6820            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6821        {
6822            return Err(SourceError::Refused {
6823                message: format!(
6824                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6825                     spells a document, so it would read back as one rather than as a {}; \
6826                     retitle it, or copy it as a document",
6827                    kind.marker(),
6828                    self.name,
6829                    kind.marker()
6830                ),
6831            });
6832        }
6833        // The destination is read by its own id, and whether this board holds it is decided
6834        // by that read — its own `projectItems` — rather than by whether a listing of the
6835        // board happens to include it yet. See the module documentation.
6836        let existing = match target {
6837            Some(target) => {
6838                Some(
6839                    self.bound_item(target)
6840                        .await?
6841                        .ok_or_else(|| SourceError::Refused {
6842                            message: format!("GitHub destination item {} was not found", target.0),
6843                        })?,
6844                )
6845            }
6846            None => None,
6847        };
6848        let existing = existing.as_ref();
6849        // An existing issue is never moved; a new one is created where the rule says — and
6850        // knowing where is what lets the board's fields and that repository's id be read
6851        // together, before anything below needs either.
6852        let creation_target = match existing {
6853            Some(_) => None,
6854            None => {
6855                let target = self.creation_target(incoming).await?;
6856                self.creation_context(&target, incoming).await?;
6857                Some(target)
6858            }
6859        };
6860        let board = self
6861            .fields_for(
6862                existing,
6863                incoming.written.status().is_some(),
6864                incoming
6865                    .priority
6866                    .is_some_and(|priority| priority != Priority::None),
6867            )
6868            .await?;
6869        let status_target = incoming
6870            .written
6871            .work_status()
6872            .map(|(kind, status)| self.resolved_target(kind, status.category))
6873            .transpose()?;
6874        let column = match (incoming.written.work_status(), status_target.as_ref()) {
6875            (Some((kind, status)), Some(target)) => {
6876                self.column_for(&board.fields, kind, status.category, target)?
6877            }
6878            _ => None,
6879        };
6880        // Resolved before anything is created, for the reason the column above is: a
6881        // priority this board has no option for is refused while nothing has been written.
6882        let priority_write = match incoming.priority {
6883            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6884            None => None,
6885        };
6886        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6887        if content_kind == ContentKind::DraftIssue {
6888            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6889                (status_target.as_ref(), incoming.written.status())
6890            {
6891                return Err(self.closes_a_draft(status.category));
6892            }
6893            if incoming.parent.is_some() {
6894                return Err(SourceError::Refused {
6895                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6896                });
6897            }
6898        }
6899        match existing {
6900            Some(item) if content_kind == ContentKind::Issue => {
6901                if item.labels != incoming.labels {
6902                    return Err(SourceError::Refused {
6903                        message: "GitHub issue labels differ from the labels being written".into(),
6904                    });
6905                }
6906            }
6907            _ => {
6908                if !incoming.labels.is_empty() {
6909                    return Err(SourceError::Refused {
6910                        message: "GitHub items created by this destination carry no labels".into(),
6911                    });
6912                }
6913            }
6914        }
6915
6916        // The repository the issue really lives in is what the slot below is written against,
6917        // so a single entry that is where the issue is created travels as no key at all, and
6918        // the read side derives it back from the issue.
6919        let own_repository = match (existing, &creation_target) {
6920            (Some(item), _) => item.own_repository.clone(),
6921            (None, Some(target)) => Some(
6922                Repository::try_from(target.origin())
6923                    .map_err(|message| SourceError::Config { message })?,
6924            ),
6925            (None, None) => None,
6926        };
6927        let (native, fallback) = self
6928            .partition_edges(
6929                incoming.written.kind(),
6930                content_kind,
6931                existing.and_then(|item| item.blocked_by.as_deref()),
6932                depends_on,
6933            )
6934            .await?;
6935        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6936        let body = compose_body(incoming.content, &slot)?;
6937        // Read before anything is created, for the reason the field below is: a value
6938        // this destination cannot store has to refuse, and refusing after `createIssue`
6939        // would leave an issue behind that nothing asked for. The engine writes a
6940        // qualified id here; a caller handing this key anything else is told so rather
6941        // than having it silently stored as no origin at all.
6942        // 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.
6943        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6944            None => "",
6945            Some(Value::String(origin)) => origin.as_str(),
6946            Some(other) => {
6947                return Err(SourceError::Refused {
6948                    message: format!(
6949                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6950                         is {other}"
6951                    ),
6952                });
6953            }
6954        };
6955        // Resolved before anything is created: a board that cannot carry the copy origin
6956        // has to refuse the write, and refusing it after `createIssue` would leave an
6957        // issue behind that nothing asked for.
6958        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6959            Some(field) => {
6960                if required_str(field, "__typename")? != "ProjectV2Field" {
6961                    return Err(SourceError::Refused {
6962                        message: format!(
6963                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6964                        ),
6965                    });
6966                }
6967                Some(required_str(field, "id")?.to_owned())
6968            }
6969            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6970                return Err(SourceError::Refused {
6971                    message: format!(
6972                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6973                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6974                         the board"
6975                    ),
6976                });
6977            }
6978            None => None,
6979        };
6980
6981        let Landed {
6982            content_id,
6983            item_id,
6984            url,
6985            number,
6986        } = match existing {
6987            // Its content is written last, below, once everything else has landed.
6988            Some(item) => Landed {
6989                content_id: item.id.clone(),
6990                item_id: item.item_id.clone(),
6991                url: item.url.clone(),
6992                number: item.number,
6993            },
6994            None => {
6995                let target = creation_target
6996                    .as_ref()
6997                    .ok_or_else(|| SourceError::Malformed {
6998                        message: "a new item was decided without a repository to create it in"
6999                            .into(),
7000                    })?;
7001                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7002                    .await?
7003            }
7004        };
7005
7006        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7007        let column = column
7008            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7009            .map(|(field, option, _)| (field, option));
7010        // Creating an item here is several calls — `createIssue`, which files it on the
7011        // board, then its board fields, the parent and the dependencies — and GitHub can fail
7012        // at any of them. Everything this source can refuse *before* the first of those is
7013        // already checked above, so what is left is GitHub itself failing part way. When it
7014        // does over an item this call created, the issue is taken back: a write that
7015        // refused must not leave an item behind that nobody asked for, and one that does
7016        // makes the retry create a second.
7017        // Whether the board-field write carrying a moved origin was answered as landing whole.
7018        // When it was refused, GitHub does not say which of its fields ran before the one that
7019        // failed, so the origin may or may not have moved.
7020        let mut origin_landed = false;
7021        let landed = self
7022            .finish_write(
7023                board.id.as_str(),
7024                incoming,
7025                &content_id,
7026                &item_id,
7027                content_kind,
7028                existing,
7029                origin_field.as_deref(),
7030                origin,
7031                column,
7032                status_target.as_ref(),
7033                priority_write.as_ref(),
7034                &native,
7035                &mut origin_landed,
7036            )
7037            .await;
7038        // An existing item's title, body and state go last, in one `updateIssue`, once its board
7039        // fields and its relationships have landed: a refusal of any of those then leaves its
7040        // body — and the metadata slot inside it — exactly as it stood.
7041        let landed = match (landed, existing) {
7042            (Ok(()), Some(item)) => {
7043                self.update_existing(item, incoming, &body, status_target.as_ref())
7044                    .await
7045            }
7046            (landed, _) => landed,
7047        };
7048        if let Err(error) = landed {
7049            match existing {
7050                // Best effort, and the write's own failure is what the caller is told: a
7051                // refusal naming the tidy-up would hide why the write failed at all.
7052                None => {
7053                    let _ = self.delete_issue(&content_id).await;
7054                }
7055                // The origin field is the one piece of an existing item's metadata written
7056                // before its body, so a write refused after it puts it back as it was. When
7057                // that is refused too, the write's own failure is still what the caller is
7058                // told — with what it left behind added, because the item's metadata is then
7059                // not as it stood and a caller retrying has to know which key moved.
7060                Some(item) => {
7061                    let before = item.origin.as_deref().unwrap_or("");
7062                    if let Some(field) = origin_field.as_deref()
7063                        && before != origin
7064                        && let Err(restore) = self
7065                            .set_item_field(
7066                                board.id.as_str(),
7067                                &item.item_id,
7068                                field,
7069                                json!({"text": before}),
7070                            )
7071                            .await
7072                    {
7073                        let left = if origin_landed {
7074                            format!(
7075                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7076                                 not be put back to {before:?} ({restore}), so item {} still \
7077                                 holds {origin:?} there",
7078                                item.id.0
7079                            )
7080                        } else {
7081                            format!(
7082                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7083                                 {origin:?}, GitHub does not say whether that part of it ran, \
7084                                 and putting it back to {before:?} was refused ({restore}), so \
7085                                 item {} holds {origin:?} or {before:?} there",
7086                                item.id.0
7087                            )
7088                        };
7089                        return Err(noting(
7090                            error,
7091                            &format!(
7092                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7093                                 run the write again"
7094                            ),
7095                        ));
7096                    }
7097                }
7098            }
7099            return Err(error);
7100        }
7101
7102        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7103            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7104                self.statuses
7105                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7106            }
7107            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7108                self.statuses
7109                    .status(kind, written_option.as_deref(), false, None)
7110            }
7111            (Some((_, status)), _) => status.clone(),
7112            (None, _) => Status {
7113                category: StatusCategory::Unknown,
7114                name: "Open".to_owned(),
7115            },
7116        };
7117
7118        // So the rest of this command reads what it just did rather than what the board
7119        // said before it. See `remember_written` for which half takes it.
7120        let remembered = Resolved {
7121            item_id,
7122            id: content_id.clone(),
7123            content_kind,
7124            kind: incoming.written.kind(),
7125            title: incoming.title.to_owned(),
7126            // The visible half of the body this write composed, split back off it the
7127            // way a read splits it — so what this record reports is what a read of the
7128            // same issue reports, rather than the person's text with the metadata slot
7129            // still on the end of it.
7130            body: metadata_body(body.clone())?.0,
7131            raw_body: body.clone(),
7132            // A document has no status of its own; what it reads back as is whatever
7133            // the issue's own state says, which is what a re-read reports.
7134            status: written_status,
7135            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7136            priority: match incoming.priority {
7137                Some(priority) => HeldPriority::Read(priority),
7138                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7139                    item.priority.clone()
7140                }),
7141            },
7142            // What `state_input` asked for: closed for a terminal target, open for any other
7143            // status, and the issue's own state left as it was by a document write.
7144            closed: content_kind == ContentKind::Issue
7145                && match status_target.as_ref() {
7146                    Some(StatusTarget::Terminal(_, _)) => true,
7147                    Some(_) => false,
7148                    None => existing.is_some_and(|item| item.closed),
7149                },
7150            delivers: incoming.delivers.to_vec(),
7151            delivered_by: incoming.delivered_by.to_vec(),
7152            labels: incoming.labels.to_vec(),
7153            parent: incoming.parent.cloned(),
7154            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7155            number,
7156            // In the update path this is the item's own url, read off `existing` where the
7157            // record above was bound, so one expression serves both halves.
7158            url,
7159            created_at: existing.and_then(|item| item.created_at),
7160            updated_at: existing.and_then(|item| item.updated_at),
7161            own_repository,
7162            repositories: incoming.repositories.to_vec(),
7163            slot,
7164            board_id: Some(board.id.as_str().to_owned()),
7165            fields: board
7166                .fields
7167                .get("nodes")
7168                .and_then(Value::as_array)
7169                .cloned()
7170                .unwrap_or_default(),
7171            board_fields: Some(board.fields.clone()),
7172            // What this write left the relationship holding is known by id alone, and a
7173            // later read of its edges needs each far end's kind, so it reads them again.
7174            blocked_by: None,
7175        };
7176        self.remember_written(remembered, existing.is_none())?;
7177        Ok(content_id)
7178    }
7179
7180    /// Everything a write does after the item exists: its board fields, its parent, and
7181    /// its dependencies.
7182    ///
7183    /// Split out of `write_item` so there is one place a failure past the point of no
7184    /// return is caught, rather than a tidy-up repeated at each `?` above.
7185    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7186    // so there is one place a failure past the point of no return is caught, and its
7187    // arguments are exactly the values that tail already had in scope. Bundling them into a
7188    // struct would describe no concept — it would be "the arguments of this function" — and
7189    // would put the whole of `write_item`'s locals behind one more indirection.
7190    #[allow(clippy::too_many_arguments)]
7191    async fn finish_write(
7192        &self,
7193        board_id: &str,
7194        incoming: &Incoming<'_>,
7195        content_id: &NativeId,
7196        item_id: &str,
7197        content_kind: ContentKind,
7198        existing: Option<&Resolved>,
7199        origin_field: Option<&str>,
7200        origin: &str,
7201        column: Option<(String, String)>,
7202        status_target: Option<&StatusTarget>,
7203        priority: Option<&PriorityWrite>,
7204        native: &[String],
7205        origin_landed: &mut bool,
7206    ) -> Result<(), SourceError> {
7207        let mut fields = Vec::new();
7208        if let Some(field_id) = origin_field
7209            && existing.map_or(!origin.is_empty(), |item| {
7210                item.origin.as_deref().unwrap_or("") != origin
7211            })
7212        {
7213            fields.push((field_id.to_owned(), json!({"text":origin})));
7214        }
7215        if let Some((field_id, option_id)) = column {
7216            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7217        }
7218        let clear = match priority {
7219            Some(PriorityWrite::Select { field, option }) => {
7220                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7221                None
7222            }
7223            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7224            None => None,
7225        };
7226        self.set_item_fields(board_id, item_id, &fields, clear)
7227            .await?;
7228        *origin_landed = true;
7229
7230        // An existing issue closes in the `updateIssue` its write ends with; one created just
7231        // now closes here, once its option is selected.
7232        if existing.is_none()
7233            && content_kind == ContentKind::Issue
7234            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7235        {
7236            self.update_content(
7237                ContentKind::Issue,
7238                content_id,
7239                json!({"stateInput":state_input(status_target)}),
7240            )
7241            .await?;
7242        }
7243
7244        if content_kind == ContentKind::Issue {
7245            self.reparent(
7246                existing.and_then(|item| item.parent.clone()),
7247                content_id,
7248                incoming.parent,
7249            )
7250            .await?;
7251            // A document takes part in no dependency graph, so writing one neither reads
7252            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7253            // against the empty list a document write carries would *delete* whatever
7254            // relationships a person had made on that issue, which is a write nobody
7255            // asked for.
7256            if incoming.written.kind() != BoardKind::Document {
7257                let issue = match existing {
7258                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7259                    None => Issue::Created,
7260                };
7261                self.reconcile_blocked_by(content_id, native, issue).await?;
7262            }
7263        }
7264        Ok(())
7265    }
7266
7267    /// Delete one issue, which takes its board item with it.
7268    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7269        let data = self
7270            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7271            .await?;
7272        data.pointer("/deleteIssue/repository")
7273            .filter(|value| !value.is_null())
7274            .ok_or_else(|| SourceError::Malformed {
7275                message: "GitHub issue deletion returned no repository".into(),
7276            })?;
7277        self.forget(id)?;
7278        Ok(())
7279    }
7280
7281    /// Remove one item this copy created, so a copy that could not finish leaves the board
7282    /// as it found it.
7283    ///
7284    /// Deleting the issue takes its board item with it, so there is no second mutation to
7285    /// keep in step. An id the board does not hold is not an error: the item is already
7286    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7287    /// by its own id — a listing of the board can still be missing an item it holds, and
7288    /// reading that as *already gone* would leave behind the very item this was asked to
7289    /// take back.
7290    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7291        let Some(item) = self.bound_item(id).await? else {
7292            return Ok(());
7293        };
7294        if item.content_kind == ContentKind::DraftIssue {
7295            return Err(SourceError::Refused {
7296                message: format!(
7297                    "GitHub item {} is a draft, and this source removes an item by deleting \
7298                     its issue; next: remove it from the board by hand",
7299                    id.0
7300                ),
7301            });
7302        }
7303        let data = self
7304            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7305            .await?;
7306        data.pointer("/deleteIssue/repository")
7307            .filter(|value| !value.is_null())
7308            .ok_or_else(|| SourceError::Malformed {
7309                message: "GitHub issue deletion returned no repository".into(),
7310            })?;
7311        self.forget(id)?;
7312        Ok(())
7313    }
7314
7315    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7316    /// task.
7317    ///
7318    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7319    /// read of the task cannot disagree about which ids name one: a project or a document of
7320    /// this board is not a task here either.
7321    ///
7322    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7323    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7324    /// which would read as a task nobody has commented on yet.
7325    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7326        let cached = self.resolved_cache()?.get(task).cloned();
7327        let Some(item) = (match cached {
7328            Some(item) => Some(item),
7329            None => self.item_by_id(task).await?,
7330        })
7331        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7332            return Ok(None);
7333        };
7334        if item.content_kind == ContentKind::DraftIssue {
7335            return Err(self.draft_has_no_comments(task));
7336        }
7337        Ok(Some(item.id))
7338    }
7339
7340    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7341    /// issues, and a draft is not one.
7342    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7343        SourceError::Refused {
7344            message: format!(
7345                "task {} of source {} is a draft item on the board, and GitHub keeps \
7346                 comments on issues alone, so a draft has none to read or write; next: \
7347                 convert the draft to an issue on the board, then comment on the issue it \
7348                 becomes",
7349                task.0, self.name
7350            ),
7351        }
7352    }
7353
7354    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7355    /// request — or `None` when this board holds no task by that id.
7356    ///
7357    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7358    /// is answered with the draft and the refusal, at the price of the draft's own read.
7359    async fn issue_detail(
7360        &self,
7361        id: &NativeId,
7362        page: &PageRequest,
7363    ) -> Result<Option<TaskDetailRead>, SourceError> {
7364        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7365        let asked = self
7366            .graphql(
7367                graphql::ISSUE_DETAIL,
7368                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7369                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7370                       "duplicates":true}),
7371            )
7372            .await;
7373        let data = match asked {
7374            Ok(data) => data,
7375            Err(error) if unresolvable_node(&error) => return Ok(None),
7376            Err(error) => return Err(error),
7377        };
7378        // `node` is null for an id that names nothing, and absent only from an answer this
7379        // source cannot read — never the same thing.
7380        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7381            message: format!("GitHub answered the read of {} with no node", id.0),
7382        })?;
7383        self.detail_of(id, node, true, after).await
7384    }
7385
7386    /// Several tasks, each with the first page of its comments when `comments` is set, read
7387    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7388    /// order.
7389    ///
7390    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7391    /// one item at a time, so that id is answered as missing and the others as themselves; any
7392    /// other refusal is every id of that batch's answer.
7393    async fn issue_details(
7394        &self,
7395        ids: &[NativeId],
7396        comments: Option<&PageRequest>,
7397    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7398        let mut read = Vec::with_capacity(ids.len());
7399        for batch in ids.chunks(DETAIL_BATCH) {
7400            match self
7401                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7402                .await
7403            {
7404                Ok(data) => {
7405                    for (slot, id) in batch.iter().enumerate() {
7406                        // Every alias asked for is answered, null for an id naming nothing;
7407                        // one missing is an answer this source cannot read.
7408                        let read_one = match data.get(format!("i{slot}")) {
7409                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7410                            None => Err(SourceError::Malformed {
7411                                message: format!(
7412                                    "GitHub answered a batch read with no item for {}",
7413                                    id.0
7414                                ),
7415                            }),
7416                        };
7417                        read.push(read_one);
7418                    }
7419                }
7420                Err(error) if unresolvable_node(&error) => {
7421                    for id in batch {
7422                        read.push(match comments {
7423                            Some(page) => self.issue_detail(id, page).await,
7424                            None => self.task_read(id).await,
7425                        });
7426                    }
7427                }
7428                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7429            }
7430        }
7431        read
7432    }
7433
7434    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7435    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7436        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7437            task,
7438            comments: None,
7439        }))
7440    }
7441
7442    /// What one node a detail read reached says: the task this board holds by `id`, with the
7443    /// page of comments the node carries when `commented` — or `None` for a node that is no
7444    /// task of this board.
7445    ///
7446    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7447    /// and an item this process created answers from this process's own record, which a node
7448    /// read taken moments after the write can still be behind.
7449    async fn detail_of(
7450        &self,
7451        id: &NativeId,
7452        node: &Value,
7453        commented: bool,
7454        after: Option<&str>,
7455    ) -> Result<Option<TaskDetailRead>, SourceError> {
7456        if node.is_null() {
7457            return Ok(None);
7458        }
7459        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7460        // An issue answered under one id is that id's, or the answer is not one this source
7461        // can report: reporting another issue's task and comments under the qualified id asked
7462        // for would be the one wrong answer here. A draft's own read checks the same.
7463        if !draft
7464            && optional_str(node, "__typename")? == Some("Issue")
7465            && required_str(node, "id")? != id.0
7466        {
7467            return Err(SourceError::Malformed {
7468                message: format!(
7469                    "GitHub answered the read of {} with issue {}",
7470                    id.0,
7471                    required_str(node, "id")?
7472                ),
7473            });
7474        }
7475        let item = if draft {
7476            self.draft_by_id(id).await?
7477        } else {
7478            self.resolve_issue(node).await?
7479        };
7480        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7481            return Ok(None);
7482        };
7483        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7484        let task = own.unwrap_or(item).task()?;
7485        let comments = match (commented, draft) {
7486            (false, _) => None,
7487            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7488            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7489        };
7490        Ok(Some(TaskDetailRead { task, comments }))
7491    }
7492
7493    /// Whether the comment `comment` is one of `issue`'s own.
7494    ///
7495    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7496    /// comment's id and nothing else: a comment id given against the wrong task would
7497    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7498    /// names something that is not an issue comment, is a comment this task does not have —
7499    /// which is what GitHub refusing to resolve it means too.
7500    async fn comment_is_on(
7501        &self,
7502        issue: &NativeId,
7503        comment: &NativeId,
7504    ) -> Result<bool, SourceError> {
7505        let asked = self
7506            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7507            .await;
7508        let data = match asked {
7509            Ok(data) => data,
7510            Err(error) if unresolvable_node(&error) => return Ok(false),
7511            Err(error) => return Err(error),
7512        };
7513        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7514            return Ok(false);
7515        };
7516        if optional_str(node, "__typename")? != Some("IssueComment") {
7517            return Ok(false);
7518        }
7519        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7520            message: format!("GitHub issue comment {} names no issue", comment.0),
7521        })?;
7522        Ok(required_str(on, "id")? == issue.0)
7523    }
7524
7525    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7526    async fn partition_edges(
7527        &self,
7528        near_kind: BoardKind,
7529        near_content: ContentKind,
7530        carried: Option<&[Value]>,
7531        depends_on: &[DependencyEdge],
7532    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7533        let mut native = Vec::new();
7534        let mut fallback = Vec::new();
7535        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7536            .iter()
7537            .map(|edge| {
7538                let same_source = edge
7539                    .to
7540                    .source()
7541                    .is_none_or(|source| source == self.name.as_str());
7542                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7543                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7544                // colons of its own, so the far end is everything after that one separator.
7545                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7546                let far_id = if edge.to.is_qualified() {
7547                    edge.to
7548                        .id()
7549                        .split_once(':')
7550                        .map_or(edge.to.id(), |(_, native)| native)
7551                } else {
7552                    edge.to.id()
7553                };
7554                // One that already blocks the near issue was answered by that issue's own
7555                // read, which carried each of its blockers' kinds — an issue every one — so it
7556                // is not read again.
7557                let blocking = carried.and_then(|nodes| {
7558                    nodes
7559                        .iter()
7560                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7561                });
7562                (edge, far_id, same_source, blocking)
7563            })
7564            .collect();
7565        // Every other same-source far end is read by its own id, exactly as the item it is a
7566        // far end of is: whether this board holds it is that read's answer, never a listing's.
7567        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7568        let mut unread: Vec<NativeId> = Vec::new();
7569        for (_, far_id, same_source, blocking) in &far_ends {
7570            let id = NativeId((*far_id).to_owned());
7571            if *same_source && blocking.is_none() && !unread.contains(&id) {
7572                unread.push(id);
7573            }
7574        }
7575        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7576            .iter()
7577            .cloned()
7578            .zip(self.items_by_ids(&unread).await?)
7579            .collect();
7580        for (edge, far_id, same_source, blocking) in far_ends {
7581            let far = match (same_source, blocking) {
7582                (false, _) => None,
7583                (true, Some(node)) => Some(FarEnd {
7584                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7585                        BoardKind::Document
7586                    } else {
7587                        BoardKind::Work(related_kind(node)?)
7588                    },
7589                    content_kind: ContentKind::Issue,
7590                }),
7591                (true, None) => {
7592                    let read = read
7593                        .get(&NativeId(far_id.to_owned()))
7594                        .cloned()
7595                        .flatten()
7596                        .ok_or_else(|| SourceError::Refused {
7597                            message: format!("GitHub dependency item {far_id} was not found"),
7598                        })?;
7599                    Some(FarEnd {
7600                        kind: read.kind,
7601                        content_kind: read.content_kind,
7602                    })
7603                }
7604            };
7605            let far = far.as_ref();
7606            // The caller says which kind the far end is, and this board holds the far end
7607            // itself, so a disagreement is settled here rather than stored: recorded, the
7608            // wrong kind would read back as a cross-level edge that never existed; written
7609            // natively, it would name a relationship of a different level than the caller
7610            // asked for.
7611            //
7612            // A far end this board holds as a *document* fails the same comparison and is
7613            // refused by the same sentence: `ItemKind` has no document variant because
7614            // nothing may point at one, so no caller can name it correctly and the refusal
7615            // is the only honest answer.
7616            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7617                return Err(SourceError::Refused {
7618                    message: format!(
7619                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7620                         names it as a {}; record the kind it is",
7621                        disagreeing.kind.describes(),
7622                        edge.to.kind.marker()
7623                    ),
7624                });
7625            }
7626            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7627            // however the far end is spelled — and one classified native here would be
7628            // written nowhere at all, because a draft's native reconciliation never runs.
7629            let native_here = near_content == ContentKind::Issue
7630                && far.is_some_and(|far| {
7631                    far.content_kind == ContentKind::Issue
7632                        && BoardKind::Work(edge.to.kind) == near_kind
7633                });
7634            if native_here {
7635                native.push(far_id.to_owned());
7636            } else {
7637                fallback.push(edge.clone());
7638            }
7639        }
7640        Ok((native, fallback))
7641    }
7642
7643    async fn update_existing(
7644        &self,
7645        item: &Resolved,
7646        incoming: &Incoming<'_>,
7647        body: &Option<String>,
7648        status_target: Option<&StatusTarget>,
7649    ) -> Result<(), SourceError> {
7650        let title = incoming.written_title();
7651        // A terminal status closes the issue here, in the same mutation as its body: its board
7652        // option was selected before this, so a close never lands on an item whose board cannot
7653        // show it.
7654        let fields = match item.content_kind {
7655            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7656            ContentKind::Issue => json!({"title":title,"body":body,
7657                                         "stateInput":state_input(status_target)}),
7658        };
7659        self.update_content(item.content_kind, &item.id, fields)
7660            .await
7661    }
7662
7663    /// Update one board item's content with exactly `fields` beside its id, through the
7664    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7665    /// a draft.
7666    ///
7667    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7668    /// is what lets a narrow write carry the one thing it changes and nothing else.
7669    async fn update_content(
7670        &self,
7671        kind: ContentKind,
7672        id: &NativeId,
7673        fields: Value,
7674    ) -> Result<(), SourceError> {
7675        let (operation, id_key, pointer) = match kind {
7676            ContentKind::DraftIssue => (
7677                graphql::UPDATE_DRAFT,
7678                "draftIssueId",
7679                "/updateProjectV2DraftIssue/draftIssue",
7680            ),
7681            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7682        };
7683        let mut input = fields;
7684        input[id_key] = json!(id.0);
7685        let data = self.graphql(operation, json!({"input":input})).await?;
7686        let returned = data
7687            .pointer(pointer)
7688            .ok_or_else(|| SourceError::Malformed {
7689                message: "GitHub item update returned no item".into(),
7690            })?;
7691        if required_str(returned, "id")? != id.0 {
7692            return Err(SourceError::Malformed {
7693                message: "GitHub item update returned the wrong item".into(),
7694            });
7695        }
7696        Ok(())
7697    }
7698
7699    /// Creates one issue, files it on the board, and reports what a read of it would say:
7700    /// its content id, its board item id, and the web address GitHub gave it.
7701    ///
7702    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7703    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7704    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7705    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7706    /// "Content already exists in this project". A terminal status is not written here:
7707    /// `finish_write` selects its option first and closes the issue after, so a close never
7708    /// lands on an item whose board cannot show it.
7709    ///
7710    /// The address and the number come back here because this is the only place either is
7711    /// known before GitHub's own board read catches up — an item this run created answers
7712    /// the reads that follow it out of the record below, and one remembered without them
7713    /// would report no location and no key for the rest of the run.
7714    async fn create_and_file_issue(
7715        &self,
7716        board_id: &str,
7717        repository: &RepositoryTarget,
7718        incoming: &Incoming<'_>,
7719        body: &Option<String>,
7720    ) -> Result<Landed, SourceError> {
7721        let repository_id = self.repository_id(repository, incoming).await?;
7722        let data = self
7723            .graphql(
7724                graphql::CREATE_ISSUE,
7725                json!({"input":{
7726                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7727                }}),
7728            )
7729            .await?;
7730        let created = data
7731            .pointer("/createIssue/issue")
7732            .filter(|value| !value.is_null())
7733            .ok_or_else(|| SourceError::Malformed {
7734                message: "GitHub issue creation returned no issue".into(),
7735            })?;
7736        let content_id = NativeId(required_str(created, "id")?.to_owned());
7737        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7738        // a response without it is not worth failing a landed write over — the item simply
7739        // reports no location until the board read catches up, which is what it did before.
7740        let url = optional_str(created, "url")?.map(str::to_owned);
7741        // The issue exists from here on, so an unreadable number and a refused board
7742        // filing below each try, best effort, to take it back: an issue in the repository
7743        // that is on no board is an item nobody asked for and nothing here would find again.
7744        //
7745        // Its number is optional on the same terms its address is — a landed write is not
7746        // worth failing over a member that came back missing, and such an item reports no
7747        // handle until a board read catches up. A number that is *present* and is not an
7748        // unsigned integer is still a response this source cannot read.
7749        let number = match created_issue_number(created) {
7750            Ok(number) => number,
7751            Err(error) => {
7752                let _ = self.delete_issue(&content_id).await;
7753                return Err(error);
7754            }
7755        };
7756        let added = match self
7757            .graphql(
7758                graphql::ADD_TO_BOARD,
7759                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7760            )
7761            .await
7762        {
7763            Ok(added) => added,
7764            Err(error) => {
7765                let _ = self.delete_issue(&content_id).await;
7766                return Err(error);
7767            }
7768        };
7769        let item = added
7770            .pointer("/addProjectV2ItemById/item")
7771            .filter(|value| !value.is_null())
7772            .ok_or_else(|| SourceError::Malformed {
7773                message: "GitHub board addition returned no project item".into(),
7774            })?;
7775        Ok(Landed {
7776            content_id,
7777            item_id: required_str(item, "id")?.to_owned(),
7778            url,
7779            number,
7780        })
7781    }
7782
7783    /// Move one issue under the project it now belongs to, or out of the one it left.
7784    async fn reparent(
7785        &self,
7786        held: Option<NativeId>,
7787        child: &NativeId,
7788        wanted: Option<&NativeId>,
7789    ) -> Result<(), SourceError> {
7790        if held.as_ref() == wanted {
7791            return Ok(());
7792        }
7793        if let Some(held) = &held {
7794            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7795                .await?;
7796        }
7797        if let Some(wanted) = wanted {
7798            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7799                .await?;
7800        }
7801        Ok(())
7802    }
7803
7804    async fn sub_issue(
7805        &self,
7806        operation: &str,
7807        parent: &NativeId,
7808        child: &NativeId,
7809        root: &str,
7810    ) -> Result<(), SourceError> {
7811        let data = self
7812            .graphql(
7813                operation,
7814                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7815            )
7816            .await?;
7817        let issue =
7818            data.pointer(&format!("/{root}/issue"))
7819                .ok_or_else(|| SourceError::Malformed {
7820                    message: "GitHub sub-issue update returned no issue".into(),
7821                })?;
7822        let sub =
7823            data.pointer(&format!("/{root}/subIssue"))
7824                .ok_or_else(|| SourceError::Malformed {
7825                    message: "GitHub sub-issue update returned no sub-issue".into(),
7826                })?;
7827        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7828            return Err(SourceError::Malformed {
7829                message: "GitHub sub-issue update returned the wrong issues".into(),
7830            });
7831        }
7832        Ok(())
7833    }
7834
7835    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7836    /// whether there was one.
7837    ///
7838    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7839    /// relationships are not read: there is nothing a read of them could find.
7840    async fn reconcile_blocked_by(
7841        &self,
7842        content_id: &NativeId,
7843        native: &[String],
7844        issue: Issue<'_>,
7845    ) -> Result<bool, SourceError> {
7846        let current = match issue {
7847            Issue::Created => Vec::new(),
7848            Issue::Existing(Some(held)) => held
7849                .iter()
7850                .map(|far| required_str(far, "id").map(str::to_owned))
7851                .collect::<Result<Vec<_>, _>>()?,
7852            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7853        };
7854        let mut changed = false;
7855        for (operation, far_id) in current
7856            .iter()
7857            .filter(|id| !native.contains(id))
7858            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7859            .chain(
7860                native
7861                    .iter()
7862                    .filter(|id| !current.contains(id))
7863                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7864            )
7865        {
7866            let data = self
7867                .graphql(
7868                    operation,
7869                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7870                )
7871                .await?;
7872            let root = if operation == graphql::ADD_BLOCKED_BY {
7873                "addBlockedBy"
7874            } else {
7875                "removeBlockedBy"
7876            };
7877            let issue =
7878                data.pointer(&format!("/{root}/issue"))
7879                    .ok_or_else(|| SourceError::Malformed {
7880                        message: "GitHub dependency update returned no issue".into(),
7881                    })?;
7882            let blocker = data
7883                .pointer(&format!("/{root}/blockingIssue"))
7884                .ok_or_else(|| SourceError::Malformed {
7885                    message: "GitHub dependency update returned no blocking issue".into(),
7886                })?;
7887            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7888            {
7889                return Err(SourceError::Malformed {
7890                    message: "GitHub dependency update returned the wrong issues".into(),
7891                });
7892            }
7893            changed = true;
7894        }
7895        Ok(changed)
7896    }
7897}
7898
7899/// What a write needs to know of one far end it names: which kind of item it is, and whether
7900/// it is an issue a native relationship can name.
7901struct FarEnd {
7902    kind: BoardKind,
7903    content_kind: ContentKind,
7904}
7905
7906/// Whether the issue one write reconciles was created by that write or was already there.
7907#[derive(Clone, Copy, PartialEq, Eq)]
7908enum Issue<'a> {
7909    /// Created by this write, so it holds no relationships yet.
7910    Created,
7911    /// On the board before this write, holding whatever relationships it holds — the far
7912    /// ends of its whole `blockedBy`, when the read that reached it carried them.
7913    Existing(Option<&'a [Value]>),
7914}
7915
7916/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7917enum Reached {
7918    /// An issue this board holds, resolved into everything this source reports about it.
7919    Held(Box<Resolved>),
7920    /// Nothing this board holds: no such node, or a node on some other board.
7921    Nothing,
7922    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7923    /// again by [`GitHubProjectsSource::draft_by_id`].
7924    Draft,
7925}
7926
7927/// What GitHub says when a string is not a node id it can resolve.
7928///
7929/// Matched because it is the ordinary answer to a project selector naming a project by its
7930/// *name*, and reporting that as a failure would make naming one impossible. It is read
7931/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7932/// does not define the syntax of a GitHub node id and would be wrong about it.
7933const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7934
7935/// `error` with `note` added to the end of what it says, its kind and every other member
7936/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7937/// what that failure left behind.
7938fn noting(error: SourceError, note: &str) -> SourceError {
7939    match error {
7940        SourceError::Config { message } => SourceError::Config {
7941            message: message + note,
7942        },
7943        SourceError::Auth { message } => SourceError::Auth {
7944            message: message + note,
7945        },
7946        SourceError::Refused { message } => SourceError::Refused {
7947            message: message + note,
7948        },
7949        SourceError::RateLimited {
7950            retry_after_seconds,
7951            message,
7952        } => SourceError::RateLimited {
7953            retry_after_seconds,
7954            message: Some(message.unwrap_or_default() + note),
7955        },
7956        SourceError::Unavailable { message } => SourceError::Unavailable {
7957            message: message + note,
7958        },
7959        SourceError::Malformed { message } => SourceError::Malformed {
7960            message: message + note,
7961        },
7962    }
7963}
7964
7965/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7966/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7967/// for them.
7968///
7969/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7970/// is read again at no added price.
7971fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7972    let mut variables = serde_json::Map::new();
7973    for slot in 0..DETAIL_BATCH {
7974        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7975        variables.insert(format!("id{slot}"), json!(id));
7976    }
7977    variables.insert(
7978        "first".to_owned(),
7979        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7980    );
7981    variables.insert("comments".to_owned(), json!(comments.is_some()));
7982    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7983    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7984    variables.insert("duplicates".to_owned(), json!(true));
7985    Value::Object(variables)
7986}
7987
7988/// Whether this refusal is GitHub saying the id names no node at all.
7989fn unresolvable_node(error: &SourceError) -> bool {
7990    matches!(error, SourceError::Refused { message }
7991        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7992}
7993
7994/// One project name, as a search qualifier which filters on it at the server.
7995///
7996/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7997/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7998/// the way it documents. A title matched here is still compared for equality afterwards:
7999/// the qualifier narrows what the server sends, and this source decides what it names.
8000fn title_qualifier(name: &str) -> String {
8001    format!("in:title {}", quoted(name))
8002}
8003
8004/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8005/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8006/// it documents — so a value holding a qualifier's spelling is searched for rather than
8007/// obeyed.
8008fn quoted(value: &str) -> String {
8009    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8010    format!("\"{escaped}\"")
8011}
8012
8013/// The search qualifier for the issues updated at or after `since`.
8014///
8015/// Written to the second, rounded down, which can only widen what the search returns.
8016fn updated_qualifier(since: DateTime<Utc>) -> String {
8017    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8018}
8019
8020/// The search terms that narrow a board-scoped issue search to a task query's text and
8021/// metadata predicates, or `None` when it carries neither.
8022///
8023/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8024/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8025/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8026/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8027/// a metadata value searches both fields for both — wider than asked, never narrower, and
8028/// every candidate is confirmed in process afterwards.
8029///
8030/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8031/// matches whole tokens where a substring rule would match inside a word, so an item holding
8032/// the text only inside a longer word is not returned. A text of nothing but whitespace
8033/// matches every item, so it narrows nothing and is not sent.
8034fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8035    let text = query
8036        .text
8037        .as_ref()
8038        .filter(|text| !text.terms.trim().is_empty());
8039    if text.is_none() && query.metadata.is_empty() {
8040        return None;
8041    }
8042    let (title, body) = match text.map(|text| text.fields) {
8043        None => (false, true),
8044        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8045        Some(TextFields::Content) => (false, true),
8046        Some(TextFields::TitleOrContent) => (true, true),
8047    };
8048    let fields = match (title, body) {
8049        (true, true) => "in:title,body",
8050        (true, false) => "in:title",
8051        _ => "in:body",
8052    };
8053    let phrases = text
8054        .map(|text| text.terms.clone())
8055        .into_iter()
8056        .chain(
8057            query
8058                .metadata
8059                .iter()
8060                .map(|wanted| as_stored(wanted.value())),
8061        )
8062        .map(|phrase| quoted(&phrase))
8063        .collect::<Vec<_>>();
8064    Some(format!("{fields} {}", phrases.join(" ")))
8065}
8066
8067/// The search terms that narrow a board-scoped issue search to a project or document query's
8068/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8069/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8070fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8071    narrowing_qualifiers(&TaskQuery {
8072        text: text.cloned(),
8073        ..TaskQuery::default()
8074    })
8075}
8076
8077/// Refuses a project or document query's text GitHub's issue search cannot find, before
8078/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8079/// query's.
8080fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8081    refuse_unsearchable(&TaskQuery {
8082        text: text.cloned(),
8083        ..TaskQuery::default()
8084    })
8085}
8086
8087/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8088/// before anything is asked of GitHub.
8089///
8090/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8091/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8092/// left out, the search is every issue of the board. So this source says it cannot answer
8093/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8094/// nothing GitHub could search for, and keeps the board read it always had.
8095fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8096    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8097                       letter or digit with a bounded query";
8098    if let Some(text) = &query.text
8099        && !text.terms.trim().is_empty()
8100        && !has_words(&text.terms)
8101    {
8102        return Err(SourceError::Refused {
8103            message: format!(
8104                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8105                text.terms
8106            ),
8107        });
8108    }
8109    if let Some(wanted) = query
8110        .metadata
8111        .iter()
8112        .find(|wanted| !has_words(wanted.value()))
8113    {
8114        return Err(SourceError::Refused {
8115            message: format!(
8116                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8117                wanted.value(),
8118                std::iter::once(wanted.key())
8119                    .chain(wanted.path().iter().map(String::as_str))
8120                    .collect::<Vec<_>>()
8121                    .join("/"),
8122            ),
8123        });
8124    }
8125    Ok(())
8126}
8127
8128/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8129fn has_words(phrase: &str) -> bool {
8130    phrase.chars().any(char::is_alphanumeric)
8131}
8132
8133/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8134///
8135/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8136/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8137/// which GitHub's word match would read as different words.
8138fn as_stored(value: &str) -> String {
8139    let encoded = Value::String(value.to_owned()).to_string();
8140    encoded[1..encoded.len() - 1].to_owned()
8141}
8142
8143/// The one narrower question a task query carrying a text, metadata or origin predicate is
8144/// sent as.
8145enum Narrowing {
8146    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8147    Origin(String),
8148    /// The board-scoped issue search narrowed by these qualifiers.
8149    Search(String),
8150}
8151
8152impl Narrowing {
8153    /// What this question is remembered under for the length of one command.
8154    fn key(&self) -> String {
8155        match self {
8156            Self::Origin(origin) => format!("origin {origin}"),
8157            Self::Search(also) => format!("search {also}"),
8158        }
8159    }
8160}
8161
8162/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8163enum Resumed {
8164    /// It reported another page, which starts after this cursor.
8165    More(String),
8166    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8167    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8168    /// can go on walking the other connection.
8169    Ended(Option<String>),
8170}
8171
8172impl Resumed {
8173    /// Whether the connection has another page.
8174    const fn has_more(&self) -> bool {
8175        matches!(self, Self::More(_))
8176    }
8177
8178    /// The cursor to send this connection next.
8179    fn cursor(self) -> Option<String> {
8180        match self {
8181            Self::More(next) => Some(next),
8182            Self::Ended(last) => last,
8183        }
8184    }
8185}
8186
8187/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8188/// with no cursor to it, or from a cursor that does not advance.
8189fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8190    let info = connection
8191        .get("pageInfo")
8192        .ok_or_else(|| SourceError::Malformed {
8193            message: "GitHub connection has no pageInfo".into(),
8194        })?;
8195    let end = optional_str(info, "endCursor")?;
8196    if required_bool(info, "hasNextPage")? {
8197        let next = end.ok_or_else(|| SourceError::Malformed {
8198            message: "GitHub connection reports another page and no endCursor".into(),
8199        })?;
8200        validate_cursor_progress(after, next)?;
8201        return Ok(Resumed::More(next.to_owned()));
8202    }
8203    Ok(Resumed::Ended(
8204        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8205    ))
8206}
8207
8208/// The board, and every item on it this source reports.
8209#[derive(Clone)]
8210struct Board {
8211    id: String,
8212    fields: Value,
8213    items: Vec<Resolved>,
8214}
8215
8216/// What a write needs of the board and nothing more: its node id and its field
8217/// definitions, in the shape a read of the board's own `fields` gives them.
8218///
8219/// Deliberately no items. A write decides which item it writes, which parent it files
8220/// under and which far ends it names by reading each of them by its own id; this is the
8221/// half of the board those reads cannot carry, and holding no item is what keeps it from
8222/// ever being asked whether an item is there.
8223#[derive(Clone)]
8224struct BoardFields {
8225    id: BoardId,
8226    fields: Value,
8227}
8228
8229/// A board's node id: what a field write and `addProjectV2ItemById` address.
8230///
8231/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8232/// refused where it is read, and one an item names blank is read as not named at all.
8233#[derive(Clone)]
8234struct BoardId(String);
8235
8236/// Where one write left its item, for the record the rest of the command reads it out of.
8237///
8238/// A named record rather than a tuple because the update arm and the create arm each fill
8239/// all four, and two `Option`s of different meaning side by side in a tuple are two
8240/// positions a reader has to count.
8241struct Landed {
8242    /// The issue's own node id, which is the [`NativeId`] this source reports.
8243    content_id: NativeId,
8244    /// The board item's id, which is what a field write addresses.
8245    // 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.
8246    item_id: String,
8247    /// The web address GitHub gave the issue, when it gave one.
8248    // 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.
8249    url: Option<String>,
8250    /// The issue's number on its repository, when GitHub reported one.
8251    number: Option<u64>,
8252}
8253
8254impl BoardId {
8255    fn parse(id: &str) -> Result<Self, SourceError> {
8256        if id.trim().is_empty() {
8257            return Err(SourceError::Malformed {
8258                message: "GitHub named a board with a blank node id".into(),
8259            });
8260        }
8261        Ok(Self(id.to_owned()))
8262    }
8263
8264    fn as_str(&self) -> &str {
8265        &self.0
8266    }
8267}
8268
8269impl Board {
8270    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8271        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8272        let nodes = fields
8273            .get("nodes")
8274            .and_then(Value::as_array)
8275            .ok_or_else(|| SourceError::Malformed {
8276                message: "GitHub project fields.nodes is not an array".into(),
8277            })?;
8278        Ok(nodes
8279            .iter()
8280            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8281    }
8282}
8283
8284/// One board item, resolved into everything this source reports about it.
8285#[derive(Clone)]
8286struct Resolved {
8287    item_id: String,
8288    id: NativeId,
8289    content_kind: ContentKind,
8290    kind: BoardKind,
8291    title: String,
8292    body: Option<String>,
8293    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8294    /// that changes the slot alone has to keep byte for byte outside it.
8295    raw_body: Option<String>,
8296    status: Status,
8297    /// The name of the board `Status` option this item sits in, as the board spells it.
8298    option: Option<String>,
8299    /// What its `Priority` field says, read through this instance's mapping.
8300    priority: HeldPriority,
8301    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8302    closed: bool,
8303    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8304    delivers: Vec<TaskRef>,
8305    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8306    /// task.
8307    delivered_by: Vec<TaskRef>,
8308    labels: Vec<Label>,
8309    parent: Option<NativeId>,
8310    // 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.
8311    origin: Option<String>,
8312    /// The issue's own number on its repository, as GitHub reports it.
8313    ///
8314    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8315    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8316    /// an issue this run created whose creating mutation answered without one, which is a
8317    /// response GitHub's own schema says cannot happen and which a landed write is not
8318    /// worth failing over. An `Issue` read off the board always has one.
8319    number: Option<u64>,
8320    url: Option<String>,
8321    created_at: Option<DateTime<Utc>>,
8322    updated_at: Option<DateTime<Utc>>,
8323    own_repository: Option<Repository>,
8324    repositories: Vec<Repository>,
8325    slot: BTreeMap<String, Value>,
8326    /// The node id of the board this item sits on, when the read that reached it said.
8327    board_id: Option<String>,
8328    /// The definition of every board field this item holds a value of, in the shape a read
8329    /// of the board's own `fields` gives one.
8330    ///
8331    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8332    /// which says nothing about whether the board has it.
8333    fields: Vec<Value>,
8334    /// Every field the board this item sits on defines, as its own read of the board's
8335    /// `fields` gives them — when the read that reached the item carried them, which a read
8336    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8337    /// of the board.
8338    board_fields: Option<Value>,
8339    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8340    /// selects one — when the read that reached it carried the connection to its end, which a
8341    /// read of it by its own id does for any issue blocked by no more than a page. What a
8342    /// write reconciles that relationship against, and what a read of its forward edges in
8343    /// the same command answers with.
8344    blocked_by: Option<Vec<Value>>,
8345}
8346
8347impl Resolved {
8348    /// The board this item's own read names it on, when that read named one this source can
8349    /// address.
8350    fn named_board(&self) -> Option<BoardId> {
8351        self.board_id
8352            .as_deref()
8353            .and_then(|id| BoardId::parse(id).ok())
8354    }
8355
8356    /// The board's id and every field it defines, when the read that reached this item
8357    /// carried both — which a read of it by its own id does.
8358    fn carried_board(&self) -> Option<BoardFields> {
8359        Some(BoardFields {
8360            id: self.named_board()?,
8361            fields: self.board_fields.clone()?,
8362        })
8363    }
8364
8365    /// Whether this item holds a value of the board field called `name`, and so carries
8366    /// that field's definition. `false` says nothing about whether the board has the field.
8367    fn defines(&self, name: &str) -> bool {
8368        self.fields
8369            .iter()
8370            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8371    }
8372
8373    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8374    /// in a field of its own, and none of the five keys that are only an encoding.
8375    ///
8376    /// The two delivery keys are left out for every kind, not only for a task: they are
8377    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8378    /// document carrying one holds nothing a caller's own metadata could mean by it.
8379    fn metadata(&self) -> BTreeMap<String, Value> {
8380        let mut metadata = self.slot.clone();
8381        metadata.remove(Repository::METADATA_KEY);
8382        metadata.remove(DependencyEdge::RECORDED_KEY);
8383        metadata.remove(ItemKind::METADATA_KEY);
8384        metadata.remove(TaskRef::DELIVERS_KEY);
8385        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8386        // The board field is the origin, and the body's copy of it is only a mirror for the
8387        // issue search to find: an item whose field holds none has none, whatever its body
8388        // says, so no reader ever sees two answers.
8389        metadata.remove(ORIGIN_KEY);
8390        if let Some(origin) = &self.origin {
8391            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8392        }
8393        metadata
8394    }
8395
8396    /// Where this item is, as a link a reader can open.
8397    ///
8398    /// A board is a hosted place and every issue on it has a web address, so that address
8399    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8400    /// of place it is, so a reader knows to open it rather than to read a file out. It
8401    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8402    /// reported before, and this says what that address *is*.
8403    ///
8404    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8405    /// rather than a third variant, which is the contract's "the source did not say". An
8406    /// issue this run created is not one of those: its address comes back from the
8407    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8408    /// rather than from whenever the board read catches up.
8409    fn location(&self) -> Option<Location> {
8410        self.url.clone().map(Location::Url)
8411    }
8412
8413    /// The short handle this board's backend shows people for a task: the issue's number
8414    /// alone, as a decimal string.
8415    ///
8416    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8417    /// value for this backend. A draft has no number and so no handle, which is the
8418    /// contract's *absent* rather than a handle of some other shape — and the native
8419    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8420    /// derives from.
8421    fn key(&self) -> Option<String> {
8422        self.number.map(|number| number.to_string())
8423    }
8424
8425    /// Whether its `Priority` field holds a value at all, mapped or not.
8426    fn holds_priority(&self) -> bool {
8427        self.priority != HeldPriority::Read(Priority::None)
8428    }
8429
8430    /// The task this item is.
8431    ///
8432    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8433    /// reading that as a level would be a guess, and reading it as `none` would let the next
8434    /// copy clear a priority a person set.
8435    fn task(&self) -> Result<Task, SourceError> {
8436        let priority = match &self.priority {
8437            HeldPriority::Read(priority) => *priority,
8438            HeldPriority::Unmapped(option) => {
8439                return Err(SourceError::Malformed {
8440                    message: format!(
8441                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8442                         this source's priority_mapping does not name, so its priority cannot be \
8443                         read; next: name {option:?} under priority_mapping, or move the item to \
8444                         a mapped option",
8445                        self.id,
8446                        self.number
8447                            .map(|number| format!(" (#{number})"))
8448                            .unwrap_or_default()
8449                    ),
8450                });
8451            }
8452        };
8453        Ok(Task {
8454            id: self.id.clone(),
8455            key: self.key(),
8456            title: self.title.clone(),
8457            content: self.body.clone(),
8458            status: self.status.clone(),
8459            priority,
8460            labels: self.labels.clone(),
8461            project: self.parent.clone(),
8462            url: self.url.clone(),
8463            location: self.location(),
8464            created_at: self.created_at,
8465            updated_at: self.updated_at,
8466            metadata: self.metadata(),
8467            repositories: self.repositories.clone(),
8468            delivers: self.delivers.clone(),
8469            delivered_by: self.delivered_by.clone(),
8470        })
8471    }
8472
8473    fn project(&self) -> Project {
8474        Project {
8475            id: self.id.clone(),
8476            title: self.title.clone(),
8477            content: self.body.clone(),
8478            status: self.status.clone(),
8479            labels: self.labels.clone(),
8480            url: self.url.clone(),
8481            location: self.location(),
8482            created_at: self.created_at,
8483            updated_at: self.updated_at,
8484            metadata: self.metadata(),
8485            repositories: self.repositories.clone(),
8486        }
8487    }
8488
8489    /// The same issue as a document: the project it is filed under, and no status and no
8490    /// dependencies, because a document is not work.
8491    fn document(&self) -> Document {
8492        Document {
8493            id: self.id.clone(),
8494            title: self.title.clone(),
8495            content: self.body.clone(),
8496            project: self.parent.clone(),
8497            labels: self.labels.clone(),
8498            url: self.url.clone(),
8499            location: self.location(),
8500            created_at: self.created_at,
8501            updated_at: self.updated_at,
8502            metadata: self.metadata(),
8503            repositories: self.repositories.clone(),
8504        }
8505    }
8506}
8507
8508/// Where one targeted update moves an item's status, and which of its two halves move.
8509struct StatusMove {
8510    /// The board the item's `Status` field is on.
8511    board: BoardId,
8512    /// The `Status` field's id.
8513    field: String,
8514    /// The option's id.
8515    option: String,
8516    /// The option's name, as the board spells it.
8517    name: String,
8518    /// What the status asks of the issue's state.
8519    target: StatusTarget,
8520    /// The status the item reads as once it is there.
8521    landed: Status,
8522    /// Which of the status's two halves differ from what the item holds.
8523    moves: Moves,
8524}
8525
8526/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8527/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8528/// and is not a value of this type.
8529#[derive(Clone, Copy, PartialEq, Eq)]
8530enum Moves {
8531    /// The option alone.
8532    Option,
8533    /// The issue's state alone: open, closed, or closed with another reason.
8534    State,
8535    /// Both.
8536    Both,
8537}
8538
8539impl Moves {
8540    /// What differs, or `None` when nothing does.
8541    const fn of(option: bool, state: bool) -> Option<Self> {
8542        match (option, state) {
8543            (true, true) => Some(Self::Both),
8544            (true, false) => Some(Self::Option),
8545            (false, true) => Some(Self::State),
8546            (false, false) => None,
8547        }
8548    }
8549
8550    /// Whether the option moves.
8551    const fn option(self) -> bool {
8552        matches!(self, Self::Option | Self::Both)
8553    }
8554
8555    /// Whether the issue's state moves.
8556    const fn state(self) -> bool {
8557        matches!(self, Self::State | Self::Both)
8558    }
8559}
8560
8561/// What one write is, and the status that comes with being it.
8562///
8563/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8564/// status and a task or a project always has one, so "a document carrying a status" and
8565/// "a task carrying none" are states a write cannot be in rather than states every use
8566/// site below has to defend against.
8567enum Written<'a> {
8568    /// A document, which is not work and so has no status at all.
8569    Document,
8570    /// A task or a project, and the status it is being written with.
8571    Work(ItemKind, &'a Status),
8572}
8573
8574impl Written<'_> {
8575    /// Which of the board's three kinds this write is.
8576    const fn kind(&self) -> BoardKind {
8577        match self {
8578            Self::Document => BoardKind::Document,
8579            Self::Work(kind, _) => BoardKind::Work(*kind),
8580        }
8581    }
8582
8583    /// The status this write carries. A document carries none, so a write of one says
8584    /// nothing about the issue's open or closed state and selects no board `Status`
8585    /// option.
8586    const fn status(&self) -> Option<&Status> {
8587        match self {
8588            Self::Document => None,
8589            Self::Work(_, status) => Some(status),
8590        }
8591    }
8592
8593    /// The status this write carries with the kind whose half of `status_mapping` it is
8594    /// written through.
8595    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8596        match self {
8597            Self::Document => None,
8598            Self::Work(kind, status) => Some((*kind, status)),
8599        }
8600    }
8601}
8602
8603/// The item being written, in the one shape all three write methods reach.
8604struct Incoming<'a> {
8605    written: Written<'a>,
8606    /// The title a person wrote. A document's goes onto the issue with
8607    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8608    title: &'a str,
8609    content: Option<&'a str>,
8610    labels: &'a [Label],
8611    metadata: &'a BTreeMap<String, Value>,
8612    repositories: &'a [Repository],
8613    parent: Option<&'a NativeId>,
8614    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8615    /// what keeps either key out of their slot.
8616    delivers: &'a [TaskRef],
8617    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8618    delivered_by: &'a [TaskRef],
8619    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8620    /// project, a document, and every write to an instance with no `priority_mapping` —
8621    /// which is what keeps such a write's requests exactly what they were before.
8622    priority: Option<Priority>,
8623}
8624
8625/// What one write does to an item's `Priority` field.
8626enum PriorityWrite {
8627    /// Select this option of this field.
8628    Select {
8629        /// The `Priority` field's id.
8630        field: String,
8631        /// The mapped option's id.
8632        option: String,
8633    },
8634    /// Clear the field's value, which is what `none` is.
8635    Clear {
8636        /// The `Priority` field's id.
8637        field: String,
8638    },
8639}
8640
8641impl Incoming<'_> {
8642    /// The title this write puts on the issue.
8643    fn written_title(&self) -> String {
8644        match self.written {
8645            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8646            Written::Work(..) => self.title.to_owned(),
8647        }
8648    }
8649}
8650
8651#[derive(Clone, Copy, PartialEq, Eq)]
8652enum ContentKind {
8653    DraftIssue,
8654    Issue,
8655}
8656
8657/// What one board issue is: a document, or the work an [`ItemKind`] names.
8658///
8659/// A type of this source's own rather than an `ItemKind` with a third variant, because
8660/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8661/// document — the contract keeps a document out of that enum deliberately. Holding the
8662/// board's three answers in one value is what makes every place that asks "which is this?"
8663/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8664/// two thirds of the board.
8665#[derive(Clone, Copy, PartialEq, Eq)]
8666enum BoardKind {
8667    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8668    Document,
8669    /// Every other issue, and every draft.
8670    Work(ItemKind),
8671}
8672
8673impl BoardKind {
8674    /// Whose half of `status_mapping` an item of this kind reads its status through. A
8675    /// document has no status of its own, so the task half stands in for whatever the issue
8676    /// holds; nothing reports it.
8677    const fn status_kind(self) -> ItemKind {
8678        match self {
8679            Self::Document => ItemKind::Task,
8680            Self::Work(kind) => kind,
8681        }
8682    }
8683
8684    /// How a refusal names this kind to the person reading it.
8685    const fn describes(self) -> &'static str {
8686        match self {
8687            Self::Document => "document",
8688            Self::Work(kind) => kind.marker(),
8689        }
8690    }
8691}
8692
8693/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8694///
8695/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8696/// the shared cross-source journeys assert one answer to one question, so two sources
8697/// that disagree about what "carries the label bug" means fail them.
8698fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8699    let holds = |name: &String| {
8700        labels
8701            .iter()
8702            .any(|label| label.name.eq_ignore_ascii_case(name))
8703    };
8704    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8705        && filter.all_of.iter().all(holds)
8706        && !filter.none_of.iter().any(holds)
8707}
8708
8709/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8710/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8711fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8712    statuses.is_empty() || statuses.contains(&category)
8713}
8714
8715/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8716///
8717/// `content` is the item's own prose — the body with this source's trailing metadata
8718/// comment already taken off — so a search never matches an encoding the author of the
8719/// issue never wrote.
8720fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8721    let terms = query.terms.to_lowercase();
8722    let in_title = title.to_lowercase().contains(&terms);
8723    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8724    match query.fields {
8725        TextFields::Title => in_title,
8726        TextFields::Content => in_content,
8727        TextFields::TitleOrContent => in_title || in_content,
8728    }
8729}
8730
8731/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8732///
8733/// The project predicate is passed separately because a read narrowed to one project has
8734/// already answered it by asking *that project* for its own items — and re-applying it
8735/// there would compare the caller's selector, which may be a project's **name**, against
8736/// the id of the project that name resolved to, and keep nothing. Every other read passes
8737/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8738/// source really does apply.
8739fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8740    labels_match(&task.labels, &query.labels)
8741        && status_matches(task.status.category, &query.statuses)
8742        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8743        && match project {
8744            ProjectFilter::Any => true,
8745            ProjectFilter::Orphans => task.project.is_none(),
8746            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8747        }
8748        && query
8749            .text
8750            .as_ref()
8751            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8752        // Against the parsed metadata slot, and against the origin field, which is where
8753        // `Resolved::metadata` reads each of them from.
8754        && query.metadata_matches(&task.metadata)
8755        && query.origin_matches(&task.metadata)
8756}
8757
8758fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8759    labels_match(&project.labels, &query.labels)
8760        && status_matches(project.status.category, &query.statuses)
8761        && query
8762            .text
8763            .as_ref()
8764            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8765}
8766
8767/// The same three predicates a task query carries, minus the status filter.
8768///
8769/// A document is not work, so it has no status for one to compare against and the query
8770/// type carries none. The project predicate is the same one — a design issue filed under a
8771/// project issue is in that project, and one filed under nothing is in none — so it is
8772/// spelled the same way here rather than answered differently.
8773fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8774    labels_match(&document.labels, &query.labels)
8775        && match project {
8776            ProjectFilter::Any => true,
8777            ProjectFilter::Orphans => document.project.is_none(),
8778            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8779        }
8780        && query
8781            .text
8782            .as_ref()
8783            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8784}
8785
8786#[async_trait::async_trait]
8787impl TaskSource for GitHubProjectsSource {
8788    fn kind(&self) -> &'static str {
8789        KIND
8790    }
8791    fn capabilities(&self) -> Capabilities {
8792        Capabilities {
8793            projects: Support::Native,
8794            documents: Support::Native,
8795            comments: Support::Native,
8796            priority: if self.priorities.is_some() {
8797                Support::Native
8798            } else {
8799                Support::Unsupported
8800            },
8801            filter_by_priority: Support::Native,
8802            filter_by_comment_activity: Support::Native,
8803            filter_by_metadata: Support::Native,
8804            filter_by_origin: Support::Native,
8805            orphan_tasks: Support::Native,
8806            filter_by_label: Support::Native,
8807            filter_by_status: Support::Native,
8808            search_title: Support::Native,
8809            search_content: Support::Native,
8810            task_dependencies: DependencySupport::BothDirections,
8811            project_dependencies: DependencySupport::BothDirections,
8812            max_page_size: MAX_PAGE_SIZE,
8813        }
8814    }
8815    async fn health(&self) -> Result<Health, SourceError> {
8816        let board = self.board_page(None, 1).await?;
8817        Ok(Health {
8818            reachable: true,
8819            detail: Some(format!(
8820                "reading GitHub project {}/{} ({})",
8821                self.owner,
8822                self.project_number,
8823                required_str(&board, "title")?
8824            )),
8825        })
8826    }
8827    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8828        self.item_by_id(id)
8829            .await?
8830            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8831            .map(|item| item.task())
8832            .transpose()
8833    }
8834    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8835        Ok(self
8836            .item_by_id(id)
8837            .await?
8838            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8839            .map(|item| item.project()))
8840    }
8841    async fn query_tasks(
8842        &self,
8843        query: &TaskQuery,
8844        page: &PageRequest,
8845    ) -> Result<Page<Task>, SourceError> {
8846        validate_page(page)?;
8847        refuse_unsearchable(query)?;
8848        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8849            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8850                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8851                (Some(also), None) => Some(also),
8852                (None, Some(since)) => Some(updated_qualifier(since)),
8853                (None, None) => None,
8854            };
8855            if let Some(also) = qualifiers {
8856                return self.search_tasks(query, page, &also).await;
8857            }
8858        }
8859
8860        // A read narrowed to one project asks that project for its own tasks, so nothing
8861        // about it costs what the rest of the board holds. A read carrying a text, metadata
8862        // or origin predicate asks GitHub the narrower question those predicates are, and a
8863        // read narrowed to comment activity alone asks the board's own issue search for the
8864        // issues updated since, which is every issue a comment could have been written or
8865        // edited on since. Every other task read is a question about the whole board and is
8866        // answered by reading it.
8867        let (held, membership) = match (&query.project, query.commented_since) {
8868            (ProjectFilter::Is(project), _) => (
8869                self.project_children(project).await?,
8870                // Answered by where these items came from; see `task_matches`.
8871                &ProjectFilter::Any,
8872            ),
8873            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8874                match (self.narrowed(query).await?, since) {
8875                    (Some(narrowed), _) => (narrowed, &query.project),
8876                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8877                    (None, None) => (self.board().await?.items, &query.project),
8878                }
8879            }
8880        };
8881        // Filtered before paged: a page of a filtered result is a page of the survivors,
8882        // never the survivors of a page.
8883        let mut tasks = Vec::new();
8884        for item in held
8885            .iter()
8886            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8887        {
8888            let task = item.task()?;
8889            if task_matches(&task, query, membership)
8890                && self.commented_since(item, query.commented_since).await?
8891            {
8892                tasks.push(task);
8893            }
8894        }
8895        Ok(offset_page(
8896            tasks,
8897            numeric_cursor(page.cursor.as_ref())?,
8898            page.limit.min(MAX_PAGE_SIZE) as usize,
8899        ))
8900    }
8901    async fn query_projects(
8902        &self,
8903        query: &ProjectQuery,
8904        page: &PageRequest,
8905    ) -> Result<Page<Project>, SourceError> {
8906        validate_page(page)?;
8907        refuse_unsearchable_text(query.text.as_ref())?;
8908        // The projects a board holds are found by an issue search scoped to that board,
8909        // never by walking the board's own item connection: what tells a project from a
8910        // task is the `parent` each issue carries, which costs nothing to read. A query
8911        // carrying a text asks that search for the text too, so it reads the issues that
8912        // hold it rather than every issue of the board.
8913        let held = match self.text_searched(query.text.as_ref()).await? {
8914            Some(searched) => searched,
8915            None => self.board_issues().await?,
8916        };
8917        let projects = held
8918            .iter()
8919            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8920            .map(Resolved::project)
8921            .filter(|project| project_matches(project, query))
8922            .collect();
8923        Ok(offset_page(
8924            projects,
8925            numeric_cursor(page.cursor.as_ref())?,
8926            page.limit.min(MAX_PAGE_SIZE) as usize,
8927        ))
8928    }
8929    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8930        Ok(self
8931            .item_by_id(id)
8932            .await?
8933            .filter(|item| item.kind == BoardKind::Document)
8934            .map(|item| item.document()))
8935    }
8936    async fn query_documents(
8937        &self,
8938        query: &DocumentQuery,
8939        page: &PageRequest,
8940    ) -> Result<Page<Document>, SourceError> {
8941        validate_page(page)?;
8942        // Narrowed to one project, this is the same sub-issue read a task list scoped to
8943        // that project makes — a document filed under a project is a sub-issue of it too,
8944        // and which of them come back is the kind this caller asked for. Unscoped, a query
8945        // carrying a text asks the board-scoped issue search for it, as a task query does,
8946        // and only one carrying none reads the board.
8947        let (held, membership) = match &query.project {
8948            ProjectFilter::Is(project) => (
8949                self.project_children(project).await?,
8950                // Answered by where these items came from; see `task_matches`.
8951                &ProjectFilter::Any,
8952            ),
8953            ProjectFilter::Any | ProjectFilter::Orphans => {
8954                refuse_unsearchable_text(query.text.as_ref())?;
8955                match self.text_searched(query.text.as_ref()).await? {
8956                    Some(searched) => (searched, &query.project),
8957                    None => (self.board().await?.items, &query.project),
8958                }
8959            }
8960        };
8961        // Filtered before paged, exactly as a task read is: a page of a filtered result is
8962        // a page of the survivors, never the survivors of a page.
8963        let documents = held
8964            .iter()
8965            .filter(|item| item.kind == BoardKind::Document)
8966            .map(Resolved::document)
8967            .filter(|document| document_matches(document, query, membership))
8968            .collect();
8969        Ok(offset_page(
8970            documents,
8971            numeric_cursor(page.cursor.as_ref())?,
8972            page.limit.min(MAX_PAGE_SIZE) as usize,
8973        ))
8974    }
8975    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8976        validate_page(page)?;
8977        let offset = numeric_cursor(page.cursor.as_ref())?;
8978        let mut labels = self
8979            .board()
8980            .await?
8981            .items
8982            .into_iter()
8983            .flat_map(|item| item.labels)
8984            .fold(Vec::new(), |mut all, label| {
8985                if !all.iter().any(|x: &Label| x.id == label.id) {
8986                    all.push(label);
8987                }
8988                all
8989            });
8990        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8991        Ok(offset_page(
8992            labels,
8993            offset,
8994            page.limit.min(MAX_PAGE_SIZE) as usize,
8995        ))
8996    }
8997    async fn task_dependencies(
8998        &self,
8999        id: &NativeId,
9000        direction: Direction,
9001        page: &PageRequest,
9002    ) -> Result<Page<DependencyEdge>, SourceError> {
9003        self.dependencies(id, ItemKind::Task, direction, page).await
9004    }
9005    async fn project_dependencies(
9006        &self,
9007        id: &NativeId,
9008        direction: Direction,
9009        page: &PageRequest,
9010    ) -> Result<Page<DependencyEdge>, SourceError> {
9011        self.dependencies(id, ItemKind::Project, direction, page)
9012            .await
9013    }
9014
9015    fn writes(&self) -> WriteSupport {
9016        WriteSupport::Supported
9017    }
9018
9019    /// Create or update one task.
9020    ///
9021    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9022    /// neither may name the task itself or name one task twice — and land in the body's
9023    /// metadata slot under their reserved keys, in place of any caller metadata of those
9024    /// names.
9025    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9026        let near = write.target.as_ref().unwrap_or(&write.item.id);
9027        for (key, entries) in [
9028            (TaskRef::DELIVERS_KEY, &write.item.delivers),
9029            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9030        ] {
9031            TaskRef::listed(key, near, Some(&self.name), entries.clone())
9032                .map_err(|message| SourceError::Refused { message })?;
9033        }
9034        if self.priorities.is_none() && write.item.priority != Priority::None {
9035            return Err(self.holds_no_priority());
9036        }
9037        self.write_item(
9038            &Incoming {
9039                written: Written::Work(ItemKind::Task, &write.item.status),
9040                title: &write.item.title,
9041                content: write.item.content.as_deref(),
9042                labels: &write.item.labels,
9043                metadata: &write.item.metadata,
9044                repositories: &write.item.repositories,
9045                parent: write.item.project.as_ref(),
9046                delivers: &write.item.delivers,
9047                delivered_by: &write.item.delivered_by,
9048                priority: self.priorities.as_ref().map(|_| write.item.priority),
9049            },
9050            write.target.as_ref(),
9051            &write.depends_on,
9052        )
9053        .await
9054    }
9055
9056    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9057        self.write_item(
9058            &Incoming {
9059                written: Written::Work(ItemKind::Project, &write.item.status),
9060                title: &write.item.title,
9061                content: write.item.content.as_deref(),
9062                labels: &write.item.labels,
9063                metadata: &write.item.metadata,
9064                repositories: &write.item.repositories,
9065                parent: None,
9066                delivers: &[],
9067                delivered_by: &[],
9068                priority: None,
9069            },
9070            write.target.as_ref(),
9071            &write.depends_on,
9072        )
9073        .await
9074    }
9075
9076    /// Create or update one document, which is one issue titled the way this board spells
9077    /// a document.
9078    ///
9079    /// Everything else is exactly a task write: caller metadata goes to the same canonical
9080    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9081    /// or a field this board cannot carry is refused by name rather than dropped, a target
9082    /// naming an issue this board does not hold is refused rather than created, and an
9083    /// issue this call created is taken back when the rest of the write fails.
9084    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9085        // A document takes part in no dependency graph, so there is no far end to write
9086        // natively and none to record: a caller naming one is told so rather than having it
9087        // stored under the reserved key, where a later read would report an edge the
9088        // contract says cannot exist.
9089        if !write.depends_on.is_empty() {
9090            return Err(SourceError::Refused {
9091                message: format!(
9092                    "this write names {} dependencies for a document, and a document takes \
9093                     part in no dependency graph; next: put the dependency on the task or \
9094                     project the document is about",
9095                    write.depends_on.len()
9096                ),
9097            });
9098        }
9099        self.write_item(
9100            &Incoming {
9101                written: Written::Document,
9102                title: &write.item.title,
9103                content: write.item.content.as_deref(),
9104                labels: &write.item.labels,
9105                metadata: &write.item.metadata,
9106                repositories: &write.item.repositories,
9107                parent: write.item.project.as_ref(),
9108                delivers: &[],
9109                delivered_by: &[],
9110                priority: None,
9111            },
9112            write.target.as_ref(),
9113            &[],
9114        )
9115        .await
9116    }
9117
9118    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9119    /// which reads nothing; then the board's `Status` option. Over an existing item that is
9120    /// read off the item, as the write reads it, and the item is held among this command's
9121    /// resolved records so the write that follows reuses that read rather than repeating it;
9122    /// an item that does not carry the field takes the board's fields, which are held once
9123    /// read. A create is checked against the board's fields only when this command already
9124    /// holds them, because a create reads them together with its repository, in one request,
9125    /// and refuses a missing option before it writes anything.
9126    async fn check_status_write(
9127        &self,
9128        kind: ItemKind,
9129        category: StatusCategory,
9130        target: Option<&NativeId>,
9131    ) -> Result<(), SourceError> {
9132        let status = self.resolved_target(kind, category)?;
9133        if status.option().is_none() {
9134            return Ok(());
9135        }
9136        let fields = match target {
9137            Some(target) => {
9138                // A target this board does not hold is the write's own refusal to make.
9139                let Some(item) = self.bound_item(target).await? else {
9140                    return Ok(());
9141                };
9142                self.resolved_cache()?.insert(target.clone(), item.clone());
9143                self.fields_for(Some(&item), true, false).await?.fields
9144            }
9145            None => {
9146                let held = self
9147                    .board_cache()?
9148                    .as_ref()
9149                    .map(|board| board.fields.clone());
9150                match held.or_else(|| {
9151                    self.fields_cache()
9152                        .ok()
9153                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9154                }) {
9155                    Some(fields) => fields,
9156                    None => return Ok(()),
9157                }
9158            }
9159        };
9160        self.column_for(&fields, kind, category, &status)
9161            .map(|_| ())
9162    }
9163
9164    /// Set one task's status alone.
9165    ///
9166    /// An open target reopens a closed issue with an `updateIssue` carrying only its
9167    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9168    /// terminal target selects its mapped option, then closes with its fixed reason. No
9169    /// request carries a title, a body or a label. The status
9170    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9171    /// what a re-read reports.
9172    async fn set_task_status(
9173        &self,
9174        id: &NativeId,
9175        category: StatusCategory,
9176    ) -> Result<Option<Status>, SourceError> {
9177        self.set_status(id, category).await
9178    }
9179
9180    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9181    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9182    /// for `none`. Refused by an instance with no `priority_mapping`.
9183    async fn set_task_priority(
9184        &self,
9185        id: &NativeId,
9186        priority: Priority,
9187    ) -> Result<Option<Priority>, SourceError> {
9188        self.set_priority(id, priority).await
9189    }
9190
9191    /// Replace one task's content with a single body update that keeps the metadata slot
9192    /// byte for byte.
9193    async fn set_task_content(
9194        &self,
9195        id: &NativeId,
9196        content: &str,
9197    ) -> Result<Option<()>, SourceError> {
9198        self.replace_content(id, content).await
9199    }
9200
9201    /// Replace one task issue's content and its provenance slot entry with a single body
9202    /// update. The answers are not kept: see `replace_rendering`.
9203    async fn set_task_rendering(
9204        &self,
9205        id: &NativeId,
9206        content: &str,
9207        provenance: &Value,
9208        _answers: &BTreeMap<String, Value>,
9209    ) -> Result<Option<()>, SourceError> {
9210        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9211            .await
9212    }
9213
9214    /// Replace one design-document issue's content and its provenance slot entry, on exactly
9215    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9216    async fn set_document_rendering(
9217        &self,
9218        id: &NativeId,
9219        content: &str,
9220        provenance: &Value,
9221        _answers: &BTreeMap<String, Value>,
9222    ) -> Result<Option<()>, SourceError> {
9223        self.replace_rendering(id, BoardKind::Document, content, provenance)
9224            .await
9225    }
9226
9227    /// Apply a targeted update with one read of the item and a write only for what differs:
9228    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9229    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9230    async fn update_task(
9231        &self,
9232        id: &NativeId,
9233        update: &TaskUpdate,
9234    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9235        self.targeted_update(id, update).await
9236    }
9237
9238    /// Replace one task's `delivered_by` with a single body update that changes the
9239    /// metadata slot and nothing outside it.
9240    async fn set_delivered_by(
9241        &self,
9242        id: &NativeId,
9243        delivered_by: &[TaskRef],
9244    ) -> Result<Option<()>, SourceError> {
9245        self.replace_delivered_by(id, delivered_by).await
9246    }
9247
9248    /// Set one key of one task issue's metadata with a single body update that changes the
9249    /// metadata slot and nothing outside it — no title, label, state or board field request —
9250    /// and sends nothing when the task already holds that value under the key.
9251    async fn set_task_metadata(
9252        &self,
9253        id: &NativeId,
9254        key: &MetadataKey,
9255        value: &Value,
9256    ) -> Result<Option<Task>, SourceError> {
9257        Ok(self
9258            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9259            .await?
9260            .map(|item| item.task())
9261            .transpose()?)
9262    }
9263
9264    /// Set one key of one project issue's metadata, on exactly the terms of
9265    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9266    async fn set_project_metadata(
9267        &self,
9268        id: &NativeId,
9269        key: &MetadataKey,
9270        value: &Value,
9271    ) -> Result<Option<Project>, SourceError> {
9272        Ok(self
9273            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9274            .await?
9275            .map(|item| item.project()))
9276    }
9277
9278    /// Set one key of one design-document issue's metadata, on exactly the terms of
9279    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9280    async fn set_document_metadata(
9281        &self,
9282        id: &NativeId,
9283        key: &MetadataKey,
9284        value: &Value,
9285    ) -> Result<Option<Document>, SourceError> {
9286        Ok(self
9287            .set_slot_key(id, BoardKind::Document, key, value)
9288            .await?
9289            .map(|item| item.document()))
9290    }
9291
9292    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9293        self.delete_item(id).await
9294    }
9295
9296    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9297        self.delete_item(id).await
9298    }
9299
9300    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9301        self.delete_item(id).await
9302    }
9303
9304    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9305    ///
9306    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9307    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9308    ///
9309    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9310    /// board is the read of its comments. A draft this process already resolved is refused
9311    /// without one.
9312    async fn task_comments(
9313        &self,
9314        task: &NativeId,
9315        page: &PageRequest,
9316    ) -> Result<Option<Page<Comment>>, SourceError> {
9317        validate_page(page)?;
9318        let cached = self.resolved_cache()?.get(task).cloned();
9319        if let Some(item) = cached {
9320            if item.kind != BoardKind::Work(ItemKind::Task) {
9321                return Ok(None);
9322            }
9323            if item.content_kind == ContentKind::DraftIssue {
9324                return Err(self.draft_has_no_comments(task));
9325            }
9326        }
9327        match self.issue_detail(task, page).await? {
9328            Some(TaskDetailRead {
9329                comments: Some(comments),
9330                ..
9331            }) => comments,
9332            _ => Ok(None),
9333        }
9334    }
9335
9336    /// Every id's task, with the first page of its comments when `comments` names it:
9337    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9338    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9339    async fn get_task_details(
9340        &self,
9341        ids: &[NativeId],
9342        comments: Option<&PageRequest>,
9343    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9344        if let Some(page) = comments
9345            && let Err(error) = validate_page(page)
9346        {
9347            return ids.iter().map(|_| Err(error.clone())).collect();
9348        }
9349        match (ids, comments) {
9350            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9351            ([id], None) => vec![self.task_read(id).await],
9352            _ => self.issue_details(ids, comments).await,
9353        }
9354    }
9355
9356    /// Add one comment to the task's issue, as the account the token belongs to.
9357    ///
9358    /// The author is refused before anything is sent — not even the task is read — because
9359    /// no answer GitHub could give would make posting under another name than the one asked
9360    /// for the right outcome.
9361    async fn add_comment(
9362        &self,
9363        task: &NativeId,
9364        comment: &NewComment,
9365    ) -> Result<Option<Comment>, SourceError> {
9366        if let Some(author) = &comment.author {
9367            return Err(SourceError::Refused {
9368                message: format!(
9369                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9370                     the token signs in as the author of every comment; next: leave --author \
9371                     out, and the comment is posted as that account",
9372                    self.name
9373                ),
9374            });
9375        }
9376        let Some(issue) = self.commented_issue(task).await? else {
9377            return Ok(None);
9378        };
9379        let data = self
9380            .graphql(
9381                graphql::ADD_COMMENT,
9382                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9383            )
9384            .await?;
9385        let subject = data
9386            .pointer("/addComment/subject")
9387            .filter(|value| !value.is_null())
9388            .ok_or_else(|| SourceError::Malformed {
9389                message: "GitHub comment addition returned no subject".into(),
9390            })?;
9391        if required_str(subject, "id")? != issue.0 {
9392            return Err(SourceError::Malformed {
9393                message: "GitHub comment addition answered about another issue".into(),
9394            });
9395        }
9396        let added = data
9397            .pointer("/addComment/commentEdge/node")
9398            .filter(|value| !value.is_null())
9399            .ok_or_else(|| SourceError::Malformed {
9400                message: "GitHub comment addition returned no comment".into(),
9401            })?;
9402        comment_from(added).map(Some)
9403    }
9404
9405    async fn edit_comment(
9406        &self,
9407        task: &NativeId,
9408        comment: &NativeId,
9409        body: &CommentBody,
9410    ) -> Result<Option<Comment>, SourceError> {
9411        let Some(issue) = self.commented_issue(task).await? else {
9412            return Ok(None);
9413        };
9414        if !self.comment_is_on(&issue, comment).await? {
9415            return Ok(None);
9416        }
9417        let data = self
9418            .graphql(
9419                graphql::UPDATE_COMMENT,
9420                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9421            )
9422            .await?;
9423        let edited = data
9424            .pointer("/updateIssueComment/issueComment")
9425            .filter(|value| !value.is_null())
9426            .ok_or_else(|| SourceError::Malformed {
9427                message: "GitHub comment update returned no comment".into(),
9428            })?;
9429        let edited = comment_from(edited)?;
9430        if edited.id != *comment {
9431            return Err(SourceError::Malformed {
9432                message: "GitHub comment update returned the wrong comment".into(),
9433            });
9434        }
9435        Ok(Some(edited))
9436    }
9437
9438    async fn delete_comment(
9439        &self,
9440        task: &NativeId,
9441        comment: &NativeId,
9442    ) -> Result<Option<NativeId>, SourceError> {
9443        let Some(issue) = self.commented_issue(task).await? else {
9444            return Ok(None);
9445        };
9446        if !self.comment_is_on(&issue, comment).await? {
9447            return Ok(None);
9448        }
9449        let data = self
9450            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9451            .await?;
9452        // The payload says nothing about the comment it removed, so what is checked is that
9453        // GitHub answered the mutation at all rather than leaving it unanswered.
9454        data.get("deleteIssueComment")
9455            .filter(|value| !value.is_null())
9456            .ok_or_else(|| SourceError::Malformed {
9457                message: "GitHub comment deletion returned no payload".into(),
9458            })?;
9459        Ok(Some(comment.clone()))
9460    }
9461
9462    /// Every request this source has recorded, and what each of GitHub's two budgets was
9463    /// attributed — read off the same accounting the session report is rendered from, so
9464    /// the two cannot count one request two ways.
9465    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9466        Ok(Some(self.ledger.snapshot().metering()))
9467    }
9468}
9469
9470/// One issue comment as the contract carries it.
9471///
9472/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9473/// and when it answers an actor with no login, because either way the source did not say who
9474/// wrote it — which is what an absent author means, rather than an author called nothing.
9475fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9476    Ok(Comment {
9477        id: NativeId(required_str(value, "id")?.to_owned()),
9478        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9479            .map(str::to_owned),
9480        created_at: optional_time(value, "createdAt")?,
9481        updated_at: optional_time(value, "updatedAt")?,
9482        body: required_str(value, "body")?.to_owned(),
9483        url: optional_str(value, "url")?.map(str::to_owned),
9484    })
9485}
9486
9487/// The page of comments one issue node carries, resumed from `after`.
9488fn comment_page(
9489    node: &Value,
9490    issue: &str,
9491    after: Option<&str>,
9492) -> Result<Page<Comment>, SourceError> {
9493    let connection = node
9494        .get("comments")
9495        .filter(|value| !value.is_null())
9496        .ok_or_else(|| SourceError::Malformed {
9497            message: format!("GitHub issue {issue} answered with no comments connection"),
9498        })?;
9499    let items = optional_nodes(Some(connection), "issue comments")?
9500        .into_iter()
9501        .flatten()
9502        .map(comment_from)
9503        .collect::<Result<Vec<_>, _>>()?;
9504    let next = next_cursor(connection)?;
9505    if let Some(next) = &next {
9506        validate_cursor_progress(after, &next.0)?;
9507    }
9508    Ok(Page { items, next })
9509}
9510
9511/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9512/// end — `None` when it carried none, or a page with more past it.
9513fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9514    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9515        return Ok(None);
9516    };
9517    if next_cursor(connection)?.is_some() {
9518        return Ok(None);
9519    }
9520    Ok(Some(
9521        optional_nodes(Some(connection), "blocked-by issues")?
9522            .into_iter()
9523            .flatten()
9524            .cloned()
9525            .collect(),
9526    ))
9527}
9528
9529/// Where the recorded tail of a dependency walk resumes; see
9530/// [`GitHubProjectsSource::recorded_edges`].
9531const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9532
9533/// The board text field this source keeps a copy's origin in.
9534///
9535/// Named after the key it holds, and held to that name by the guard below rather than by
9536/// a reader noticing.
9537const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9538
9539/// The metadata key that field holds.
9540///
9541/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9542/// constructs or interprets the qualified id it carries. This source names it only to
9543/// route it — a short, typed value belongs in a typed field rather than in the body slot
9544/// a caller's own prose shares.
9545///
9546/// Restated rather than imported, because no plugin crate may depend on the engine. What
9547/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9548/// target in `check`: it reads the engine's own literal and fails naming the file and the
9549/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9550/// that creates a second item every run instead of finding the one it wrote — and that is
9551/// too late to learn it.
9552const ORIGIN_KEY: &str = "onetaskgraph.origin";
9553
9554/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9555///
9556/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9557/// is derived from the far end, never written down on the near item — so only a forward
9558/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9559/// it did not come from, and it is told so rather than answered with an empty page that
9560/// reads as a walk which ended.
9561fn recorded_offset(
9562    cursor: Option<&str>,
9563    direction: Direction,
9564) -> Result<Option<usize>, SourceError> {
9565    cursor
9566        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9567        .map(|offset| {
9568            if direction != Direction::DependsOn {
9569                return Err(SourceError::Config {
9570                    message: format!(
9571                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9572                         reverse dependency read never issues; resume it in the direction \
9573                         that reported it"
9574                    ),
9575                });
9576            }
9577            offset.parse().map_err(|_| SourceError::Config {
9578                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9579            })
9580        })
9581        .transpose()
9582}
9583
9584fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9585    let mut page = offset_page(edges, offset, limit.max(1));
9586    page.next = page
9587        .next
9588        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9589    page
9590}
9591
9592/// The kind of one issue reached through a dependency connection.
9593///
9594/// The same questions the board scan asks, over the fields the dependency document
9595/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9596/// then anything with sub-issues or the marker is a project.
9597///
9598/// # Errors
9599///
9600/// A far end this board holds as a document is refused rather than reported. The two
9601/// answers that are not refusals would both be wrong: reporting it as a task names an id
9602/// no task read of this source can find, and reporting it as a project names one no
9603/// project read can. There is no third value to return — `ItemKind` has no document
9604/// variant, because nothing may point at a document — so the relationship itself is what
9605/// the person is told about.
9606fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9607    let id = required_str(value, "id")?;
9608    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9609        return Err(SourceError::Refused {
9610            message: format!(
9611                "GitHub issue {id} is a document of this board — its title begins \
9612                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9613                 on by one; next: remove that issue's blocking relationship on this board"
9614            ),
9615        });
9616    }
9617    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9618    if parent.is_some() {
9619        return Ok(ItemKind::Task);
9620    }
9621    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9622    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9623        message: format!("GitHub issue {id}: {message}"),
9624    })?;
9625    let sub_issues = sub_issue_total(value)?;
9626    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9627        ItemKind::Project
9628    } else {
9629        ItemKind::Task
9630    })
9631}
9632
9633/// The `IssueStateUpdateInput` one status target asks for.
9634///
9635/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9636/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9637/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9638/// would report a change forever. A document has no status at all, and asks for neither.
9639fn state_input(target: Option<&StatusTarget>) -> Value {
9640    match target {
9641        Some(StatusTarget::Terminal(_, reason)) => {
9642            json!({"value":"CLOSED","stateReason":reason.reason()})
9643        }
9644        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9645        // A document has no status, so a write of one says nothing about the issue's open
9646        // or closed state rather than forcing it open: `stateInput` is what carries that
9647        // instruction, and an explicit null asks for no change to it.
9648        None => Value::Null,
9649    }
9650}
9651
9652/// The metadata one write stores in the item's body slot.
9653///
9654/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9655/// rather than carried: the kind marker so an empty project stays readable, the
9656/// repository list only when it is not exactly the issue's own repository, and the far
9657/// ends no relationship here can name.
9658///
9659/// The copy origin is the one typed field that is also mirrored here, and only as a
9660/// mirror: it lands in the board's origin field as well, which stays the one every reader
9661/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9662/// and catches up with a write in seconds rather than minutes — can find the item by it.
9663/// A reader of the release before this one drops the slot's copy and reads the field, so an
9664/// item written here still reads with exactly one origin there.
9665fn slot_metadata(
9666    incoming: &Incoming<'_>,
9667    own_repository: Option<&Repository>,
9668    fallback: &[DependencyEdge],
9669) -> BTreeMap<String, Value> {
9670    let mut metadata = incoming.metadata.clone();
9671    match metadata.remove(ORIGIN_KEY) {
9672        Some(Value::String(origin)) if !origin.is_empty() => {
9673            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9674        }
9675        _ => {}
9676    }
9677    match incoming.written.kind() {
9678        BoardKind::Work(kind) => metadata.insert(
9679            ItemKind::METADATA_KEY.to_owned(),
9680            Value::String(kind.marker().to_owned()),
9681        ),
9682        // A document is told by its title, so it carries no kind marker: that key names
9683        // what a dependency endpoint points at, and nothing may point at a document.
9684        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9685    };
9686    let derivable = own_repository
9687        .map(|own| incoming.repositories == [own.clone()])
9688        .unwrap_or(incoming.repositories.is_empty());
9689    if derivable {
9690        metadata.remove(Repository::METADATA_KEY);
9691    } else {
9692        metadata.insert(
9693            Repository::METADATA_KEY.to_owned(),
9694            Value::Array(
9695                incoming
9696                    .repositories
9697                    .iter()
9698                    .map(|repository| Value::String(repository.as_str().to_owned()))
9699                    .collect(),
9700            ),
9701        );
9702    }
9703    // The typed lists are what land, whatever the caller's own metadata held under their
9704    // keys: a key of either name travelling beside the field would otherwise be a second
9705    // answer to the same question, and the field is the one the contract names.
9706    for (key, entries) in [
9707        (TaskRef::DELIVERS_KEY, incoming.delivers),
9708        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9709    ] {
9710        set_task_list(&mut metadata, key, entries);
9711    }
9712    record_edges(&mut metadata, fallback);
9713    metadata
9714}
9715
9716/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9717/// one slot's metadata, or no such key when there are none.
9718fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9719    if fallback.is_empty() {
9720        metadata.remove(DependencyEdge::RECORDED_KEY);
9721    } else {
9722        metadata.insert(
9723            DependencyEdge::RECORDED_KEY.to_owned(),
9724            Value::Array(
9725                fallback
9726                    .iter()
9727                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9728                    .collect(),
9729            ),
9730        );
9731    }
9732}
9733
9734/// Every label one item carries, from its content's own connection and nowhere else.
9735///
9736/// There is no second place to read one from: no document this source sends selects the
9737/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9738/// cannot carry one at all. The module documentation records the three schema facts that
9739/// settle it.
9740fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9741    optional_nodes(content.get("labels"), "content labels")?
9742        .into_iter()
9743        .flatten()
9744        .map(|v| {
9745            Ok(Label {
9746                id: NativeId(required_str(v, "id")?.to_owned()),
9747                name: required_str(v, "name")?.to_owned(),
9748                color: optional_str(v, "color")?.map(str::to_owned),
9749            })
9750        })
9751        .collect()
9752}
9753
9754/// The definition of each board field one item's values are values of, in the shape a read
9755/// of the board's own `fields` gives one.
9756///
9757/// A value names its field through a fragment on that field's own type, so the type is
9758/// known from which kind of value it is: a single-select value's field is a
9759/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9760/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9761fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9762    field_values
9763        .iter()
9764        .filter_map(|value| {
9765            let field = value.get("field")?.as_object()?;
9766            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9767            let typename = if value.get("text").is_some() {
9768                "ProjectV2Field"
9769            } else if value.get("name").is_some() {
9770                "ProjectV2SingleSelectField"
9771            } else {
9772                return None;
9773            };
9774            let mut defined = field.clone();
9775            defined.insert("__typename".to_owned(), json!(typename));
9776            Some(Value::Object(defined))
9777        })
9778        .collect()
9779}
9780
9781fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9782    let Some(node) = field_values
9783        .iter()
9784        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9785    else {
9786        return Ok(None);
9787    };
9788    Ok(optional_str(node, "text")?.map(str::to_owned))
9789}
9790
9791fn valid_github_owner(owner: &str) -> bool {
9792    !owner.is_empty()
9793        && owner.len() <= 39
9794        && !owner.starts_with('-')
9795        && !owner.ends_with('-')
9796        && !owner.contains("--")
9797        && owner
9798            .bytes()
9799            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9800}
9801
9802/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9803/// neither of the two names a path segment already means.
9804fn valid_github_repository_name(name: &str) -> bool {
9805    !name.is_empty()
9806        && name.len() <= 100
9807        && name != "."
9808        && name != ".."
9809        && name
9810            .bytes()
9811            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9812}
9813
9814fn valid_environment_name(name: &str) -> bool {
9815    let mut bytes = name.bytes();
9816    bytes
9817        .next()
9818        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9819        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9820}
9821
9822/// How many sub-issues one issue has.
9823///
9824/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9825/// absent or non-integer one is a response this source cannot read — and reading it as
9826/// zero would classify a project as a task, which is exactly the mistake the marker
9827/// exists to keep from happening quietly.
9828fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9829    let summary = issue
9830        .get("subIssuesSummary")
9831        .ok_or_else(|| SourceError::Malformed {
9832            message: "GitHub issue is missing subIssuesSummary".into(),
9833        })?;
9834    summary
9835        .get("total")
9836        .and_then(Value::as_u64)
9837        .ok_or_else(|| SourceError::Malformed {
9838            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9839        })
9840}
9841
9842/// One issue's own `number`.
9843///
9844/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9845/// an issue in this module asks for it. So a read of one that comes back without it, or
9846/// with something that is not an unsigned integer, is a response this source cannot read —
9847/// absence here is **not** "this issue has no number". A draft is the content that has
9848/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9849/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9850fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9851    issue
9852        .get("number")
9853        .and_then(Value::as_u64)
9854        .ok_or_else(|| SourceError::Malformed {
9855            message: "GitHub issue number is missing or is not an unsigned integer".into(),
9856        })
9857}
9858
9859/// The `number` a creating mutation answered with, and `None` when it answered without one;
9860/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9861fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9862    match created.get("number") {
9863        None | Some(Value::Null) => Ok(None),
9864        Some(value) => value
9865            .as_u64()
9866            .map(Some)
9867            .ok_or_else(|| SourceError::Malformed {
9868                message: "GitHub created issue number is not an unsigned integer".into(),
9869            }),
9870    }
9871}
9872
9873fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9874    value
9875        .get(field)
9876        .and_then(Value::as_str)
9877        .ok_or_else(|| SourceError::Malformed {
9878            message: format!("GitHub response is missing string field {field}"),
9879        })
9880}
9881
9882fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9883    let found = required_str(value, field)?;
9884    if found.trim().is_empty() {
9885        return Err(SourceError::Malformed {
9886            message: format!("GitHub response has blank string field {field}"),
9887        });
9888    }
9889    Ok(found)
9890}
9891
9892/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9893/// needs one — Linear spells them too, in its own description field.
9894///
9895/// Restated rather than shared, because a plugin crate depends on the contract crate and
9896/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9897/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9898/// source round-trips its own writes perfectly well under its own spelling.
9899const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9900const METADATA_CLOSE: &str = "\n-->";
9901
9902/// What the composer puts between a non-empty visible body and the slot, and the one thing
9903/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9904/// other trailing byte of the body comes back as it was written.
9905// 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.
9906const METADATA_SEPARATOR: &str = "\n\n";
9907
9908/// The visible body and the metadata slot at the end of it.
9909///
9910/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9911/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9912/// own content and is left alone. The visible body is everything before the slot less the
9913/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9914fn metadata_body(
9915    body: Option<String>,
9916) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9917    let Some(body) = body else {
9918        return Ok((None, BTreeMap::new()));
9919    };
9920    let Some(slot) = slot_span(&body)? else {
9921        return Ok((Some(body), BTreeMap::new()));
9922    };
9923    let metadata =
9924        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9925            SourceError::Malformed {
9926                message: format!(
9927                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9928                ),
9929            }
9930        })?;
9931    let before = &body[..slot.start];
9932    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9933    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9934}
9935
9936/// Where the metadata slot sits in one body, as byte offsets into it.
9937struct SlotSpan {
9938    /// Where [`METADATA_OPEN`] begins.
9939    start: usize,
9940    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9941    encoded_start: usize,
9942    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9943    encoded_end: usize,
9944    /// Just past [`METADATA_CLOSE`].
9945    end: usize,
9946}
9947
9948/// The slot at the very end of `body`, or `None` when it has none.
9949///
9950/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9951/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9952/// slot.
9953fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9954    let Some(start) = body.rfind(METADATA_OPEN) else {
9955        return Ok(None);
9956    };
9957    let encoded_start = start + METADATA_OPEN.len();
9958    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9959        return Err(SourceError::Malformed {
9960            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9961        });
9962    };
9963    let encoded_end = encoded_start + relative_end;
9964    let end = encoded_end + METADATA_CLOSE.len();
9965    if !body[end..].trim().is_empty() {
9966        return Ok(None);
9967    }
9968    Ok(Some(SlotSpan {
9969        start,
9970        encoded_start,
9971        encoded_end,
9972        end,
9973    }))
9974}
9975
9976/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9977/// slot as it was.
9978///
9979/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9980/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9981/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9982/// or alone in an empty body — and a body with no slot that is given no metadata is
9983/// returned as it is.
9984fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9985    let encoded = if metadata.is_empty() {
9986        None
9987    } else {
9988        Some(
9989            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9990                message: error.to_string(),
9991            })?,
9992        )
9993    };
9994    Ok(match (slot_span(body)?, encoded) {
9995        (Some(slot), Some(encoded)) => format!(
9996            "{}{encoded}{}",
9997            &body[..slot.encoded_start],
9998            &body[slot.encoded_end..]
9999        ),
10000        (Some(slot), None) => {
10001            let before = &body[..slot.start];
10002            format!(
10003                "{}{}",
10004                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10005                &body[slot.end..]
10006            )
10007        }
10008        (None, None) => body.to_owned(),
10009        (None, Some(encoded)) if body.is_empty() => {
10010            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10011        }
10012        (None, Some(encoded)) => {
10013            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10014        }
10015    })
10016}
10017
10018/// `body` with everything before its metadata slot replaced by `content`, and the slot
10019/// itself kept byte for byte.
10020///
10021/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10022/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10023/// `content` is empty — so a read of the result reports `content` as the visible body and
10024/// the slot's metadata exactly as it was.
10025fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10026    let Some(slot) = slot_span(body)? else {
10027        return Ok(content.to_owned());
10028    };
10029    let kept = &body[slot.start..];
10030    Ok(if content.is_empty() {
10031        kept.to_owned()
10032    } else {
10033        format!("{content}{METADATA_SEPARATOR}{kept}")
10034    })
10035}
10036
10037/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10038fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10039    if entries.is_empty() {
10040        metadata.remove(key);
10041    } else {
10042        metadata.insert(
10043            key.to_owned(),
10044            Value::Array(
10045                entries
10046                    .iter()
10047                    .map(|entry| Value::String(entry.as_str().to_owned()))
10048                    .collect(),
10049            ),
10050        );
10051    }
10052}
10053
10054fn compose_body(
10055    content: Option<&str>,
10056    metadata: &BTreeMap<String, Value>,
10057) -> Result<Option<String>, SourceError> {
10058    let visible = content.unwrap_or_default();
10059    if metadata.is_empty() {
10060        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10061    }
10062    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10063        message: error.to_string(),
10064    })?;
10065    Ok(Some(if visible.is_empty() {
10066        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10067    } else {
10068        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10069    }))
10070}
10071
10072fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10073    value
10074        .get(field)
10075        .and_then(Value::as_bool)
10076        .ok_or_else(|| SourceError::Malformed {
10077            message: format!("GitHub response is missing boolean field {field}"),
10078        })
10079}
10080fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10081    match value.get(field) {
10082        None | Some(Value::Null) => Ok(None),
10083        Some(value) => value
10084            .as_str()
10085            .map(Some)
10086            .ok_or_else(|| SourceError::Malformed {
10087                message: format!("GitHub response field {field} is not a string or null"),
10088            }),
10089    }
10090}
10091fn optional_nodes<'a>(
10092    connection: Option<&'a Value>,
10093    name: &str,
10094) -> Result<Option<&'a Vec<Value>>, SourceError> {
10095    match connection {
10096        None | Some(Value::Null) => Ok(None),
10097        Some(value) => value
10098            .get("nodes")
10099            .and_then(Value::as_array)
10100            .map(Some)
10101            .ok_or_else(|| SourceError::Malformed {
10102                message: format!("GitHub {name}.nodes is not an array"),
10103            }),
10104    }
10105}
10106fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10107    let page_info = connection
10108        .get("pageInfo")
10109        .ok_or_else(|| SourceError::Malformed {
10110            message: format!("GitHub {name} has no pageInfo"),
10111        })?;
10112    if required_bool(page_info, "hasNextPage")? {
10113        return Err(SourceError::Malformed {
10114            message: format!(
10115                "GitHub {name} exceeds the supported nested connection size of {size}"
10116            ),
10117        });
10118    }
10119    Ok(())
10120}
10121fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10122    optional_str(value, field)?
10123        .map(|timestamp| {
10124            timestamp.parse().map_err(|error| SourceError::Malformed {
10125                message: format!("GitHub response field {field} is not a timestamp: {error}"),
10126            })
10127        })
10128        .transpose()
10129}
10130fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10131    if page.limit == 0 {
10132        Err(SourceError::Config {
10133            message: "page limit must be at least 1".into(),
10134        })
10135    } else {
10136        Ok(())
10137    }
10138}
10139fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10140    let page = connection
10141        .get("pageInfo")
10142        .filter(|value| value.is_object())
10143        .ok_or_else(|| SourceError::Malformed {
10144            message: "GitHub connection is missing pageInfo".into(),
10145        })?;
10146    if required_bool(page, "hasNextPage")? {
10147        let cursor = required_str(page, "endCursor")?;
10148        validate_cursor_progress(None, cursor)?;
10149        Ok(Some(Cursor(cursor.into())))
10150    } else {
10151        Ok(None)
10152    }
10153}
10154fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10155    if next.is_empty() || previous == Some(next) {
10156        Err(SourceError::Malformed {
10157            message: "GitHub pagination cursor is empty or did not advance".into(),
10158        })
10159    } else {
10160        Ok(())
10161    }
10162}
10163/// The version of this plugin's opaque narrowing-search cursor.
10164pub const SEARCH_CURSOR_VERSION: u32 = 4;
10165
10166#[derive(Serialize, Deserialize)]
10167#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10168enum SearchConnection {
10169    Initial {},
10170    Continuing { after: Cursor },
10171    Exhausted {},
10172}
10173impl SearchConnection {
10174    fn after(&self) -> Option<&str> {
10175        match self {
10176            Self::Continuing { after } => Some(&after.0),
10177            _ => None,
10178        }
10179    }
10180    fn exhausted(&self) -> bool {
10181        matches!(self, Self::Exhausted { .. })
10182    }
10183    /// Whether a cursor naming this position, `offset` rows into its page, is one this
10184    /// plugin could have handed out: a page is resumed only part of the way through it — an
10185    /// offset of a whole page or more would skip rows nobody was given — an initial page
10186    /// only once some of it was handed out, and an exhausted connection has no page to be
10187    /// part of the way through.
10188    fn valid_resume(&self, offset: usize) -> bool {
10189        let within = offset < SEARCH_PAGE_SIZE as usize;
10190        match self {
10191            Self::Initial { .. } => offset > 0 && within,
10192            Self::Continuing { after } => !after.0.is_empty() && within,
10193            Self::Exhausted { .. } => offset == 0,
10194        }
10195    }
10196}
10197
10198/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10199#[derive(Serialize, Deserialize)]
10200#[serde(deny_unknown_fields)]
10201struct SearchPosition {
10202    version: u32,
10203    connection: SearchConnection,
10204    /// How many rows of the page `connection` starts were already handed out.
10205    #[serde(default, skip_serializing_if = "is_zero")]
10206    offset: usize,
10207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10208    seen: Vec<NativeId>,
10209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10210    own: Vec<NativeId>,
10211}
10212impl Default for SearchPosition {
10213    fn default() -> Self {
10214        Self {
10215            version: SEARCH_CURSOR_VERSION,
10216            connection: SearchConnection::Initial {},
10217            offset: 0,
10218            seen: Vec::new(),
10219            own: Vec::new(),
10220        }
10221    }
10222}
10223
10224fn is_zero(offset: &usize) -> bool {
10225    *offset == 0
10226}
10227
10228fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10229    cursor.map_or(Ok(0), |c| {
10230        c.0.parse().map_err(|_| SourceError::Config {
10231            message: "page cursor is invalid".into(),
10232        })
10233    })
10234}
10235fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10236    if offset > items.len() {
10237        return Page::last(vec![]);
10238    }
10239    let tail = items.split_off(offset);
10240    let mut selected = tail;
10241    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10242    selected.truncate(limit);
10243    Page {
10244        items: selected,
10245        next,
10246    }
10247}