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 from a status category to
85//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
86//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
87//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
88//! A missing mapped option refuses the write before either representation changes. Reads
89//! give a closed issue's reason precedence over its option, while an open issue's option
90//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
91//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
92//! list, so it preserves every existing option id and verifies the field and item
93//! assignments immediately afterwards. It counts a terminal category's mapped option as
94//! configured, because a terminal write refuses without it. No ordinary source read or
95//! write calls that mutation, whose
96//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
97//! and a mistake destroys every item's status. A status this board cannot represent is a
98//! refusal naming the status and the instance instead.
99//!
100//! `unknown` is disabled by default because this source cannot preserve an open-ended
101//! status word: it writes an existing board option and never
102//! creates an option. An operator may map `unknown` to one existing option, in which case
103//! every unknown word lands on that option and reads back as `unknown` under the option's
104//! name. This differs from `local-md`, which writes and reads the original word itself.
105//!
106//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
107//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
108//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
109//! finished tasks were only moved to a "Done" column would read 0% complete forever.
110// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
111//!
112//! # What this source declares, field by field
113//!
114//! One verdict per field of [`Capabilities`], and what `Native` means when this source
115//! says it. *Proven* means a shared journey drives it against the real
116//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
117//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
118//! [`capabilities`](TaskSource::capabilities) from parting.
119//!
120//! | Field | Verdict |
121//! | --- | --- |
122//! | `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. |
123//! | `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. |
124//! | `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. |
125//! | `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. |
126//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
127//! | `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. |
128//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
129//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
130//! | `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`. |
131//! | `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. |
132//! | `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. |
133//! | `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 or document query's text is applied by that same substring rule over the issues its read already holds, and narrows nothing. |
134//! | `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. |
135//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
136//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
137//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
138//!
139//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
140//! source has documents and that its tasks have comments, both of which hold — and the three
141//! facts behind the uniform `Native` on the
142//! predicates beside it are recorded below rather than re-derived, because a reader who
143//! takes `Native` to mean *the remote service filters* will read that uniformity as a
144//! lie.
145//!
146//! First, the plugin contract defines `Support::Native` as *the source applies this
147//! predicate itself*, and says nothing about where it applies it. What the declaration
148//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
149//! — so that the engine may push it down and apply nothing of its own.
150//!
151//! Second, this source can keep that promise for every predicate at no additional API
152//! cost, because whichever of the reads below answers a query has already read every
153//! candidate that query will return before it filters anything. Filtering those items is
154//! in-process work over data already in hand.
155//!
156//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
157//! applied in process over what that question returned. A project filter has a relationship — a
158//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
159//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
160//! a search for metadata values, is the board-scoped issue search carrying the text and each
161//! value as quoted phrases; an origin is the board's own field filter over its origin field
162//! beside the same search for the id. **The text search narrows, and that is this source's
163//! declared semantics:** GitHub matches whole words where the substring rule this source and
164//! the local Markdown source confirm with would match inside one, so an item holding the text
165//! only inside a longer word is never a candidate. Every item returned does contain the text.
166//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
167//! so those three are applied in process over the candidates, and a query carrying none of
168//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
169//! engine compensate for work this source has already done, and declaring `projects` native
170//! while ignoring the filter (which this source once did) silently returns another project's
171//! tasks, because the engine trusts the declaration and applies nothing locally.
172//!
173//! # The three ways this source reaches an item, and what each costs
174//!
175//! A board read is charged for what its *nested* connections could return rather than for
176//! what was asked, so one whole-board read costs the same whether the question was about
177//! one project or about all of them. That is why a question about one project is never
178//! answered by reading the board:
179//!
180//! | The question | What is sent | What it costs |
181//! | --- | --- | --- |
182//! | 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 |
183//! | 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 |
184//! | 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 |
185//! | 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 |
186//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
187//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
188//! | 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 |
189//! | 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 |
190//! | 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 |
191//! | 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 |
192//! | 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 |
193//!
194//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
195//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
196//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
197//! equal to declared points equal to the row. They include the origin lookup and the
198//! field/repository discovery a create needs. A bound re-copy changes status, priority,
199//! content and metadata; comment recount means a subsequent detail read. Each request here
200//! costs one declared point. A membership beyond the embedded page can additionally require
201//! the one-point membership recovery described above. A bound re-copy of a task filed under a
202//! project adds one read, the engine confirming that project's link by its own id once per
203//! command; and the same-source far ends a write newly names — those that do not already block
204//! the item, whose own read answered for them — are read together by their own ids,
205//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
206//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
207//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
208//!
209//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
210//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
211//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
212//! holds both halves.
213//!
214//! **An existing item is written body last.** A bound re-copy and a `task update` send its
215//! board fields first — the `Status` option and the `Priority` together, in one request — then
216//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
217//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
218//! undoing an earlier field when a later one fails, so that order is what makes a write
219//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
220//! the one piece of metadata written before the body, an origin a copy re-points, is put back
221//! when a later write is refused — and when putting it back is refused too, the write's own
222//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
223//! of those writes in turn, whole and as one aliased field failing after the one before it.
224//!
225//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
226//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
227//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
228//!
229//! - **A board is accepted at creation but its item is not answered, so a create still files
230//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
231//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
232//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
233//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
234//!   against a real board on 2026-10-01 with a create sending the board there and reading the
235//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
236//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
237//!   refused "Content already exists in this project" — GitHub had filed the issue after
238//!   answering, and refuses a second filing rather than answering with the item it holds. A
239//!   create therefore sends no `projectV2Ids` and files the issue with
240//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
241//!   real is the read before it: the board's fields and the repository's id together, in
242//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
243//! - **A comment still reads its target first, so a comment is 2 requests.**
244//!   `AddCommentInput.subjectId: ID!` is declared there with
245//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
246//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
247//!   project's issue, a document's issue, an issue on no board of this source and a pull
248//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
249//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
250//!   refuses those by name.
251//!
252//! | Verb | Requests / points | Documents |
253//! | --- | --- | --- |
254//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
255//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
256//! | 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 |
257//! | 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 |
258//! | 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 |
259//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
260//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
261//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
262//! | recount | 1 | ISSUE_DETAIL |
263//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
264//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
265//! | content | 2 | ISSUE, UPDATE_ISSUE |
266//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
267//! | 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 |
268//! | record only | 1 | ISSUE |
269//!
270//! <!-- github-search-paging:start -->
271//! Board-scoped text, metadata, project-name and comment-activity searches send every
272//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
273//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
274//! needs rows. A page is never resized to the rows still needed: GitHub orders one
275//! search differently at different page sizes, so one fixed size makes a paged walk
276//! send exactly the requests one whole read sends, and the answer's order is the order
277//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
278//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
279//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
280//! returned and fetched pages: a limit is sliced from the pages it needs, and local
281//! confirmation can require more candidates than matching rows. Walking all pages
282//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
283//! cursor and how far into that page the last answer stopped, and resumes in the same
284//! process or a new one, without duplicates or gaps. It carries no rows: one process
285//! sends each page's search once, and a new process re-reads only the page it resumes
286//! in, then sends a further page once, never as a re-read, only when its limit still
287//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
288//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
289//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
290//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
291//! not required to include the original process's writes still omitted by the index.
292//! <!-- github-search-paging:end -->
293//!
294//! The board half of an issue — its board item's id, its `Status` option and this
295//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
296//! an item reached any of those ways resolves through the same
297//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
298//! same status, the same labels and the same qualified id. That connection comes back a
299//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
300//! on the page in hand and — only if that page reports more of the connection — in the
301//! last row's read of that one issue's memberships, resumed from the page's own cursor and
302//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
303//! report, which is what keeps an id naming another repository's issue from being answered
304//! as an item of this board; and because the page is where the search starts rather than
305//! where it ends, that answer is one about a connection read to exhaustion and never about
306//! an unread page. Nothing costs the extra read but an issue on more boards than a page
307//! holds: an issue this board really does not hold reports no next page, so its
308//! memberships are already exhausted where they arrived.
309//!
310//! **No document here selects the board's own `Labels` field, and nothing is lost by
311//! that.** An item's labels are read from its content alone, wherever that content is
312//! reached: the three documents above select `Issue.labels` on the fragment, and
313//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
314//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
315//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
316//! create one, and `ProjectV2FieldValue` — the whole of what
317//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
318//! it from the content, for every content type it exists on, and there is nothing it can
319//! hold that the content does not already say: for an `Issue` it *is* that issue's own
320//! labels, so selecting it beside them unions a set with itself.
321//!
322//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
323//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
324//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
325//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
326//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
327//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
328//! label set, and that set is the fixture issue's own, by
329//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
330//! item whose content is a draft reports an empty set, by
331//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
332//! selection is held over [`graphql::DOCUMENTS`] by
333//! `no_document_selects_the_boards_own_labels_field`.
334//!
335//! The whole-board row is still the board's own item connection, and deliberately: a
336//! **draft** board item is not an issue, so no search can list one, and the reads that have
337//! to answer for the whole board are the ones whose cost is the board's size anyway.
338//!
339//! **A question about one item this source already names by id never lists the board.**
340//! Whether that item is on this board, and what its board fields are, is answered by reading
341//! that item — its own `Issue.projectItems`, walked to exhaustion by
342//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
343//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
344//! holds. That covers a write's destination, the project a new item is filed under, a
345//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
346//! and the delete that takes back an item a copy made. What such a write needs of the board
347//! and the item does not carry — the board's id, the `Status` and origin field definitions —
348//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
349//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
350//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
351//! minutes. Scanning this host's 842-item board has refused a document copy and an update
352//! even though the items' own reads named that board. A scan there gives the wrong answer
353//! as well as paying for every page. So a `board.items` lookup does not belong on any of
354//! those paths.
355//!
356//! **What a read may return is capped too, and that cap is on the document rather than on
357//! the board.** GitHub limits the number of nodes **one query may return** to
358//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
359//! an error naming the connection the count crossed at, not a slow or a partial result.
360//! Every board this source reads is refused the same way, so no board is too big for these
361//! documents and none is small enough to save one that is over.
362//!
363//! The count is arithmetic over the document's own text: each connection contributes the
364//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
365//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
366//! restate them — `github-graphql-node-count` implements them, and
367//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
368//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
369//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
370//! same text on every run and fails naming any that reaches the limit — so a connection
371//! added to a shared fragment is caught there rather than by GitHub.
372//!
373//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
374//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
375//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
376//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
377//! effectively squared there, which is why it is the one the limit is most sensitive to.
378//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
379//! page of memberships misses is recovered by one further read rather than refused, so it
380//! buys a bound every read pays for at the price of a request only a multi-board issue
381//! pays.
382//!
383//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
384//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
385//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
386//! `cost` is rate-limit points, metered per hour across everything one credential does; it
387//! is what the two limiters [`Limiter`] tells apart meter, and a document under
388//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
389//! that second number, and `tests/point_cost.rs` pins every document in
390//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
391//! one under, the pin itself is the check. The credentialed lane reconciles both figures
392//! against GitHub's own, off a probe it already sends.
393//!
394//! **What is pinned that way is a per-document price and never a session's.** The record in
395//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
396//! **requests** and **worst-case nodes** — and neither is points. What one whole session
397//! consumes of the hourly point allowance is observable only from a credentialed run's own
398//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
399//! and what `tests/live.rs` prints at the end of every run.
400//!
401//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
402//!
403//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
404//! enumerations of a board can supply one alone.** Resolving a node id is strongly
405//! consistent, so a read by id and a project's own sub-issues are already current. The
406//! other two are not, and they are behind by different amounts and in different directions:
407//!
408//! - GitHub's **issue search** is an index and answers a write made moments ago with the
409//!   value from before it — usually for a second or two.
410//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
411//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
412//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
413//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
414//!
415//! That second one is a measurement rather than a caution. This repository's own
416//! credentialed journey writes a project and waits for the board to report it, then writes a
417//! task and waits for the same thing seconds later on the same board: the project wait is
418//! answered through the search and converged in two or three attempts in each of three runs,
419//! and the task wait is answered through `ProjectV2.items` and converged in none of them
420//! inside thirty. Separately, an item added to a second and larger board was read back by
421//! `Issue.projectItems` on that board's own id while every one of that connection's nine
422//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
423//! through the lagging one alone is what had a board read deny an issue that had certainly
424//! landed on it.
425//!
426//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
427//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
428//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
429//! search reports what the projection is behind on. What closes the last
430//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
431//! source answers is completed with what this process itself wrote, so an item created
432//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
433//! nothing is written down, and the record dies with the process. **A wait that has to
434//! observe GitHub's own data cannot be answered from that record** — which is why the
435//! credentialed journey asks through a source built afresh, and why the union above rather
436//! than a longer wait is what makes such a wait converge.
437//!
438//! **A narrowed read is the same bargain, stated for each of the three predicates it
439//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
440//! than walking the board, and every such answer is completed with what this process wrote —
441//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
442//! each filtered by the same predicates as the rest — so an item this command wrote a moment
443//! ago is returned by a query that matches it whether or not the index has caught up. An item
444//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
445//! consistent. What is left is stated rather than papered over:
446//!
447//! | Read | Finds | Behind by |
448//! | --- | --- | --- |
449//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
450//! | 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 |
451//! | 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 |
452//! | origin, third read | this process's own writes | nothing |
453//!
454//! So an origin carrier another process added within the last second or two, before either
455//! index has it, can be missing from an origin query, and one written by the release before
456//! this one — its origin in the field alone — can be missing for as long as the board's own
457//! item connection is behind on it. A copy that must not duplicate its own earlier write
458//! relies on the link it records, not on either index. **A board draft is not an issue**, so
459//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
460//! lists one, the origin lookup drops any the board's own field filter names, and one this
461//! process wrote is not added back either.
462//!
463//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
464//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
465//! body's metadata slot, so the issue search can find it in seconds. The field is
466//! authoritative: this source reads an item's origin from the field alone, so a slot that
467//! disagrees with it, or holds one where the field holds none, is never read as a second
468//! origin — and the release before this one reads the slot, drops that key's copy for the
469//! field's, and sees the same one origin.
470//!
471//! Filtering happens before paging, so a page of a filtered result is a page of the
472//! survivors rather than the survivors of a page. Label matching and the substring rule a
473//! text candidate is confirmed by answer the same question the same way the local Markdown
474//! source's do; which candidates a text search has to confirm is GitHub's word match, which
475//! is the one place the two sources can answer the same text differently.
476//!
477//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
478//! has one source, `capabilities`, and the note above is the reasoning behind it rather
479//! than a second copy of it: without the three facts recorded here a reader takes the
480//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
481//! crate's own capabilities test, which pins every field of it against a fully spelled-out
482//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
483//! fails to compile there rather than going unasserted. -->
484//! The fixture-server tests above run wherever this crate is selected; the credentialed
485//! lane runs in the same required check, beside them, and can fail it — it verifies the
486//! 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,
487//! one filed under neither, a label on one of the three and a closed status on another —
488//! because that shape is what tells an honoured predicate from an ignored one: a board
489//! holding a single project answers a project filter the same way whether or not this
490//! source applies it, which is exactly how the defect above went unseen.
491//!
492//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
493//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
494//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
495//! nominated is what keeps a credentialed write lane off a board and a repository nobody
496//! nominated; it never asks GitHub which project was updated most recently. Before it
497//! starts, the lane also clears any item titled — and any repository label named — the way
498//! it titles and names its own artifacts, which is self-healing after an interrupted run:
499//! a process killed between its writes and its cleanup leaves artifacts the next run
500//! removes.
501//!
502//! # What a session of requests costs, and where the report is
503//!
504//! This source records **every** request it sends into [`accounting::Accounting`], at
505//! `send_once` — the one place a request leaves this crate, which is why a read path added
506//! later is counted without anybody remembering to count it. That is the whole of what this
507//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
508//! session's spend is arrived at, and what it deliberately does not know are set out.
509//!
510//! What one whole session of the live journey costs, counted that way against this crate's
511//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
512//! reduction it came out of, and with what it does and does not say about rate-limit points.
513//!
514//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
515//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
516//! code path — no environment variable, no feature, no build configuration — because an
517//! instrument nobody switches on measures nothing, and
518//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
519//! source's counts the whole session rather than this source's share. The credentialed lane
520//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
521//! passed or failed.
522//!
523//! **A live session refuses to start unless the account can afford it.** Before it does any
524//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
525//! GitHub documents as not counting against the REST rate limit and which answers both of
526//! its budgets at once — and starts only if, for each of them, what remains minus this
527//! session's estimated cost is still at least
528//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
529//! allowance. A session that cannot **declines**: it did not run, so it is
530//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
531//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
532//! waiting for the budget to come back. The estimate is derived offline from
533//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
534//! which is also where the published rule that model rests on is cited; the accounting
535//! above records the gate's own read like any other request, and
536//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
537//!
538//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
539//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
540//! text, which is what lets it run on every platform and on a pull request from a fork with
541//! no credential — and that is what actually stops a regression merging. But an offline
542//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
543//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
544//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
545//! number of nodes this query may return"* and whose `cost` is what that document would
546//! spend, both for a document **without executing it**, and the lane asks it for every query
547//! document this source sends, under the largest bindings this source sends, and fails when
548//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
549//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
550//! one; the offline pins still cover it. It records what those calls reported about the
551//! account's own allowance, because whether asking is free is a thing to observe rather than
552//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
553//! and `cost` is metered against an hourly allowance the accounting above reads off a
554//! credentialed run's own response headers.
555//!
556//! **GitHub has two rate limiters and this source is refused by both, so nothing here
557//! treats them as one thing.** The primary budget is the hourly allowance `gh api
558//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
559//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
560//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
561//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
562//! one part of.
563#![deny(missing_docs)]
564
565use std::collections::BTreeMap;
566use std::sync::{Arc, Mutex};
567use std::time::{Duration, Instant};
568
569use chrono::{DateTime, Utc};
570use onetaskgraph_plugin_api::{
571    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
572    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
573    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
574    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
575    SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskDetailRead, TaskQuery,
576    TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField,
577    WriteSupport,
578};
579use reqwest::{Client, StatusCode, Url};
580use schemars::{Schema, schema_for};
581use secrecy::{ExposeSecret, SecretString};
582use serde::{Deserialize, Serialize};
583use serde_json::{Value, json};
584
585pub mod accounting;
586
587use accounting::Accounting;
588
589/// The registry name for this plugin.
590pub const KIND: &str = "github-projects";
591/// GitHub's maximum connection page size.
592pub const MAX_PAGE_SIZE: u32 = 100;
593/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
594/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
595/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
596pub const SEARCH_PAGE_SIZE: u32 = 20;
597/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
598/// its comments: the largest batch the node-count model prices at one point.
599///
600/// Each aliased item is resolved once, and what GitHub charges for it is the connections
601/// under it — its labels, its page of board memberships, the field values of each of those
602/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
603/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
604/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
605/// one point and fails if one item more would still be priced at one.
606pub const DETAIL_BATCH: usize = 24;
607
608/// The most nodes any one document this source sends may be asked to return.
609///
610/// GitHub's own published per-query ceiling, taken from
611/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
612/// workspace cannot hold a stale copy of somebody else's number. A query above it is
613/// **refused before it is executed**, whoever is asking and whatever board they are
614/// asking about — so this is a bound on the documents rather than a budget that runs out.
615///
616/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
617/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
618/// everything the credential does — two numbers against two limits, and this constant
619/// bounds only the first. The second is computed offline too, per document:
620/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
621/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
622/// lane. There is no constant like this one to hold a price under, because points are an
623/// hourly allowance rather than a per-call bound.
624///
625/// Neither is a session's price. What `session-cost.md` records of a whole session is its
626/// **requests** and its **worst-case nodes**; what a whole session spends in points is
627/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
628/// [`accounting`]. The module section on the three ways this source reaches an item says how
629/// the count is arrived at, and which of the page sizes below decide it.
630pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
631
632/// Nested connection size for the connections that hang off one item.
633///
634/// It multiplies through every document that reaches an item under a page — the count
635/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
636/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
637/// every document under these constants and fails naming any that reaches the limit, so
638/// raising this is caught there rather than by GitHub.
639const NESTED_PAGE_SIZE: u32 = 50;
640/// How many of one issue's board memberships are read when an issue is reached directly.
641///
642/// An issue reached through a search or through its own node id carries its board half in
643/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
644/// under a page of issues, so every point of it multiplies through the whole document and
645/// is paid for whether or not any issue is on a second board — which is why it is
646/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
647///
648/// **Three, because what a page misses is now recovered rather than refused**, and the
649/// recovery is what the value is chosen against. An issue whose entry for this board sits
650/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
651/// that page's own cursor — so the value trades a bound every read pays for a request only
652/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
653/// boards would pay that request *per issue*, which is order N against the one page per
654/// hundred issues a read costs today. At three it is only reached by an issue on four or
655/// more boards at once, which keeps the recovery path exceptional rather than routine for
656/// a plausible deployment.
657const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
658/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
659/// its two connections for.
660///
661/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
662/// second is a duplicate a copy already takes the first of. Both connections are walked to
663/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
664/// never what the answer is. It is small because every point of it is paid on every lookup,
665/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
666/// worst-case nodes than the one whole-board read they replaced.
667const ORIGIN_PAGE_SIZE: u32 = 3;
668
669pub use github_graphql_node_count::{NodeCountError, Variables};
670
671/// The largest value this source can bind to each page-size variable its documents name.
672///
673/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
674/// constant above it wherever a caller's own limit could reach it — `$first` at
675/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
676/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
677/// not one configuration of it, which is what makes a bound computed under it a bound on
678/// every read.
679pub fn largest_page_sizes() -> Variables {
680    Variables::from([
681        ("first".to_owned(), MAX_PAGE_SIZE),
682        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
683        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
684        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
685    ])
686}
687
688/// The most nodes `document` could be asked to return, by GitHub's published rules.
689///
690/// Computed offline from the document's own text under [`largest_page_sizes`] — no
691/// network, no credential and no schema — by
692/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
693/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
694/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
695///
696/// # Errors
697///
698/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
699/// no single operation, or binds a page size this source does not name — each of which is
700/// a defect in the document rather than a number.
701pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
702    node_count(document, &largest_page_sizes())
703}
704
705/// The most rate-limit points one call of `document` could spend, by GitHub's published
706/// rules.
707///
708/// Computed offline from the document's own text under [`largest_page_sizes`] — no
709/// network, no credential and no schema — by
710/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
711/// This is `cost`, metered **per hour** against the allowance one credential shares across
712/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
713/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
714/// under, so what `tests/point_cost.rs` does with it is pin every document in
715/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
716/// figures against GitHub's own reported `cost`.
717///
718/// # Errors
719///
720/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
721/// no single operation, or binds a page size this source does not name — each of which is
722/// a defect in the document rather than a number.
723pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
724    github_graphql_node_count::point_cost(document, &largest_page_sizes())
725}
726
727/// The most nodes `document` could be asked to return under `variables`.
728///
729/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
730/// [`accounting`] is this under the bindings one request really sent — one spelling of the
731/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
732/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
733///
734/// # Errors
735///
736/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
737/// no single operation, or binds a page size `variables` does not name.
738pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
739    github_graphql_node_count::node_count(document, variables)
740}
741
742/// The issue-title prefix that makes a board issue a document.
743///
744/// A GitHub Projects board has no document type — it holds issues — so the discriminator
745/// is the title, and this is the whole of it: an issue whose title begins with these bytes
746/// is a document and every other issue is the task or project the sub-issue rule makes it.
747///
748/// It is spelled **once**, here, and read rather than restated everywhere else — including
749/// by the shared journeys, which take it from this constant so a board fixture cannot
750/// drift from what this source reads. `docs/metadata.md` records the two consequences that
751/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
752/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
753/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
754pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
755
756/// Exact GraphQL query documents issued by this plugin.
757///
758/// Keeping the production documents here lets the pinned-schema test validate the same
759/// bytes that are sent to GitHub, rather than a test-only copy which could drift
760/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
761/// field, and its guarded caller always supplies the complete existing option set with ids.
762pub mod graphql {
763    /// The board half of one item: the field values every document here reads it from.
764    ///
765    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
766    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
767    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
768    /// *the same value*, because
769    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
770    /// one path. Three spellings of it is what would drift, so there is one.
771    ///
772    /// The `Status` option and this source's own origin text field are the whole of it. It
773    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
774    /// content, so it holds nothing the content's own `labels` do not already say, and it
775    /// would sit a label connection two page sizes deep.
776    macro_rules! board_item_values {
777        () => {
778            r#"fieldValues(first:$nestedFirst){nodes{
779          ... on ProjectV2ItemFieldSingleSelectValue{name field{
780            ... on ProjectV2SingleSelectField{id name options{id name}}
781          }}
782          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
783        }pageInfo{hasNextPage}}"#
784        };
785    }
786
787    /// Everything this source reads about one issue, wherever it reaches that issue.
788    ///
789    /// A macro rather than a constant so the three documents below can `concat!` it: one
790    /// spelling of these fields is what makes an issue read through the board-scoped
791    /// search, through its own node id, and through its project's sub-issue relationship
792    /// resolve to *the same* item, which is the whole of what
793    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
794    ///
795    /// `projectItems` is what carries the board half of an issue: the board item's own id
796    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
797    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
798    /// issue rather than on the board, which is what makes the cost of a read proportional
799    /// to what was asked for instead of to the board's size.
800    ///
801    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
802    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
803    /// not on that page: a page here is where the search for the entry starts rather than
804    /// where it ends.
805    ///
806    /// It does **not** select the board's `Labels` field value, and that is the whole of
807    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
808    /// a label connection there sits under `fieldValues` under `projectItems` under a page
809    /// of issues, spending `$nestedFirst` twice down one path, and took
810    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
811    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
812    /// above, and that connection is where every label this source reports comes from. No
813    /// document in this module selects the board field any longer, [`BOARD`] included; the
814    /// module documentation records why nothing it could have held is lost.
815    macro_rules! board_issue {
816        () => {
817            concat!(
818                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
819      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
820      projectItems(first:$boardItems){nodes{id project{id number}
821        "#,
822                board_item_values!(),
823                r#"}pageInfo{hasNextPage endCursor}}}"#
824            )
825        };
826    }
827
828    /// Every issue of one board, found by a search scoped to that board.
829    ///
830    /// This is how the projects a board holds are listed, and it selects no `items`
831    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
832    /// container walked page by page, so nothing nested inside a board item is paid for.
833    /// Which of the issues it returns is a project is then read off `parent` — GitHub
834    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
835    /// discriminator has to be applied to the field, which is a scalar on the issue and
836    /// costs nothing.
837    pub const SEARCH_ISSUES: &str = concat!(
838        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
839      search(query:$search,type:$type,first:$first,after:$after){
840        pageInfo{hasNextPage endCursor}
841        nodes{__typename ...BoardIssue}
842      }
843    }"#,
844        board_issue!()
845    );
846
847    /// What a dependency read selects of each far end: enough to say which kind of item it
848    /// is, its body included for the kind marker.
849    macro_rules! related_issue {
850        () => {
851            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
852        };
853    }
854
855    /// One issue by its own node id, which is what a qualified id names here — with what a
856    /// write of it needs and the issue does not carry in `board_issue!`: the field
857    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
858    ///
859    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
860    /// answers a write made moments ago with the value from before it, and resolving a node
861    /// id does not.
862    ///
863    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
864    /// reads it by its own id, and with them that one read answers everything the write
865    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
866    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
867    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
868    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
869    /// they sit under one item, and this read is still one point.
870    pub const ISSUE: &str = concat!(
871        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
872      node(id:$id){__typename ...BoardIssue ... on Issue{
873        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
874          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
875          ... on ProjectV2Field{__typename id name}
876        }pageInfo{hasNextPage}}}}}
877        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
878      }}
879    }"#,
880        board_issue!(),
881        related_issue!()
882    );
883
884    /// One project's tasks: the sub-issues of the issue that project is.
885    ///
886    /// The work this costs is the project's own size. Nothing about it grows as the board
887    /// gains projects, or as those projects gain tasks.
888    pub const SUB_ISSUES: &str = concat!(
889        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
890      node(id:$id){__typename
891        ... on Issue{subIssues(first:$first,after:$after){
892          pageInfo{hasNextPage endCursor}
893          nodes{__typename ...BoardIssue}
894        }}}
895    }"#,
896        board_issue!()
897    );
898
899    /// What a read of the board's own `items` selects of each item's content.
900    ///
901    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
902    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
903    /// content by one spelling.
904    macro_rules! board_item_content {
905        () => {
906            r#" content{
907        ... 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}}}
908        ... on PullRequest{__typename id}
909        ... on DraftIssue{__typename id title body createdAt updatedAt}
910      }"#
911        };
912    }
913
914    /// Reads the board's fields and one page of its items.
915    pub const BOARD: &str = concat!(
916        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
917      owner:repositoryOwner(login:$owner){
918        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
919      }
920    } fragment Board on ProjectV2 { id title
921      fields(first:$nestedFirst){nodes{
922        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
923        ... on ProjectV2Field{__typename id name}
924      }pageInfo{hasNextPage}}
925      items(first:$first,after:$after){nodes{id "#,
926        board_item_values!(),
927        board_item_content!(),
928        r#"} pageInfo{hasNextPage endCursor}}
929    }"#
930    );
931
932    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
933    /// the board.
934    ///
935    /// **`originItems`** is the board's own items narrowed by its own field filter —
936    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
937    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
938    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
939    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
940    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
941    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
942    /// fresh `addProjectV2ItemById` the way that connection does.
943    ///
944    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
945    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
946    /// indexes that comment, and the index catches up with a write in a second or two rather
947    /// than in minutes, so it finds a carrier another process wrote that the first read is
948    /// still behind on.
949    ///
950    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
951    /// — and resumes from its own cursor; a connection already walked to its end is resumed
952    /// from its last cursor, which answers an empty page. Every candidate either read returns
953    /// is confirmed against its own origin field before it is reported, so a token match of
954    /// the search or anything else the filter admits never is.
955    ///
956    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
957    /// own whole reads counts this one among them.
958    pub const ORIGIN_LOOKUP: &str = concat!(
959        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
960      originItems:repositoryOwner(login:$owner){
961        ... on ProjectV2Owner{projectV2(number:$number){
962          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
963        board_item_values!(),
964        board_item_content!(),
965        r#"} pageInfo{hasNextPage endCursor}}
966        }}
967      }
968      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
969        pageInfo{hasNextPage endCursor}
970        nodes{__typename ...BoardIssue}
971      }
972    }"#,
973        board_issue!()
974    );
975
976    /// The board's own id and field definitions, and not one of its items.
977    ///
978    /// What a write needs of the board when the item it writes does not say: the id a field
979    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
980    /// origin fields. It selects no `items`, so what it costs is the board's field list
981    /// however many items the board holds — and it decides nothing about which items those
982    /// are, which is the question a read of one item by its own id answers instead.
983    ///
984    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
985    /// board's item reads by their root counts this one among them.
986    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
987      boardFields:repositoryOwner(login:$owner){
988        ... on ProjectV2Owner{projectV2(number:$number){id
989          fields(first:$nestedFirst){nodes{
990            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
991            ... on ProjectV2Field{__typename id name}
992          }pageInfo{hasNextPage}}
993        }}
994      }
995    }"#;
996
997    /// One board draft by its own node id, with the board item it sits in.
998    ///
999    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1000    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1001    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1002    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1003    /// board listing hands it to, and nothing has to list the board to find one.
1004    pub const DRAFT: &str = concat!(
1005        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1006      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1007        projectV2Items(first:$boardItems){nodes{id project{id number}
1008        "#,
1009        board_item_values!(),
1010        r#"}pageInfo{hasNextPage endCursor}}}}
1011    }"#
1012    );
1013
1014    /// One issue's board memberships alone, walked past the page a read of it carried.
1015    ///
1016    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1017    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1018    /// boards than that page holds may have this board's entry past its end. This asks that
1019    /// one issue for its memberships and nothing else — the caller already holds the issue —
1020    /// so an answer of "this board does not hold it" is only ever given about a connection
1021    /// read to exhaustion.
1022    ///
1023    /// It selects the board item's id, its project number and the same
1024    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1025    /// very same resolver: an issue recovered this way reports the same title, the same
1026    /// status, the same labels and the same qualified id as one whose entry was on the
1027    /// page.
1028    ///
1029    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1030    /// multiplies through it and the membership connection can be walked at
1031    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1032    /// further request for any issue a person really keeps.
1033    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1034        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1035      node(id:$id){
1036        ... on Issue{projectItems(first:$first,after:$after){
1037          nodes{id project{id number}
1038        "#,
1039        board_item_values!(),
1040        r#"}
1041          pageInfo{hasNextPage endCursor}}}
1042      }
1043    }"#
1044    );
1045    /// Resolves the configured repository's node id, which creating an issue requires.
1046    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1047    /// What creating an issue needs and has not read yet: the board's own id and field
1048    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1049    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1050    ///
1051    /// Sent at the point a create knows which repository it is for, when neither half is
1052    /// already known to this process; a create needing only one of them sends that one's own
1053    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1054    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1055    /// status.
1056    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1057      boardFields:repositoryOwner(login:$owner){
1058        ... on ProjectV2Owner{projectV2(number:$number){id
1059          fields(first:$nestedFirst){nodes{
1060            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1061            ... on ProjectV2Field{__typename id name}
1062          }pageInfo{hasNextPage}}
1063        }}
1064      }
1065      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1066    }"#;
1067    /// Reads both dependency directions for one issue, with each far end's own kind — and
1068    /// the issue's own body, which is where an edge to another source is recorded, so that
1069    /// half of a dependency read needs no second read of the issue or of the board.
1070    pub const ISSUE_DEPENDENCIES: &str = concat!(
1071        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1072      ... on Issue{body
1073        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1074        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1075      }}}"#,
1076        related_issue!()
1077    );
1078    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1079    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1080    /// answered when it was.
1081    pub const CREATE_ISSUE: &str =
1082        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1083    /// Puts an existing issue on the configured board.
1084    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1085    /// Updates an issue's visible fields and its open or closed state in one call.
1086    pub const UPDATE_ISSUE: &str =
1087        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1088    /// Updates an existing draft's user-visible fields.
1089    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1090    /// Updates a text or single-select value on one project item.
1091    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}}}}}}}}"#;
1092    /// Writes up to three board fields and an optional clear in one ordered mutation.
1093    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}}}"#;
1094    /// Clears one project item's value of one field, which is what a `none` priority is.
1095    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}}}}}}}}"#;
1096    /// Creates one single-select field with its options. Only the guarded field setup may use
1097    /// this document, and only for a field the board lacks.
1098    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1099    /// Replaces a single-select field's options. Only the guarded field setup — the
1100    /// `status-options` and `fields` operations — may use this document, because GitHub
1101    /// treats the input as the complete option list.
1102    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1103    /// A fresh snapshot of the Status field and every board item's assignment.
1104    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}}}}}}"#;
1105    /// Files one issue under another as a sub-issue, which is what project membership is.
1106    pub const ADD_SUB_ISSUE: &str =
1107        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1108    /// Takes one issue back out of its parent.
1109    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1110    /// Adds GitHub's native issue blocked-by relationship.
1111    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1112    /// Removes one native issue blocked-by relationship.
1113    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1114    /// Deletes one issue, which takes its board item with it.
1115    ///
1116    /// The engine sends this in one situation only: undoing a copy that could not finish,
1117    /// over the items that same copy created. Deleting the issue removes the board item
1118    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1119    pub const DELETE_ISSUE: &str =
1120        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1121
1122    /// Everything this source reads about one issue comment, wherever it reaches one.
1123    ///
1124    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1125    /// and a comment just edited are handed to one mapper, so they are selected by one
1126    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1127    /// longer exists, and `login` is the one member every kind of actor carries.
1128    macro_rules! issue_comment {
1129        () => {
1130            "id author{login} createdAt updatedAt body url"
1131        };
1132    }
1133
1134    /// One task's comments: a page of its issue's own `comments` connection.
1135    ///
1136    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1137    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1138    /// list every time somebody edited it; left unordered the connection answers in the order
1139    /// the comments were written, which is the order GitHub documents for the same collection
1140    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1141    /// node count and the caller's own page size is pushed straight down.
1142    pub const ISSUE_COMMENTS: &str = concat!(
1143        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1144        issue_comment!(),
1145        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1146    );
1147    /// One issue by its own node id, with a page of its comments: what `task show` and a
1148    /// comment listing read, in one request.
1149    ///
1150    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1151    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1152    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1153    /// connection there would multiply through both of those documents' price, and neither
1154    /// needs one.
1155    pub const ISSUE_DETAIL: &str = concat!(
1156        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1157      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1158        issue_comment!(),
1159        r#"}pageInfo{hasNextPage endCursor}}}}
1160    }"#,
1161        board_issue!()
1162    );
1163
1164    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1165    /// page of its comments when `$comments` asks for them.
1166    macro_rules! issue_details_alias {
1167        ($n:literal) => {
1168            concat!(
1169                "\n      i",
1170                stringify!($n),
1171                ":node(id:$id",
1172                stringify!($n),
1173                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1174                issue_comment!(),
1175                "}pageInfo{hasNextPage endCursor}}}}"
1176            )
1177        };
1178    }
1179
1180    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1181    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1182    ///
1183    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1184    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1185    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1186    /// neither — so every connection under it would be priced at nothing and the pin in
1187    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1188    /// one-item read the model already prices, so the batch costs what its aliases cost.
1189    ///
1190    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1191    /// slots it has no item for to the last item it does, and reads that item again; the
1192    /// price is the document's, whatever its variables, so a short batch costs what a full
1193    /// one does and nothing more.
1194    pub const ISSUE_DETAILS: &str = concat!(
1195        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!){"#,
1196        issue_details_alias!(0),
1197        issue_details_alias!(1),
1198        issue_details_alias!(2),
1199        issue_details_alias!(3),
1200        issue_details_alias!(4),
1201        issue_details_alias!(5),
1202        issue_details_alias!(6),
1203        issue_details_alias!(7),
1204        issue_details_alias!(8),
1205        issue_details_alias!(9),
1206        issue_details_alias!(10),
1207        issue_details_alias!(11),
1208        issue_details_alias!(12),
1209        issue_details_alias!(13),
1210        issue_details_alias!(14),
1211        issue_details_alias!(15),
1212        issue_details_alias!(16),
1213        issue_details_alias!(17),
1214        issue_details_alias!(18),
1215        issue_details_alias!(19),
1216        issue_details_alias!(20),
1217        issue_details_alias!(21),
1218        issue_details_alias!(22),
1219        issue_details_alias!(23),
1220        "\n    }",
1221        board_issue!()
1222    );
1223
1224    /// Which issue one comment is on, read before that comment is edited or removed.
1225    ///
1226    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1227    /// comment id given against the wrong task would change a comment on another issue.
1228    pub const COMMENT_ISSUE: &str =
1229        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1230    /// Adds one comment to an issue, signed as the account the token belongs to.
1231    pub const ADD_COMMENT: &str = concat!(
1232        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1233        issue_comment!(),
1234        r#"}}}}"#
1235    );
1236    /// Replaces the body of one issue comment.
1237    pub const UPDATE_COMMENT: &str = concat!(
1238        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1239        issue_comment!(),
1240        r#"}}}"#
1241    );
1242    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1243    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1244
1245    /// Every document above, with what this source is doing when it sends one.
1246    ///
1247    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1248    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1249    /// document added later with "talking to GitHub" and never say so.
1250    ///
1251    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1252    /// const` here that this list omits, so the two cannot part — which is the same guard
1253    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1254    pub const DOCUMENTS: [(&str, &str); 33] = [
1255        (SEARCH_ISSUES, "searching this board's issues"),
1256        (ISSUE, "reading one issue"),
1257        (
1258            ISSUE_BOARD_ITEMS,
1259            "reading one issue's board memberships past the page it came with",
1260        ),
1261        (SUB_ISSUES, "reading a project's tasks"),
1262        (BOARD, "reading the board"),
1263        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1264        (BOARD_FIELDS, "reading the board's fields"),
1265        (DRAFT, "reading one draft"),
1266        (REPOSITORY, "reading the destination repository"),
1267        (
1268            CREATION_CONTEXT,
1269            "reading the board's fields and the destination repository",
1270        ),
1271        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1272        (CREATE_ISSUE, "creating an issue"),
1273        (ADD_TO_BOARD, "adding an issue to the board"),
1274        (UPDATE_ISSUE, "updating an issue"),
1275        (UPDATE_DRAFT, "updating a draft item"),
1276        (UPDATE_FIELD, "writing a board field"),
1277        (UPDATE_FIELDS, "writing board fields together"),
1278        (CLEAR_FIELD, "clearing a board field"),
1279        (
1280            CREATE_FIELD,
1281            "creating a board single-select field with its options",
1282        ),
1283        (
1284            STATUS_OPTIONS_SNAPSHOT,
1285            "snapshotting board Status options and assignments",
1286        ),
1287        (
1288            STATUS_OPTIONS_UPDATE,
1289            "safely replacing the board Status option list",
1290        ),
1291        (ADD_SUB_ISSUE, "filing an issue under its project"),
1292        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1293        (ADD_BLOCKED_BY, "recording a dependency"),
1294        (REMOVE_BLOCKED_BY, "removing a dependency"),
1295        (DELETE_ISSUE, "deleting an issue"),
1296        (ISSUE_COMMENTS, "reading a task's comments"),
1297        (ISSUE_DETAIL, "reading one issue with its comments"),
1298        (
1299            ISSUE_DETAILS,
1300            "reading a batch of issues with their comments",
1301        ),
1302        (COMMENT_ISSUE, "reading which issue a comment is on"),
1303        (ADD_COMMENT, "adding a comment"),
1304        (UPDATE_COMMENT, "editing a comment"),
1305        (DELETE_COMMENT, "deleting a comment"),
1306    ];
1307}
1308
1309/// Which of GitHub's two rate limiters refused a request.
1310///
1311/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1312/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1313/// the whole reason this is carried rather than collapsed into "rate limited".
1314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315enum Limiter {
1316    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1317    Primary,
1318    /// The burst limiter over content-generating requests, which nothing reports.
1319    Secondary,
1320}
1321
1322/// The wordings GitHub answers a secondary rate limit with.
1323///
1324/// It sends them under a forbidden status, under a too-many-requests status, and inside
1325/// the `errors` of a *successful* response, which is why the text is what this matches on
1326/// rather than the status. `abuse detection` is the wording GitHub used before the
1327/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1328/// what a burst of content creation is refused with.
1329///
1330/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1331/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1332/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1333/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1334pub const SECONDARY_WORDINGS: [&str; 5] = [
1335    "secondary rate limit",
1336    "temporarily blocked from content creation",
1337    "abuse detection",
1338    "submitted too quickly",
1339    "exceeded a secondary",
1340];
1341
1342/// The wordings GitHub answers an exhausted primary budget with.
1343///
1344/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1345/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1346/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1347/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1348/// two phrases is a substring of it, so without it that answer read as a refusal that will
1349/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1350/// one reason.
1351pub const PRIMARY_WORDINGS: [&str; 4] = [
1352    "api rate limit exceeded",
1353    "api rate limit already exceeded",
1354    "rate limit exceeded",
1355    "rate_limited",
1356];
1357
1358/// What a response *says about itself*, which is the only place a refusal can be read.
1359///
1360/// Deliberately not the whole response body. A board is a place people write about their
1361/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1362/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1363/// reported. So the item data is never read: what is read is GitHub's own REST-style
1364/// `message` envelope, which is what a forbidden status carries, and the `message` and
1365/// `type` of each GraphQL error, which is where a *successful* response says it.
1366///
1367/// A body that is not JSON at all has nothing structured to read, so only a failing
1368/// response's own text is taken — a successful response that is not JSON is malformed
1369/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1370fn refusal_wording(status: StatusCode, body: &str) -> String {
1371    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1372        return if status.is_success() {
1373            String::new()
1374        } else {
1375            body.to_owned()
1376        };
1377    };
1378    let mut said: Vec<&str> = parsed
1379        .get("message")
1380        .and_then(Value::as_str)
1381        .into_iter()
1382        .collect();
1383    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1384        for error in errors {
1385            said.extend(
1386                ["message", "type"]
1387                    .into_iter()
1388                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1389            );
1390        }
1391    }
1392    said.join("; ")
1393}
1394
1395impl Limiter {
1396    /// Which limiter refused this response, or `None` when none of them did.
1397    ///
1398    /// The wording is read first and the status only decides what carries none of it,
1399    /// because GitHub answers a secondary limit with a forbidden status far more often
1400    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1401    /// really is a credential this token lacks.
1402    ///
1403    /// A response is a refusal because of its status or its own wording. A spent budget
1404    /// only ever explains one; it never turns an answer into a refusal.
1405    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1406        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1407        if SECONDARY_WORDINGS
1408            .iter()
1409            .any(|wording| normalized.contains(wording))
1410        {
1411            return Some(Self::Secondary);
1412        }
1413        if status == StatusCode::TOO_MANY_REQUESTS {
1414            return Some(Self::Primary);
1415        }
1416        // An exhausted budget *explains* a response that failed; it does not make one that
1417        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1418        // request the budget allowed as well as on the ones it then refuses, so reading
1419        // the header alone threw away a good answer — and, once refusals were retried,
1420        // replayed a request that had already taken effect.
1421        if !status.is_success() && budget_exhausted {
1422            return Some(Self::Primary);
1423        }
1424        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1425        // `errors` of an HTTP 200, where nothing about the status says so at all.
1426        if status.is_success()
1427            && PRIMARY_WORDINGS
1428                .iter()
1429                .any(|wording| normalized.contains(wording))
1430        {
1431            return Some(Self::Primary);
1432        }
1433        None
1434    }
1435
1436    /// What this limiter is called where an operator can look it up.
1437    const fn name(self) -> &'static str {
1438        match self {
1439            Self::Primary => "GitHub's primary API rate limit",
1440            Self::Secondary => "GitHub's secondary rate limit",
1441        }
1442    }
1443
1444    /// What the endpoint an operator would go and check says about this limiter.
1445    const fn where_to_look(self) -> &'static str {
1446        match self {
1447            Self::Primary => {
1448                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1449                 comes back."
1450            }
1451            Self::Secondary => {
1452                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1453                 primary budget and does not report this one, so budget showing there says \
1454                 nothing about this refusal, and every further attempt extends it."
1455            }
1456        }
1457    }
1458
1459    /// The next step this limiter actually calls for.
1460    const fn what_to_do(self) -> &'static str {
1461        match self {
1462            Self::Primary => {
1463                "wait for the reset `gh api rate_limit` reports, then run the command again."
1464            }
1465            Self::Secondary => {
1466                "leave this board alone for a few minutes, then run the command again — or \
1467                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1468            }
1469        }
1470    }
1471}
1472
1473/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1474#[derive(Debug, Clone, Copy)]
1475struct Limited {
1476    limiter: Limiter,
1477    hint: Option<u64>,
1478}
1479
1480impl Limited {
1481    /// What the caller is told once this source has waited as long as it may.
1482    ///
1483    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1484    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1485    /// about *which* limiter it was makes it a different kind of failure. What differs is
1486    /// the operator's next step, and that is what the message carries — a secondary
1487    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1488    /// budget looks fine, and then back to retry the very burst that was refused.
1489    fn exhausted(
1490        self,
1491        doing: &str,
1492        waits: u32,
1493        waited: Duration,
1494        needed: Duration,
1495        budget: Duration,
1496    ) -> SourceError {
1497        SourceError::RateLimited {
1498            retry_after_seconds: self.hint,
1499            message: Some(format!(
1500                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1501                 again, and the next wait of {} would take it past the {} one call may spend \
1502                 waiting. {} next: {}",
1503                self.limiter.name(),
1504                plural(waits, "refusal"),
1505                seconds(waited),
1506                seconds(needed),
1507                seconds(budget),
1508                self.limiter.where_to_look(),
1509                self.limiter.what_to_do(),
1510            )),
1511        }
1512    }
1513}
1514
1515/// One HTTP attempt's result, with what its response said about the rate limit.
1516///
1517/// The two travel together so the record and the outcome are written from the same place:
1518/// what a response said about the budget is only readable while that response is in hand,
1519/// and what the attempt *meant* is only decidable once its body has been read.
1520struct Attempted {
1521    result: Result<Value, Attempt>,
1522    limits: accounting::RateLimit,
1523    /// GitHub's own reported cost for this call, for a document that asked for it.
1524    reported_cost: Option<u64>,
1525}
1526
1527/// One attempt's outcome: an error to report, or a rate limit to wait out.
1528enum Attempt {
1529    Failed(SourceError),
1530    Limited(Limited),
1531}
1532
1533fn plural(count: u32, thing: &str) -> String {
1534    if count == 1 {
1535        format!("{count} {thing}")
1536    } else {
1537        format!("{count} {thing}s")
1538    }
1539}
1540
1541fn seconds(duration: Duration) -> String {
1542    format!("{:.1}s", duration.as_secs_f64())
1543}
1544
1545/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1546///
1547/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1548/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1549/// header, and neither is what makes a response a refusal — so the whole cost of one this
1550/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1551/// instead. Refusing the response over the header would turn a readable refusal into an
1552/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1553fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1554    value
1555        .and_then(|value| value.to_str().ok())
1556        .and_then(|value| value.trim().parse::<u64>().ok())
1557}
1558
1559/// Every mutation this source sends creates content — an issue, a board item, a field of
1560/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1561/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1562/// and what the keyword says are the same set. That is what makes the keyword a sound test
1563/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1564/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1565fn is_mutation(query: &str) -> bool {
1566    query.trim_start().starts_with("mutation")
1567}
1568
1569/// What this source was doing, for a diagnostic that has to say so.
1570///
1571/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1572/// a document added without a description is caught by that list's own gate instead of
1573/// falling through to the vague arm below.
1574fn operation_description(query: &str) -> &'static str {
1575    graphql::DOCUMENTS
1576        .iter()
1577        .find(|(document, _)| *document == query)
1578        .map_or("talking to GitHub", |(_, doing)| *doing)
1579}
1580
1581/// GitHub's published ceiling on content-generating requests, per minute.
1582///
1583/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1584/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1585/// from it, so a pacing value checked only against itself cannot go stale here.
1586pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1587/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1588/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1589/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1590pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1591/// Shortest interval between two content-creating mutations, in milliseconds.
1592///
1593/// GitHub documents two secondary limits on content-generating requests:
1594/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1595/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1596/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1597/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1598/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1599/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1600/// deliberately *not* what this paces at. An installation that wants the hourly bound
1601/// honoured for a long sequence of copies says so through
1602/// `pacing.min_mutation_interval_ms`.
1603pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1604/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1605///
1606/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1607/// own advice for a secondary limit — wait, and wait longer each time — without spending
1608/// the first minute of a transient refusal doing nothing.
1609pub const RETRY_BACKOFF_MS: u64 = 1_000;
1610/// Total time one call may spend waiting out rate limits before it reports a failure.
1611///
1612/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1613/// short enough that a command an operator is watching returns. The bound is what makes
1614/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1615/// the limiter, not in a process nobody can tell from a wedged one.
1616pub const RETRY_BUDGET_MS: u64 = 120_000;
1617
1618fn default_token_env() -> String {
1619    "GH_PROJECTS_TOKEN".to_owned()
1620}
1621fn default_endpoint() -> String {
1622    "https://api.github.com/graphql".to_owned()
1623}
1624
1625/// Where one status category lands on this board.
1626///
1627/// `null` — an absent value — disables the category for this instance, and using a
1628/// disabled status is a refusal naming the status and the instance.
1629#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1630#[serde(untagged)]
1631pub enum StatusTargetConfig {
1632    /// The name of a `Status` single-select option already on the board.
1633    Column(ColumnName),
1634}
1635
1636/// The name of a `Status` single-select option on the board.
1637///
1638/// Validated on the way in rather than checked later, so a blank option name — which
1639/// nothing on a board can be — is a state this type cannot hold.
1640#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1641#[serde(try_from = "String")]
1642#[schemars(extend("minLength" = 1))]
1643pub struct ColumnName(String);
1644
1645impl ColumnName {
1646    /// The option name, as the board spells it.
1647    fn as_str(&self) -> &str {
1648        &self.0
1649    }
1650}
1651
1652impl TryFrom<String> for ColumnName {
1653    type Error = String;
1654
1655    fn try_from(name: String) -> Result<Self, Self::Error> {
1656        if name.trim().is_empty() {
1657            return Err("a status_mapping option name cannot be blank".to_owned());
1658        }
1659        Ok(Self(name))
1660    }
1661}
1662
1663/// The two closed states this product can mean.
1664///
1665/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1666/// work nor abandoned work, so nothing here ever writes it.
1667#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1668#[serde(rename_all = "kebab-case")]
1669pub enum ClosedState {
1670    /// `COMPLETED` — precisely done.
1671    Completed,
1672    /// `NOT_PLANNED` — precisely cancelled.
1673    NotPlanned,
1674}
1675
1676impl ClosedState {
1677    const fn reason(self) -> &'static str {
1678        match self {
1679            Self::Completed => "COMPLETED",
1680            Self::NotPlanned => "NOT_PLANNED",
1681        }
1682    }
1683}
1684
1685/// Configuration for one GitHub Projects v2 board.
1686#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1687#[serde(default, deny_unknown_fields)]
1688pub struct GitHubProjectsConfig {
1689    /// Login of the user or organization which owns the board.
1690    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1691    /// The project number shown in the board's GitHub URL.
1692    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1693    // 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.
1694    /// `owner/name` of the repository this source creates an issue in when the item's own
1695    /// `repositories` field does not decide it.
1696    ///
1697    /// An item naming exactly one repository is created there; a task or a document naming
1698    /// none or several is created in its parent project's repository; and a project, or a
1699    /// task or document with no parent, naming none or several is created here. A board
1700    /// has no repository of its own and `createIssue` requires one, so a write without
1701    /// this is refused naming the field. Reads never need it.
1702    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1703    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1704    /// Environment variable containing a fine-grained token with Projects and Issues
1705    /// read/write plus Pull requests read-only access for every repository represented on
1706    /// the board.
1707    #[serde(default = "default_token_env")]
1708    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1709    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1710    #[serde(default = "default_endpoint")]
1711    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1712    /// Per-instance mapping from a status category to where it lands on this board.
1713    ///
1714    /// A category this does not mention keeps its shipped default: `backlog` to
1715    /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1716    /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1717    /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1718    /// board option; every unknown word then lands on that option and reads back as
1719    /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1720    /// word because it never creates board options.
1721    #[serde(default)]
1722    pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` parses each key into a `StatusCategory` and reports an unknown one against this instance.
1723    /// Per-instance mapping from a task's priority to an option of this board's
1724    /// single-select field named `Priority`.
1725    ///
1726    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1727    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1728    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1729    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1730    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1731    /// no two levels may name one option. Reads and writes never create the field or an
1732    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1733    /// the board lacks is refused pointing there.
1734    #[serde(default)]
1735    pub priority_mapping: Option<PriorityMappingConfig>,
1736    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1737    ///
1738    /// Every field keeps its shipped default when it is absent, and the defaults are
1739    /// GitHub's own published limits rather than taste. See [`Pacing`].
1740    #[serde(default)]
1741    pub pacing: PacingConfig,
1742}
1743
1744/// Which option of the board's `Priority` field each priority lands on.
1745///
1746/// One member per level rather than a map, so a key that is not a level is refused where
1747/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1748/// value in the field, not an option of it.
1749#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1750#[serde(default, deny_unknown_fields)]
1751pub struct PriorityMappingConfig {
1752    /// The option `urgent` lands on; `Urgent` when absent.
1753    pub urgent: Option<PriorityOptionName>,
1754    /// The option `high` lands on; `High` when absent.
1755    pub high: Option<PriorityOptionName>,
1756    /// The option `medium` lands on; `Medium` when absent.
1757    pub medium: Option<PriorityOptionName>,
1758    /// The option `low` lands on; `Low` when absent.
1759    pub low: Option<PriorityOptionName>,
1760}
1761
1762/// The name of an option of the board's `Priority` single-select field.
1763///
1764/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1765/// blank name.
1766#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1767#[serde(try_from = "String")]
1768#[schemars(extend("minLength" = 1))]
1769pub struct PriorityOptionName(String);
1770
1771impl PriorityOptionName {
1772    /// The option name, as the board spells it.
1773    fn as_str(&self) -> &str {
1774        &self.0
1775    }
1776}
1777
1778impl TryFrom<String> for PriorityOptionName {
1779    type Error = String;
1780
1781    fn try_from(name: String) -> Result<Self, Self::Error> {
1782        if name.trim().is_empty() {
1783            return Err("a priority_mapping option name cannot be blank".to_owned());
1784        }
1785        Ok(Self(name))
1786    }
1787}
1788
1789/// The name of the board field a priority is held in.
1790pub const PRIORITY_FIELD: &str = "Priority";
1791
1792/// The four priorities a board option can hold, in the order a new `Priority` field lists
1793/// them. `none` is not among them: it is the field holding no value.
1794///
1795/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1796/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1797/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1798/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1799pub const PRIORITY_LEVELS: [Priority; 4] = [
1800    Priority::Urgent,
1801    Priority::High,
1802    Priority::Medium,
1803    Priority::Low,
1804];
1805
1806/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1807/// see that list for what this pins.
1808#[must_use]
1809pub const fn level_position(priority: Priority) -> Option<usize> {
1810    match priority {
1811        Priority::None => None,
1812        Priority::Urgent => Some(0),
1813        Priority::High => Some(1),
1814        Priority::Medium => Some(2),
1815        Priority::Low => Some(3),
1816    }
1817}
1818
1819/// This instance's complete priority-to-option mapping, read in both directions.
1820///
1821/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1822/// two levels name one option.
1823#[derive(Debug, Clone)]
1824struct PriorityMapping {
1825    options: [PriorityOptionName; 4],
1826}
1827
1828impl PriorityMapping {
1829    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1830        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1831        let mapping = Self {
1832            options: [
1833                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1834                config.high.unwrap_or_else(|| shipped("High")),
1835                config.medium.unwrap_or_else(|| shipped("Medium")),
1836                config.low.unwrap_or_else(|| shipped("Low")),
1837            ],
1838        };
1839        for (index, option) in mapping.options.iter().enumerate() {
1840            if let Some(earlier) = mapping.options[..index]
1841                .iter()
1842                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1843            {
1844                return Err(SourceError::Config {
1845                    message: format!(
1846                        "priority_mapping of source {instance} sends both {} and {} to the board \
1847                         option {:?}; one option cannot read back as two priorities",
1848                        PRIORITY_LEVELS[earlier],
1849                        PRIORITY_LEVELS[index],
1850                        option.as_str()
1851                    ),
1852                });
1853            }
1854        }
1855        Ok(mapping)
1856    }
1857
1858    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1859    fn option(&self, priority: Priority) -> Option<&str> {
1860        level_position(priority).map(|index| self.options[index].as_str())
1861    }
1862
1863    /// The priority a board option name reports, or `None` when nothing maps to it.
1864    fn priority_of(&self, option: &str) -> Option<Priority> {
1865        self.options
1866            .iter()
1867            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1868            .map(|index| PRIORITY_LEVELS[index])
1869    }
1870
1871    /// Every mapped option name, in the order a new `Priority` field lists them.
1872    fn names(&self) -> impl Iterator<Item = &str> {
1873        self.options.iter().map(PriorityOptionName::as_str)
1874    }
1875}
1876
1877/// What one item's `Priority` field says, read through this instance's mapping.
1878#[derive(Debug, Clone, PartialEq, Eq)]
1879enum HeldPriority {
1880    /// A priority this source reports: an option the mapping names, or no value (`none`).
1881    Read(Priority),
1882    /// An option the mapping does not name, which is never read as a level or as `none`.
1883    Unmapped(String),
1884}
1885
1886/// How fast this source writes, and how long it waits out a rate-limit refusal.
1887///
1888/// Configurable because a GitHub Enterprise installation sets its own limits and an
1889/// operator who has already been refused may want to go slower still — not because the
1890/// defaults are guesses.
1891#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1892#[serde(default, deny_unknown_fields)]
1893pub struct PacingConfig {
1894    /// Shortest interval between two content-creating mutations, in milliseconds.
1895    ///
1896    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1897    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1898    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.
1899    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1900    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1901    /// zero while there is a budget to spend, because a schedule of zero-length waits
1902    /// consumes none of it and so never ends.
1903    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.
1904    /// Total time one call may spend waiting out rate limits, in milliseconds.
1905    ///
1906    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1907    /// the bound is what makes this a wait rather than a hang.
1908    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.
1909}
1910
1911/// The largest any pacing setting may be, in milliseconds.
1912///
1913/// One hour. GitHub's own harshest published bound on content-generating requests works
1914/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1915/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1916/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1917/// and an interval beyond it is a command that never sends its second mutation. It also
1918/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1919/// what an `Instant` can hold on every platform.
1920pub const MAX_PACING_MS: u64 = 3_600_000;
1921
1922/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1923/// source holds.
1924#[derive(Debug, Clone, Copy)]
1925struct Pacing {
1926    min_mutation_interval: Duration,
1927    retry_backoff: Duration,
1928    retry_budget: Duration,
1929}
1930
1931impl Pacing {
1932    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1933    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1934        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1935            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1936                message: format!(
1937                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1938                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1939                     GitHub's own harshest published limit"
1940                ),
1941            }),
1942            Some(value) => Ok(Duration::from_millis(value)),
1943            None => Ok(Duration::from_millis(default)),
1944        };
1945        let retry_backoff = bounded(
1946            config.retry_backoff_ms,
1947            RETRY_BACKOFF_MS,
1948            "retry_backoff_ms",
1949        )?;
1950        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1951        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1952            return Err(SourceError::Config {
1953                message: format!(
1954                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1955                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1956                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1957                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1958                     waiting at all",
1959                    retry_budget.as_millis()
1960                ),
1961            });
1962        }
1963        Ok(Self {
1964            min_mutation_interval: bounded(
1965                config.min_mutation_interval_ms,
1966                MIN_MUTATION_INTERVAL_MS,
1967                "min_mutation_interval_ms",
1968            )?,
1969            retry_backoff,
1970            retry_budget,
1971        })
1972    }
1973}
1974
1975/// Factory for [`GitHubProjectsSource`].
1976#[derive(Debug, Clone, Copy, Default)]
1977pub struct Plugin;
1978
1979impl SourcePlugin for Plugin {
1980    fn kind(&self) -> &'static str {
1981        KIND
1982    }
1983    fn config_schema(&self) -> Schema {
1984        schema_for!(GitHubProjectsConfig)
1985    }
1986    fn build(
1987        &self,
1988        name: &SourceName,
1989        config: &Value,
1990        secrets: &dyn SecretResolver,
1991    ) -> Result<Box<dyn TaskSource>, SourceError> {
1992        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1993    }
1994}
1995
1996impl Plugin {
1997    /// Build a source recording every request it sends into an accounting the caller holds.
1998    ///
1999    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2000    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2001    /// session total rather than two — see [`accounting`] and
2002    /// [`GitHubProjectsSource::recording_into`].
2003    ///
2004    /// # Errors
2005    ///
2006    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2007    /// [`SourceError::Config`] for configuration this plugin cannot use and
2008    /// [`SourceError::Auth`] for a credential it cannot find.
2009    pub fn build_recording_into(
2010        &self,
2011        name: &SourceName,
2012        config: &Value,
2013        secrets: &dyn SecretResolver,
2014        ledger: Arc<Accounting>,
2015    ) -> Result<Box<dyn TaskSource>, SourceError> {
2016        let config: GitHubProjectsConfig =
2017            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2018                message: format!("source {name}: {e}"),
2019            })?;
2020        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2021            |error| match error {
2022                SourceError::Config { message } => SourceError::Config {
2023                    message: format!("source {name}: {message}"),
2024                },
2025                SourceError::Auth { message } => SourceError::Auth {
2026                    message: format!("source {name}: {message}"),
2027                },
2028                other => other,
2029            },
2030        )?;
2031        Ok(Box::new(source))
2032    }
2033}
2034
2035/// Where a status category lands on this board, once configuration is resolved.
2036#[derive(Debug, Clone, PartialEq, Eq)]
2037enum StatusTarget {
2038    /// Not usable against this instance.
2039    Disabled,
2040    /// The board's `Status` option of this name.
2041    Column(ColumnName),
2042    /// A closed issue, with both its board option and the reason that says which closed it means.
2043    // 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, `StatusMapping::new`, 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.
2044    Terminal(ColumnName, ClosedState),
2045}
2046
2047/// Every status category, in the order the vocabulary declares them.
2048///
2049/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2050/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2051/// added to the shared vocabulary fails to compile until it is named there, and this
2052/// crate's suite reconciles this list against that enum's own derived schema, which is
2053/// generated from the variants rather than written beside them. The schema is what
2054/// catches a list left one short — a list checking only the positions it already holds
2055/// would pass while every mapping indexed by the new position panicked.
2056pub const CATEGORIES: [StatusCategory; 8] = [
2057    StatusCategory::Draft,
2058    StatusCategory::Backlog,
2059    StatusCategory::Todo,
2060    StatusCategory::Queued,
2061    StatusCategory::InProgress,
2062    StatusCategory::Done,
2063    StatusCategory::Cancelled,
2064    StatusCategory::Unknown,
2065];
2066
2067/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2068#[must_use]
2069pub const fn category_position(category: StatusCategory) -> usize {
2070    match category {
2071        StatusCategory::Draft => 0,
2072        StatusCategory::Backlog => 1,
2073        StatusCategory::Todo => 2,
2074        StatusCategory::Queued => 3,
2075        StatusCategory::InProgress => 4,
2076        StatusCategory::Done => 5,
2077        StatusCategory::Cancelled => 6,
2078        StatusCategory::Unknown => 7,
2079    }
2080}
2081
2082/// The spelling a status category is configured and reported under.
2083fn category_name(category: StatusCategory) -> &'static str {
2084    match category {
2085        StatusCategory::Draft => "draft",
2086        StatusCategory::Backlog => "backlog",
2087        StatusCategory::Todo => "todo",
2088        StatusCategory::Queued => "queued",
2089        StatusCategory::InProgress => "in-progress",
2090        StatusCategory::Done => "done",
2091        StatusCategory::Cancelled => "cancelled",
2092        StatusCategory::Unknown => "unknown",
2093    }
2094}
2095
2096/// A shipped default's option name.
2097///
2098/// The literals below are this file's own and non-blank, and they are validated by the
2099/// one constructor a configured name goes through rather than beside it.
2100fn shipped_column(name: &'static str) -> ColumnName {
2101    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2102}
2103
2104/// The shipped default for one category, before this instance's configuration.
2105fn shipped_default(category: StatusCategory) -> StatusTarget {
2106    match category {
2107        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2108        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2109        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2110        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2111        StatusCategory::Done => {
2112            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2113        }
2114        StatusCategory::Cancelled => {
2115            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2116        }
2117        StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
2118    }
2119}
2120
2121/// This instance's complete category-to-target mapping, read in both directions.
2122///
2123/// One target per category, held at that category's own [`category_position`], so a
2124/// category missing from the mapping, named twice in it, or filed out of order is a
2125/// state this type cannot hold rather than one [`Self::target`] has to defend against.
2126#[derive(Debug, Clone)]
2127struct StatusMapping {
2128    targets: [StatusTarget; CATEGORIES.len()],
2129}
2130
2131impl StatusMapping {
2132    fn resolve(
2133        configured: BTreeMap<String, Option<StatusTargetConfig>>,
2134        instance: &SourceName,
2135    ) -> Result<Self, SourceError> {
2136        let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
2137        for (key, value) in configured {
2138            let category = CATEGORIES
2139                .iter()
2140                .find(|category| category_name(**category) == key)
2141                .ok_or_else(|| SourceError::Config {
2142                    message: format!(
2143                        "status_mapping names {key:?}, which is not a status category of source \
2144                         {instance}; the categories are {}",
2145                        CATEGORIES
2146                            .iter()
2147                            .map(|category| category_name(*category))
2148                            .collect::<Vec<_>>()
2149                            .join(", ")
2150                    ),
2151                })?;
2152            overrides.insert(category_name(*category), value);
2153        }
2154        // `CATEGORIES[position] == category` for every category — the crate's suite
2155        // asserts it — so mapping the list in order fills each category's own slot.
2156        let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
2157            None => shipped_default(category),
2158            Some(None) => StatusTarget::Disabled,
2159            Some(Some(StatusTargetConfig::Column(option))) => match category {
2160                StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
2161                StatusCategory::Cancelled => {
2162                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2163                }
2164                _ => StatusTarget::Column(option),
2165            },
2166        });
2167        let mapping = Self { targets };
2168        for (index, category) in CATEGORIES.into_iter().enumerate() {
2169            let option = match mapping.target(category) {
2170                StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
2171                StatusTarget::Disabled => continue,
2172            };
2173            if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
2174                matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2175                    if name.as_str().eq_ignore_ascii_case(option.as_str()))
2176            }) {
2177                return Err(SourceError::Config {
2178                    message: format!(
2179                        "status_mapping of source {instance} sends both {} and {} to the board \
2180                         option {:?}; one option cannot read back as two categories",
2181                        category_name(*other),
2182                        category_name(category),
2183                        option.as_str()
2184                    ),
2185                });
2186            }
2187        }
2188        Ok(mapping)
2189    }
2190
2191    fn target(&self, category: StatusCategory) -> &StatusTarget {
2192        &self.targets[category_position(category)]
2193    }
2194
2195    /// The category a board option name reports, or `None` when nothing maps to it.
2196    fn category_of(&self, option: &str) -> Option<StatusCategory> {
2197        CATEGORIES.into_iter().find(|category| {
2198            matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2199                if name.as_str().eq_ignore_ascii_case(option))
2200        })
2201    }
2202
2203    /// The status an item reports, from the three things a read of it says: its board
2204    /// `Status` option, whether its issue is closed, and the reason it was closed with.
2205    ///
2206    /// The closed state decides the category and the `Status` option decides the name, so
2207    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2208    /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2209    /// duplicate is not finished work, and calling it done is a lie the next copy would
2210    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2211    /// it is read permissively rather than refused — reads are faithful, and refusals
2212    /// belong on writes.
2213    ///
2214    /// One function of those three rather than of a response, so a narrow status write can
2215    /// answer what a re-read would report by applying it to the state it has just written.
2216    fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2217        if closed {
2218            let category = match reason {
2219                None | Some("COMPLETED") => StatusCategory::Done,
2220                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2221                Some(_) => StatusCategory::Unknown,
2222            };
2223            let fallback = match category {
2224                StatusCategory::Done => "Done",
2225                StatusCategory::Cancelled => "Cancelled",
2226                _ => "Closed",
2227            };
2228            return Status {
2229                category,
2230                name: option.unwrap_or(fallback).to_owned(),
2231            };
2232        }
2233        let name = option.unwrap_or("Open").to_owned();
2234        Status {
2235            category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2236            name,
2237        }
2238    }
2239}
2240
2241// 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.
2242/// One repository this source can create an issue in, as `owner/name`.
2243///
2244/// Every `createIssue` this source sends names one of these: the item's own single
2245/// `repositories` entry, else its parent project issue's repository, else the configured
2246/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2247/// that choice and says what it refuses before `createIssue`.
2248// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2249#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2250struct RepositoryTarget {
2251    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2252    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2253}
2254
2255impl RepositoryTarget {
2256    fn parse(value: &str) -> Result<Self, SourceError> {
2257        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2258            message: format!(
2259                "repository must be spelled owner/name; {value:?} names no repository"
2260            ),
2261        })?;
2262        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2263            return Err(SourceError::Config {
2264                message: format!(
2265                    "repository must be spelled owner/name with a GitHub login and one \
2266                     repository name; {value:?} is not"
2267                ),
2268            });
2269        }
2270        Ok(Self {
2271            owner: owner.to_owned(),
2272            name: name.to_owned(),
2273        })
2274    }
2275
2276    /// The one host whose repositories this source creates issues in, spelled once: it is
2277    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2278    const HOST: &str = "github.com";
2279
2280    fn origin(&self) -> String {
2281        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2282    }
2283
2284    /// The repository a normalized origin names, or why it is none this source can create
2285    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2286    fn from_origin(origin: &Repository) -> Result<Self, String> {
2287        let not_here = || {
2288            format!(
2289                "{} is not a {}/owner/name repository",
2290                origin.as_str(),
2291                Self::HOST
2292            )
2293        };
2294        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2295        if host != Self::HOST {
2296            return Err(not_here());
2297        }
2298        Self::parse(rest).map_err(|_| not_here())
2299    }
2300
2301    fn slug(&self) -> String {
2302        format!("{}/{}", self.owner, self.name)
2303    }
2304}
2305
2306/// A source which reads GitHub afresh for every operation.
2307pub struct GitHubProjectsSource {
2308    /// This source's configured name, used both to tell a far end naming this source
2309    /// from one naming a system it knows nothing about, and to name the instance a
2310    /// status refusal is about.
2311    name: SourceName,
2312    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2313    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2314    repository: Option<RepositoryTarget>,
2315    endpoint: Url,
2316    token: SecretString,
2317    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2318    statuses: StatusMapping,
2319    /// Where each priority lands on this board, or `None` when this instance holds none.
2320    priorities: Option<PriorityMapping>,
2321    client: Client,
2322    /// Every item this source has created since it was built, in the order it created
2323    /// them.
2324    ///
2325    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2326    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2327    /// a copy resolving a dependency on an item it had just created refused it as not
2328    /// found. A board read is completed from this — an item remembered here and absent from
2329    /// the read is added back, because the board really does hold it and only the read is
2330    /// behind.
2331    ///
2332    /// It is not a cache of a user's work: nothing is remembered that this process did not
2333    /// itself just write, it lives and dies with the process, and it is never consulted for
2334    /// an item this source did not create.
2335    created: Mutex<Vec<Resolved>>,
2336    /// Every item that already existed and that this source has written since it was built,
2337    /// as it wrote it.
2338    ///
2339    /// The other half of [`Self::created`], held on the same terms and for the reason a
2340    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2341    /// filter is an index behind a write this process made moments ago, so a query matching
2342    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2343    /// is remembered that this process did not itself just write.
2344    updated: Mutex<Vec<Resolved>>,
2345    /// How fast this source writes, and how long it waits out a refusal.
2346    pacing: Pacing,
2347    /// When the last content-creating mutation finished, or the moment the furthest-out
2348    /// reserved slot releases the next one, whichever is later — so the one after it can be
2349    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2350    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2351    /// what it is measured from.
2352    last_mutation: Mutex<Option<Instant>>,
2353    /// The board as this process last read it, for the length of one command.
2354    ///
2355    /// A copy of a project used to re-read the whole board, paged, before writing each of
2356    /// its items, which is by far the largest part of a copy's request count and none of
2357    /// its work. Nothing else changes this board while a command runs — this source's own
2358    /// writes are the only writer — so one read answers them all.
2359    ///
2360    /// It is not a store of a user's work and it is not the cache the no-persistence
2361    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2362    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2363    /// an item this command created and then depends on resolves whether or not GitHub's
2364    /// own eventually-consistent read has caught up. A write to an item already on the
2365    /// board updates the entry here too, so what this holds is the last read plus this
2366    /// process's own writes rather than a snapshot taken before them.
2367    board_cache: Mutex<Option<Board>>,
2368    /// Every issue this board's own search reported, for the length of one command.
2369    ///
2370    /// The second half of a board read, and cached for the same reason and on the same
2371    /// terms as the first: it lives and dies with the process, nothing is written down, and
2372    /// a write this process makes updates the entry here exactly as it updates the one in
2373    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2374    /// that lists this board's projects and its tasks pays for one search rather than two.
2375    search_cache: Mutex<Option<Vec<Resolved>>>,
2376    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2377    /// the length of one command.
2378    ///
2379    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2380    /// and dies with the process, nothing is written down, a write this process makes
2381    /// updates the entry here as it updates the other two, and every answer is completed
2382    /// with this process's own writes each time it is given. A command that asks the same
2383    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2384    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2385    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2386    search_next: Mutex<BTreeMap<String, Option<String>>>,
2387    /// Records already resolved in this source instance, reused by writes and
2388    /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2389    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2390    /// The board's own id and field definitions as this process last read them on their
2391    /// own, for the length of one command.
2392    ///
2393    /// What a write needs of the board and its item does not say, read once per command
2394    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2395    /// lives and dies with the process and nothing is written down. It holds no item and so
2396    /// can answer no question about one — see [`Self::board_fields`].
2397    fields_cache: Mutex<Option<BoardFields>>,
2398    /// Each destination repository's node id, resolved once per repository
2399    /// rather than per issue created.
2400    ///
2401    /// A repository's node id does not change, and re-reading it for every issue of a copy
2402    /// spent one request per item on an answer this source already had. It is a map rather
2403    /// than one entry because a copy files each item in the repository its own
2404    /// `repositories` field names, so a plan across five repositories asks GitHub five
2405    /// times and not once per item.
2406    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2407    /// What every request this source sends is recorded into.
2408    ///
2409    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2410    /// a request leaves this crate, so nothing has to be switched on for a session to be
2411    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2412    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2413    /// source's reads and writes — adds up one accounting instead of two. See
2414    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2415    ledger: Arc<Accounting>,
2416}
2417
2418/// GitHub's closed single-select color vocabulary.
2419#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2420#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2421pub enum StatusOptionColor {
2422    /// Gray.
2423    Gray,
2424    /// Blue.
2425    Blue,
2426    /// Green.
2427    Green,
2428    /// Yellow.
2429    Yellow,
2430    /// Purple.
2431    Purple,
2432    /// Red.
2433    Red,
2434    /// Orange.
2435    Orange,
2436    /// Pink.
2437    Pink,
2438}
2439
2440/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2441/// applies its additions.
2442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2443pub enum SetupMode {
2444    /// Read without mutation.
2445    Plan,
2446    /// Apply and verify.
2447    Apply,
2448}
2449
2450/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2451/// against it goes on compiling.
2452pub type StatusOptionsMode = SetupMode;
2453
2454/// The explicit result of the requested operation.
2455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2456#[serde(rename_all = "kebab-case")]
2457pub enum StatusOptionsOutcome {
2458    /// A read-only plan.
2459    Planned,
2460    /// Apply found nothing missing.
2461    Unchanged,
2462    /// Additions were applied and verified.
2463    Applied,
2464}
2465
2466/// A GitHub single-select option's opaque GraphQL node identifier.
2467#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2468#[serde(transparent)]
2469pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2470
2471impl TryFrom<String> for StatusOptionId {
2472    type Error = String;
2473
2474    fn try_from(id: String) -> Result<Self, Self::Error> {
2475        if id.trim().is_empty() {
2476            return Err("a GitHub Status option id cannot be blank".to_owned());
2477        }
2478        Ok(Self(id))
2479    }
2480}
2481
2482/// One existing or proposed option in a guarded Status-field update.
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2484pub struct StatusOption {
2485    /// GitHub's stable id.
2486    pub id: StatusOptionId,
2487    /// The visible option name.
2488    pub name: ColumnName,
2489    /// GitHub's single-select color token.
2490    pub color: StatusOptionColor,
2491    /// The option description, including an empty one.
2492    pub description: String,
2493}
2494
2495/// One board item's Status assignment, retained as recovery data.
2496#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2497pub struct StatusAssignment {
2498    /// The project item id whose assignment this is.
2499    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2500    // carried verbatim as operator recovery data; introducing a semantic type would claim
2501    // validation rules GitHub does not publish and no operation here interprets.
2502    pub item_id: String,
2503    /// The selected option, absent when the item has no status.
2504    #[serde(skip_serializing_if = "Option::is_none")]
2505    pub option: Option<AssignedStatusOption>,
2506}
2507
2508/// The inseparable id and name of an assigned option.
2509#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2510pub struct AssignedStatusOption {
2511    /// GitHub's stable id.
2512    pub id: StatusOptionId,
2513    /// The visible name.
2514    pub name: ColumnName,
2515}
2516
2517/// The plan and verified outcome of reconciling configured Status options.
2518#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2519pub struct StatusOptionsReport {
2520    /// The configured source name.
2521    pub source: SourceName,
2522    /// Configured option names absent before the operation.
2523    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2524    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2525    // serialized string here preserves the report's intentionally simple public contract.
2526    pub missing: Vec<String>,
2527    /// What the requested operation did.
2528    pub outcome: StatusOptionsOutcome,
2529    /// The complete option list observed before any mutation.
2530    pub existing: Vec<StatusOption>,
2531}
2532
2533#[derive(Debug, Clone, PartialEq, Eq)]
2534struct StatusSnapshot {
2535    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2536    // passed back as the mutation's project identity; a newtype could enforce no stronger
2537    // invariant because GitHub publishes no grammar for it.
2538    board_id: String,
2539    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2540    // passed back as the mutation's field identity; a newtype could enforce no stronger
2541    // invariant because GitHub publishes no grammar for it.
2542    field_id: String,
2543    options: Vec<StatusOption>,
2544    assignments: Vec<StatusAssignment>,
2545}
2546
2547/// The name of the board field a status is held in.
2548const STATUS_FIELD: &str = "Status";
2549
2550/// Every item's value of each field `report` names, as it stood before the setup wrote
2551/// anything — what a person puts back when the setup is refused part way.
2552fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2553    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2554        .fields
2555        .iter()
2556        .map(|field| (field.field.name(), before.assignments(field.field)))
2557        .collect();
2558    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2559        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2560    })
2561}
2562
2563/// One board field the guarded setup reads and writes — every one it reads, and the only
2564/// ones it writes.
2565#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2566pub enum BoardField {
2567    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2568    Status,
2569    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2570    Priority,
2571}
2572
2573impl BoardField {
2574    /// The field's name on the board.
2575    #[must_use]
2576    pub const fn name(self) -> &'static str {
2577        match self {
2578            Self::Status => STATUS_FIELD,
2579            Self::Priority => PRIORITY_FIELD,
2580        }
2581    }
2582
2583    /// The field a board calls `name`, or `None` for one this setup does not own.
2584    fn named(name: &str) -> Option<Self> {
2585        [Self::Status, Self::Priority]
2586            .into_iter()
2587            .find(|field| field.name() == name)
2588    }
2589}
2590
2591/// What the guarded setup did to one field.
2592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2593#[serde(rename_all = "kebab-case")]
2594pub enum FieldOutcome {
2595    /// A read-only plan.
2596    Planned,
2597    /// Apply found the field there with every configured option.
2598    Unchanged,
2599    /// Missing options were added to the field that was there, and verified.
2600    Applied,
2601    /// The field was not there; it was created holding the configured options, and verified.
2602    Created,
2603}
2604
2605/// One field's plan, or its verified outcome.
2606#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2607pub struct FieldReport {
2608    /// Which field.
2609    pub field: BoardField,
2610    /// Whether the board had the field before the operation.
2611    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2612    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2613    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2614    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2615    // one constructor, and it derives `outcome` from `exists` in one match.
2616    pub exists: bool,
2617    /// Configured option names the field lacked before the operation — every one of them,
2618    /// in the order a new field lists them, when the field was not there at all.
2619    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2620    // mapping name and has therefore already passed its nonblank validation; the serialized
2621    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2622    pub missing: Vec<String>,
2623    /// What the requested operation did.
2624    pub outcome: FieldOutcome,
2625    /// The field's complete option list observed before any mutation; empty when the field
2626    /// was not there.
2627    pub existing: Vec<StatusOption>,
2628}
2629
2630/// The plan and verified outcome of setting up every field a source's configuration names.
2631#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2632pub struct FieldsReport {
2633    /// The configured source name.
2634    pub source: SourceName,
2635    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2636    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2637    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2638    // per field would change a published JSON shape. The states the list could hold and the
2639    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2640    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2641    pub fields: Vec<FieldReport>,
2642}
2643
2644/// Which options one field is configured with, in the order a new field would list them.
2645struct FieldPlan {
2646    field: BoardField,
2647    wanted: Vec<String>,
2648}
2649
2650/// One single-select field as the guarded setup snapshots it.
2651#[derive(Debug, Clone, PartialEq, Eq)]
2652struct SnapshotField {
2653    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2654    // passed back as the mutation's field identity; a newtype could enforce no stronger
2655    // invariant because GitHub publishes no grammar for it.
2656    field_id: String,
2657    options: Vec<StatusOption>,
2658}
2659
2660/// Every single-select field of a board and every item's value of each.
2661#[derive(Debug, Clone, PartialEq, Eq)]
2662struct BoardSnapshot {
2663    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2664    // passed back as the mutation's project identity; a newtype could enforce no stronger
2665    // invariant because GitHub publishes no grammar for it.
2666    board_id: String,
2667    fields: BTreeMap<BoardField, SnapshotField>,
2668    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2669    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2670}
2671
2672impl BoardSnapshot {
2673    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2674    /// carries.
2675    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2676        self.items
2677            .iter()
2678            .map(|(item_id, values)| StatusAssignment {
2679                item_id: item_id.clone(),
2680                option: values.get(&field).cloned(),
2681            })
2682            .collect()
2683    }
2684}
2685
2686impl GitHubProjectsSource {
2687    /// Report missing configured Status options and, when `apply` is true, add them with
2688    /// a whole-list mutation that preserves every existing id and verifies the result.
2689    ///
2690    /// # Errors
2691    ///
2692    /// Refuses a board without a single-select `Status` field. A post-write difference in
2693    /// any pre-existing option id or item assignment is refused with the complete pre-write
2694    /// assignment snapshot in the diagnostic for recovery.
2695    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2696    // successful mutation, both drift refusals, source selection, missing Status, casing,
2697    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2698    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2699    // responses from entering the defensive malformed-response branches below.
2700    pub async fn status_options(
2701        &self,
2702        mode: StatusOptionsMode,
2703    ) -> Result<StatusOptionsReport, SourceError> {
2704        let before = self.status_snapshot().await?;
2705        let configured = self
2706            .statuses
2707            .targets
2708            .iter()
2709            // A terminal category's option is as configured as an open one's: a terminal
2710            // write validates it before closing and refuses when the board lacks it.
2711            .filter_map(|target| match target {
2712                StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2713                    Some(name.as_str().to_owned())
2714                }
2715                StatusTarget::Disabled => None,
2716            });
2717        let missing = configured
2718            .filter(|wanted| {
2719                !before
2720                    .options
2721                    .iter()
2722                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2723            })
2724            .collect::<Vec<_>>();
2725        let report = StatusOptionsReport {
2726            source: self.name.clone(),
2727            missing: missing.clone(),
2728            outcome: match (mode, missing.is_empty()) {
2729                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2730                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2731                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2732            },
2733            existing: before.options.clone(),
2734        };
2735        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2736            return Ok(report);
2737        }
2738        let mut options = before
2739            .options
2740            .iter()
2741            .map(|option| {
2742                json!({
2743                    "id": option.id, "name": option.name, "color": option.color,
2744                    "description": option.description,
2745                })
2746            })
2747            .collect::<Vec<_>>();
2748        options.extend(missing.iter().map(|name| {
2749            json!({
2750                "name": name, "color": "GRAY", "description": ""
2751            })
2752        }));
2753        self.graphql(
2754            graphql::STATUS_OPTIONS_UPDATE,
2755            json!({"input": {
2756                "projectId": before.board_id, "fieldId": before.field_id,
2757                "singleSelectOptions": options,
2758            }}),
2759        )
2760        .await?;
2761        let after = self.status_snapshot().await?;
2762        let options_preserved = before
2763            .options
2764            .iter()
2765            .all(|old| after.options.iter().any(|new| new == old));
2766        let additions_present = missing.iter().all(|wanted| {
2767            after
2768                .options
2769                .iter()
2770                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2771        });
2772        if !options_preserved || !additions_present || after.assignments != before.assignments {
2773            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2774                SourceError::Malformed {
2775                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2776                }
2777            })?;
2778            return Err(SourceError::Refused {
2779                message: format!(
2780                    "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}"
2781                ),
2782            });
2783        }
2784        Ok(report)
2785    }
2786
2787    /// A fresh snapshot of the Status field and every board item's assignment of it.
2788    ///
2789    /// # Errors
2790    ///
2791    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2792    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2793        // Status alone, as this operation has always read it: a `Priority` field is another
2794        // operation's, so nothing about it can refuse this one.
2795        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2796        let field = board
2797            .fields
2798            .remove(&BoardField::Status)
2799            .ok_or_else(|| self.no_status_field())?;
2800        Ok(StatusSnapshot {
2801            assignments: board.assignments(BoardField::Status),
2802            board_id: board.board_id,
2803            field_id: field.field_id,
2804            options: field.options,
2805        })
2806    }
2807
2808    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2809    fn no_status_field(&self) -> SourceError {
2810        SourceError::Refused {
2811            message: format!("source {} board has no Status field", self.name),
2812        }
2813    }
2814
2815    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2816    // the real CLI loopback journey, including pagination. The individual malformed guards
2817    // are defensive validation of a schema-pinned third-party response, not separate user
2818    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2819    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2820    /// every board item's value of each, walked to the end of the board's items. A field not
2821    /// in `owned` is read past whatever it holds.
2822    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2823        let mut after: Option<String> = None;
2824        let mut snapshot: Option<BoardSnapshot> = None;
2825        loop {
2826            let data = self
2827                .graphql(
2828                    graphql::STATUS_OPTIONS_SNAPSHOT,
2829                    json!({
2830                        "owner": self.owner, "number": self.project_number,
2831                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2832                    }),
2833                )
2834                .await?;
2835            let board = data
2836                .pointer("/owner/projectV2")
2837                .filter(|board| board.is_object())
2838                .ok_or_else(|| SourceError::Refused {
2839                    message: format!(
2840                        "source {} has no accessible GitHub Projects board",
2841                        self.name
2842                    ),
2843                })?;
2844            if board
2845                .pointer("/fields/pageInfo/hasNextPage")
2846                .and_then(Value::as_bool)
2847                != Some(false)
2848            {
2849                return Err(SourceError::Malformed {
2850                    message:
2851                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2852                            .into(),
2853                });
2854            }
2855            let mut fields = BTreeMap::new();
2856            // Only the fields this setup owns, by name: a node the single-select fragment did not
2857            // match carries no name, and a person's own single-select field — a `Size`, a
2858            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2859            // `Status` or `Priority` field without its options is malformed, not absent.
2860            // 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.
2861            for (owned, field) in board
2862                .pointer("/fields/nodes")
2863                .and_then(Value::as_array)
2864                .ok_or_else(|| SourceError::Malformed {
2865                    message: "GitHub project fields.nodes is not an array".into(),
2866                })?
2867                .iter()
2868                .filter_map(|field| {
2869                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2870                    owned.contains(&named).then_some((named, field))
2871                })
2872            {
2873                let options = field
2874                    .get("options")
2875                    .and_then(Value::as_array)
2876                    .ok_or_else(|| SourceError::Malformed {
2877                        message: "GitHub single-select field options is not an array".into(),
2878                    })?
2879                    .iter()
2880                    .map(|option| {
2881                        Ok(StatusOption {
2882                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2883                                .map_err(|message| SourceError::Malformed { message })?,
2884                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2885                                .map_err(|message| SourceError::Malformed {
2886                                    message: format!(
2887                                        "GitHub single-select option name is invalid: {message}"
2888                                    ),
2889                                })?,
2890                            color: serde_json::from_value(
2891                                option.get("color").cloned().unwrap_or(Value::Null),
2892                            )
2893                            .map_err(|error| {
2894                                SourceError::Malformed {
2895                                    message: format!(
2896                                        "GitHub single-select option color is invalid: {error}"
2897                                    ),
2898                                }
2899                            })?,
2900                            description: optional_str(option, "description")?
2901                                .unwrap_or_default()
2902                                .to_owned(),
2903                        })
2904                    })
2905                    .collect::<Result<Vec<_>, SourceError>>()?;
2906                let snapshot = SnapshotField {
2907                    field_id: required_nonblank_str(field, "id")?.to_owned(),
2908                    options,
2909                };
2910                // A board's field names are unique, so a second one is an answer that cannot
2911                // say which field the setup would act on — refused rather than one chosen.
2912                if fields.insert(owned, snapshot).is_some() {
2913                    return Err(SourceError::Malformed {
2914                        message: format!(
2915                            "GitHub answered two {} fields for this board",
2916                            owned.name()
2917                        ),
2918                    });
2919                }
2920            }
2921            let board_id = required_nonblank_str(board, "id")?.to_owned();
2922            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2923                board_id,
2924                fields,
2925                items: Vec::new(),
2926            });
2927            let items = board
2928                .pointer("/items/nodes")
2929                .and_then(Value::as_array)
2930                .ok_or_else(|| SourceError::Malformed {
2931                    message: "GitHub project items.nodes is not an array".into(),
2932                })?;
2933            for item in items {
2934                let field_values =
2935                    item.get("fieldValues")
2936                        .ok_or_else(|| SourceError::Malformed {
2937                            message: "GitHub project item is missing fieldValues".into(),
2938                        })?;
2939                if field_values
2940                    .pointer("/pageInfo/hasNextPage")
2941                    .and_then(Value::as_bool)
2942                    != Some(false)
2943                {
2944                    return Err(SourceError::Malformed {
2945                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2946                    });
2947                }
2948                let values = item
2949                    .pointer("/fieldValues/nodes")
2950                    .and_then(Value::as_array)
2951                    .ok_or_else(|| SourceError::Malformed {
2952                        message: "GitHub project item fieldValues.nodes is not an array".into(),
2953                    })?;
2954                let item_id = required_nonblank_str(item, "id")?;
2955                let mut assigned = BTreeMap::new();
2956                for value in values {
2957                    let Some(field) = value
2958                        .pointer("/field/name")
2959                        .and_then(Value::as_str)
2960                        .and_then(BoardField::named)
2961                        .filter(|field| owned.contains(field))
2962                    else {
2963                        continue;
2964                    };
2965                    let held = assigned.insert(
2966                        field,
2967                        AssignedStatusOption {
2968                            id: StatusOptionId::try_from(
2969                                required_str(value, "optionId")?.to_owned(),
2970                            )
2971                            .map_err(|message| SourceError::Malformed { message })?,
2972                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2973                                .map_err(|message| SourceError::Malformed {
2974                                    message: format!(
2975                                        "GitHub assigned {} name is invalid: {message}",
2976                                        field.name()
2977                                    ),
2978                                })?,
2979                        },
2980                    );
2981                    // An item holds one value of a field, so a second one leaves no way to
2982                    // tell which it holds — and a verification or recovery built on either
2983                    // could restore the wrong one.
2984                    if held.is_some() {
2985                        return Err(SourceError::Malformed {
2986                            message: format!(
2987                                "GitHub answered two {} values for board item {item_id}",
2988                                field.name()
2989                            ),
2990                        });
2991                    }
2992                }
2993                current.items.push((item_id.to_owned(), assigned));
2994            }
2995            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2996                message: "GitHub project is missing items".into(),
2997            })?;
2998            let has_next = page
2999                .pointer("/pageInfo/hasNextPage")
3000                .and_then(Value::as_bool)
3001                .ok_or_else(|| SourceError::Malformed {
3002                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3003                })?;
3004            if !has_next {
3005                break;
3006            }
3007            let next =
3008                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3009            validate_cursor_progress(after.as_deref(), next)?;
3010            after = Some(next.to_owned());
3011        }
3012        snapshot.ok_or_else(|| SourceError::Malformed {
3013            message: "GitHub returned no board field snapshot".into(),
3014        })
3015    }
3016    // llmlint: ignore-end[changed_behavior_has_e2e]
3017
3018    /// Report every board field this source's configuration names and, with
3019    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3020    /// the `Priority` field when the board has none.
3021    ///
3022    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3023    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3024    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3025    /// color and description: the whole option list goes back with every existing id, because
3026    /// a re-minted id clears every item's value.
3027    ///
3028    /// # Errors
3029    ///
3030    /// Refuses a board without a single-select `Status` field. After an apply the board is
3031    /// read again, and a pre-existing option or any item's value of either field that moved is
3032    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3033    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3034    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3035    // with no Status field and a non-github-projects source through the compiled CLI against
3036    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3037    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3038        let owned: Vec<BoardField> = if self.priorities.is_some() {
3039            vec![BoardField::Status, BoardField::Priority]
3040        } else {
3041            vec![BoardField::Status]
3042        };
3043        let before = self.board_snapshot(&owned).await?;
3044        let mut plans = vec![FieldPlan {
3045            field: BoardField::Status,
3046            wanted: self
3047                .statuses
3048                .targets
3049                .iter()
3050                .filter_map(|target| match target {
3051                    StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
3052                        Some(name.as_str().to_owned())
3053                    }
3054                    StatusTarget::Disabled => None,
3055                })
3056                .collect(),
3057        }];
3058        if !before.fields.contains_key(&BoardField::Status) {
3059            return Err(self.no_status_field());
3060        }
3061        if let Some(mapping) = &self.priorities {
3062            plans.push(FieldPlan {
3063                field: BoardField::Priority,
3064                wanted: mapping.names().map(str::to_owned).collect(),
3065            });
3066        }
3067        // The snapshot reads single-select fields alone, so a field it did not find may still
3068        // be on the board under the name, of another type: creating one beside it would fail
3069        // part way, or leave two fields of one name. Asked of the board's own field list, and
3070        // only when a field is missing.
3071        if plans
3072            .iter()
3073            .any(|plan| !before.fields.contains_key(&plan.field))
3074        {
3075            let board = self.board_fields().await?;
3076            for plan in plans
3077                .iter()
3078                .filter(|plan| !before.fields.contains_key(&plan.field))
3079            {
3080                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3081                    return Err(SourceError::Refused {
3082                        message: format!(
3083                            "source {}'s board has a {} field that is not a single-select field \
3084                             (it is a {}), so it cannot hold this source's options; next: rename \
3085                             or remove that field, then run this again",
3086                            self.name,
3087                            plan.field.name(),
3088                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3089                        ),
3090                    });
3091                }
3092            }
3093        }
3094        let mut reports = Vec::new();
3095        for plan in &plans {
3096            let held = before.fields.get(&plan.field);
3097            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3098            let mut missing: Vec<String> = Vec::new();
3099            for wanted in &plan.wanted {
3100                let present = existing
3101                    .iter()
3102                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3103                    || missing
3104                        .iter()
3105                        .any(|named| named.eq_ignore_ascii_case(wanted));
3106                if !present {
3107                    missing.push(wanted.clone());
3108                }
3109            }
3110            reports.push(FieldReport {
3111                field: plan.field,
3112                exists: held.is_some(),
3113                outcome: match (mode, held.is_some(), missing.is_empty()) {
3114                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3115                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3116                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3117                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3118                },
3119                missing,
3120                existing,
3121            });
3122        }
3123        let report = FieldsReport {
3124            source: self.name.clone(),
3125            fields: reports,
3126        };
3127        let writes: Vec<&FieldReport> = report
3128            .fields
3129            .iter()
3130            .filter(|field| !field.missing.is_empty() || !field.exists)
3131            .collect();
3132        if mode == SetupMode::Plan || writes.is_empty() {
3133            return Ok(report);
3134        }
3135        let mut landed: Vec<&str> = Vec::new();
3136        for field in &writes {
3137            let added = field
3138                .missing
3139                .iter()
3140                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3141            let sent = match before.fields.get(&field.field) {
3142                Some(held) => {
3143                    let mut options = held
3144                        .options
3145                        .iter()
3146                        .map(|option| {
3147                            json!({
3148                                "id": option.id, "name": option.name, "color": option.color,
3149                                "description": option.description,
3150                            })
3151                        })
3152                        .collect::<Vec<_>>();
3153                    options.extend(added);
3154                    self.graphql(
3155                        graphql::STATUS_OPTIONS_UPDATE,
3156                        json!({"input": {
3157                            "projectId": before.board_id, "fieldId": held.field_id,
3158                            "singleSelectOptions": options,
3159                        }}),
3160                    )
3161                    .await
3162                }
3163                None => {
3164                    self.graphql(
3165                        graphql::CREATE_FIELD,
3166                        json!({"input": {
3167                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3168                            "name": field.field.name(),
3169                            "singleSelectOptions": added.collect::<Vec<_>>(),
3170                        }}),
3171                    )
3172                    .await
3173                }
3174            };
3175            // A mutation that failed does not establish that GitHub left its field as it was,
3176            // so every failure from here on carries the recovery data a drift refusal does.
3177            match sent {
3178                Ok(_) => landed.push(field.field.name()),
3179                Err(error) => {
3180                    let changed = if landed.is_empty() {
3181                        String::new()
3182                    } else {
3183                        format!("changed the {} field and then ", landed.join(" and "))
3184                    };
3185                    return Err(SourceError::Refused {
3186                        message: format!(
3187                            "the guarded field setup {changed}failed on the {} field, which it may \
3188                             have changed part way: {error}; the pre-write item assignments \
3189                             are:\n{}",
3190                            field.field.name(),
3191                            recovery(&report, &before)?
3192                        ),
3193                    });
3194                }
3195            }
3196        }
3197        // The board has been written, so a verification read that fails leaves it unverified
3198        // rather than unchanged, and says what to put back.
3199        let after = match self.board_snapshot(&owned).await {
3200            Ok(after) => after,
3201            Err(error) => {
3202                return Err(SourceError::Refused {
3203                    message: format!(
3204                        "the guarded field setup changed the {} field and then could not read the \
3205                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3206                        landed.join(" and "),
3207                        recovery(&report, &before)?
3208                    ),
3209                });
3210            }
3211        };
3212        let mut moved = Vec::new();
3213        for field in &report.fields {
3214            let name = field.field.name();
3215            let now = after
3216                .fields
3217                .get(&field.field)
3218                .map(|held| held.options.as_slice())
3219                .unwrap_or_default();
3220            if !field.existing.iter().all(|old| now.contains(old)) {
3221                moved.push(format!(
3222                    "a pre-existing {name} option id, name, color or description"
3223                ));
3224            }
3225            if !field.missing.iter().all(|wanted| {
3226                now.iter()
3227                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3228            }) {
3229                moved.push(format!("an added {name} option"));
3230            }
3231            if after.assignments(field.field) != before.assignments(field.field) {
3232                moved.push(format!("an item's {name} value"));
3233            }
3234        }
3235        if !moved.is_empty() {
3236            return Err(SourceError::Refused {
3237                message: format!(
3238                    "GitHub changed {} after the guarded field setup; the pre-write item \
3239                     assignments are:\n{}",
3240                    moved.join(", "),
3241                    recovery(&report, &before)?
3242                ),
3243            });
3244        }
3245        Ok(report)
3246    }
3247
3248    /// Validate configuration and capture the named credential without exposing it.
3249    ///
3250    /// # Errors
3251    ///
3252    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3253    /// [`SourceError::Auth`] when the named credential is missing or empty.
3254    pub fn new(
3255        name: &SourceName,
3256        config: GitHubProjectsConfig,
3257        secrets: &dyn SecretResolver,
3258    ) -> Result<Self, SourceError> {
3259        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3260    }
3261
3262    /// The same, recording every request it sends into an accounting the caller holds too.
3263    ///
3264    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3265    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3266    /// up — passes the one it records those into, so the session total accounts for the
3267    /// whole session rather than for this source's share of it.
3268    ///
3269    /// # Errors
3270    ///
3271    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3272    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3273    pub fn recording_into(
3274        name: &SourceName,
3275        config: GitHubProjectsConfig,
3276        secrets: &dyn SecretResolver,
3277        ledger: Arc<Accounting>,
3278    ) -> Result<Self, SourceError> {
3279        if !valid_github_owner(&config.owner) {
3280            return Err(SourceError::Config {
3281                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3282            });
3283        }
3284        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3285            return Err(SourceError::Config {
3286                message: format!("project_number must be between 1 and {}", i32::MAX),
3287            });
3288        }
3289        if !valid_environment_name(&config.token_env) {
3290            return Err(SourceError::Config {
3291                message: "token_env must be a valid environment-variable name".into(),
3292            });
3293        }
3294        let repository = config
3295            .repository
3296            .as_deref()
3297            .map(RepositoryTarget::parse)
3298            .transpose()?;
3299        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3300            message: format!("endpoint is not a valid URL: {e}"),
3301        })?;
3302        if endpoint.scheme() != "https"
3303            && !(endpoint.scheme() == "http"
3304                && endpoint
3305                    .host_str()
3306                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3307        {
3308            return Err(SourceError::Config {
3309                message:
3310                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3311                        .into(),
3312            });
3313        }
3314        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3315            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),
3316        })?;
3317        Ok(Self {
3318            name: name.clone(),
3319            owner: config.owner,
3320            project_number: config.project_number,
3321            repository,
3322            endpoint,
3323            token,
3324            credential_name: config.token_env,
3325            statuses: StatusMapping::resolve(config.status_mapping, name)?,
3326            priorities: config
3327                .priority_mapping
3328                .map(|mapping| PriorityMapping::resolve(mapping, name))
3329                .transpose()?,
3330            client: Client::builder()
3331                .user_agent("onetaskgraph")
3332                .build()
3333                .map_err(|e| SourceError::Config {
3334                    message: format!("cannot build HTTP client: {e}"),
3335                })?,
3336            created: Mutex::new(Vec::new()),
3337            updated: Mutex::new(Vec::new()),
3338            pacing: Pacing::resolve(config.pacing, name)?,
3339            last_mutation: Mutex::new(None),
3340            board_cache: Mutex::new(None),
3341            search_cache: Mutex::new(None),
3342            narrowed_cache: Mutex::new(BTreeMap::new()),
3343            resolved_cache: Mutex::new(BTreeMap::new()),
3344            search_next: Mutex::new(BTreeMap::new()),
3345            fields_cache: Mutex::new(None),
3346            repository_cache: Mutex::new(BTreeMap::new()),
3347            ledger,
3348        })
3349    }
3350
3351    /// A snapshot of every request this source has sent, and what each cost.
3352    ///
3353    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3354    /// of them can sit side by side. When this source was built with
3355    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3356    /// point of building it that way.
3357    #[must_use]
3358    pub fn accounting(&self) -> accounting::Session {
3359        self.ledger.snapshot()
3360    }
3361
3362    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3363    /// rate limit rather than handing it straight back as an error.
3364    ///
3365    /// Retrying is safe for every document here, including the mutations, and the reason
3366    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3367    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3368    /// this replays has already taken effect. An outcome this source cannot know — the
3369    /// send failed, or the body could not be read, so the mutation may well have landed —
3370    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3371    /// attempt. A duplicate write would come from replaying one of those, and none is
3372    /// replayed.
3373    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3374        if is_mutation(query)
3375            && ![
3376                graphql::ADD_COMMENT,
3377                graphql::UPDATE_COMMENT,
3378                graphql::DELETE_COMMENT,
3379            ]
3380            .contains(&query)
3381        {
3382            let mut cache = self.resolved_cache()?;
3383            for argument in ["input", "second", "third", "clear"] {
3384                if let Some(input) = variables.get(argument) {
3385                    cache.retain(|id, item| {
3386                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3387                            input
3388                                .get(key)
3389                                .and_then(Value::as_str)
3390                                .is_some_and(|value| value == id.0 || value == item.item_id)
3391                        })
3392                    });
3393                }
3394            }
3395        }
3396        let doing = operation_description(query);
3397        let mut waited = Duration::ZERO;
3398        let mut waits = 0_u32;
3399        let mut backoff = self.pacing.retry_backoff;
3400        loop {
3401            if is_mutation(query) {
3402                let spacing = self.reserve_mutation_slot();
3403                if !spacing.is_zero() {
3404                    tokio::time::sleep(spacing).await;
3405                }
3406            }
3407            let attempt = self.send_once(query, &variables).await;
3408            if is_mutation(query) {
3409                self.finish_mutation();
3410            }
3411            let limited = match attempt {
3412                Ok(data) => return Ok(data),
3413                Err(Attempt::Failed(error)) => return Err(error),
3414                Err(Attempt::Limited(limited)) => limited,
3415            };
3416            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3417            // move that extends a secondary limit, so a hint below the schedule's own next
3418            // wait is raised to it.
3419            let wait = match limited.hint {
3420                Some(hint) => Duration::from_secs(hint).max(backoff),
3421                None => backoff,
3422            };
3423            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3424            // A wait of nothing spends none of the budget, so it is exhaustion rather
3425            // than a retry. `Pacing::resolve` rules out every way of configuring one
3426            // except a budget of zero, where reporting the first refusal is the ask.
3427            if wait.is_zero() || wait > remaining {
3428                return Err(limited.exhausted(
3429                    doing,
3430                    waits,
3431                    waited,
3432                    wait,
3433                    self.pacing.retry_budget,
3434                ));
3435            }
3436            tokio::time::sleep(wait).await;
3437            waited += wait;
3438            waits += 1;
3439            backoff = backoff.saturating_mul(2);
3440        }
3441    }
3442
3443    /// The next moment a content-creating mutation may leave this source, as a wait from
3444    /// now.
3445    ///
3446    /// The slot is reserved under the lock and the waiting happens outside it, so two
3447    /// callers take two slots rather than the same one — and no lock is held across an
3448    /// await.
3449    ///
3450    /// The moment it is spaced from is the previous mutation's *completion*, which
3451    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3452    /// own is the wrong thing to measure from.
3453    fn reserve_mutation_slot(&self) -> Duration {
3454        if self.pacing.min_mutation_interval.is_zero() {
3455            return Duration::ZERO;
3456        }
3457        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3458        // it would turn an earlier failure into a second one for no gain.
3459        let mut last = self
3460            .last_mutation
3461            .lock()
3462            .unwrap_or_else(std::sync::PoisonError::into_inner);
3463        let now = Instant::now();
3464        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3465        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3466        let at = last.map_or(now, |previous| {
3467            previous
3468                .checked_add(self.pacing.min_mutation_interval)
3469                .map_or(now, |earliest| earliest.max(now))
3470        });
3471        *last = Some(at);
3472        at.saturating_duration_since(now)
3473    }
3474
3475    /// Record that a content-creating mutation has finished, so the next one is spaced
3476    /// from here rather than from the moment this one was released.
3477    ///
3478    /// This source can only choose when a request *departs*; the limiter counts when it
3479    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3480    /// departure from the last therefore hands the limiter a gap of the interval less that
3481    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3482    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3483    /// slower machine while passing on a quick one.
3484    ///
3485    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3486    /// previous request had already arrived before its response came back, so its arrival
3487    /// is no later than this moment, and the next mutation is released at least the
3488    /// interval after this moment and arrives no earlier than it is released: the gap the
3489    /// limiter measures is therefore at least the interval, whatever transit costs and on
3490    /// whatever platform. The price is that a mutation's own round trip no longer counts
3491    /// towards its spacing, which makes this source slightly slower than the configured
3492    /// rate rather than slightly faster — the safe side of a limit that punishes being
3493    /// wrong by refusing reads for the next fifty minutes.
3494    ///
3495    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3496    /// and one that never left costs only a wait nobody needed.
3497    fn finish_mutation(&self) {
3498        if self.pacing.min_mutation_interval.is_zero() {
3499            return;
3500        }
3501        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3502        let mut last = self
3503            .last_mutation
3504            .lock()
3505            .unwrap_or_else(std::sync::PoisonError::into_inner);
3506        let now = Instant::now();
3507        // `max` rather than an assignment: a concurrent caller may already have reserved a
3508        // slot further out, and completing this request must never pull that slot back in.
3509        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3510    }
3511
3512    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3513    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3514    ///
3515    /// This is the one place a request leaves this crate, which is why the accounting is
3516    /// here rather than at each of the callers: a read path added later is counted without
3517    /// anybody remembering to count it, and
3518    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3519    /// when one is not.
3520    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3521        let Attempted {
3522            result,
3523            limits,
3524            reported_cost,
3525        } = self.attempt(query, variables).await;
3526        // No `otherwise` name: every document this source sends is one of its own, and the
3527        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3528        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3529        let outcome = match &result {
3530            Ok(_) => accounting::Outcome::Answered,
3531            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3532            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3533        };
3534        self.ledger.record(sending.finished(outcome, limits));
3535        result
3536    }
3537
3538    /// The attempt itself, with what its response said about the rate limit alongside.
3539    ///
3540    /// The two are returned together rather than recorded here because every one of the
3541    /// early exits below is a different outcome, and a record written at each of them is a
3542    /// record one of them can be added without.
3543    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3544        let mut limits = accounting::RateLimit::default();
3545        let mut reported_cost = None;
3546        let result = self
3547            .attempted(query, variables, &mut limits, &mut reported_cost)
3548            .await;
3549        Attempted {
3550            result,
3551            limits,
3552            reported_cost,
3553        }
3554    }
3555
3556    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3557    async fn attempted(
3558        &self,
3559        query: &str,
3560        variables: &Value,
3561        limits: &mut accounting::RateLimit,
3562        reported_cost: &mut Option<u64>,
3563    ) -> Result<Value, Attempt> {
3564        let response = self
3565            .client
3566            .post(self.endpoint.clone())
3567            .bearer_auth(self.token.expose_secret())
3568            .json(&json!({"query": query, "variables": variables}))
3569            .send()
3570            .await
3571            .map_err(|e| {
3572                Attempt::Failed(SourceError::Unavailable {
3573                    message: format!("GitHub GraphQL request failed: {e}"),
3574                })
3575            })?;
3576        let status = response.status();
3577        let header = |name: &str| whole_seconds(response.headers().get(name));
3578        *limits = accounting::RateLimit::read(|name| {
3579            response
3580                .headers()
3581                .get(name)
3582                .and_then(|value| value.to_str().ok())
3583                .map(str::to_owned)
3584        });
3585        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3586        // that are not text at all — is "not known to be exhausted". This never makes a
3587        // response a refusal on its own: it says which limiter a refusal is attributed to
3588        // and where its hint comes from, so a value this cannot read costs a hint rather
3589        // than an answer.
3590        let exhausted = response
3591            .headers()
3592            .get("x-ratelimit-remaining")
3593            .and_then(|value| value.to_str().ok())
3594            == Some("0");
3595        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3596        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3597        // which is the same question answered as an absolute time. Nothing else here is a
3598        // hint, and a schedule is what answers a refusal that carries none.
3599        let hint = header("retry-after").or_else(|| {
3600            exhausted
3601                .then(|| header("x-ratelimit-reset"))
3602                .flatten()
3603                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3604        });
3605        // Read before it is parsed, because the evidence which tells a secondary rate
3606        // limit from a rejected credential is in the body of a response whose status says
3607        // only "forbidden" — and a non-success response was never parsed at all.
3608        let body = response.text().await.map_err(|e| {
3609            Attempt::Failed(SourceError::Unavailable {
3610                message: format!("GitHub GraphQL response could not be read: {e}"),
3611            })
3612        })?;
3613        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3614            return Err(Attempt::Limited(Limited { limiter, hint }));
3615        }
3616        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3617            return Err(Attempt::Failed(SourceError::Auth {
3618                message: format!(
3619                    "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"
3620                ),
3621            }));
3622        }
3623        if !status.is_success() {
3624            return Err(Attempt::Failed(SourceError::Unavailable {
3625                message: format!("GitHub GraphQL returned HTTP {status}"),
3626            }));
3627        }
3628        // GitHub reports what a call cost only when the document asked it to, and no
3629        // document this source sends does — so this is `None` here and carries the figure
3630        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3631        // pick up is a `dryRun` probe's cost, which is some other document's.
3632        *reported_cost = serde_json::from_str::<Value>(&body)
3633            .ok()
3634            .as_ref()
3635            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3636            .and_then(Value::as_u64);
3637        self.answer(&body).map_err(Attempt::Failed)
3638    }
3639
3640    /// What one successful HTTP response says, once its GraphQL errors are read.
3641    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3642        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3643            message: format!("GitHub returned invalid JSON: {e}"),
3644        })?;
3645        let errors = body
3646            .get("errors")
3647            .map(|value| {
3648                value.as_array().ok_or_else(|| SourceError::Malformed {
3649                    message: "GitHub response errors is not an array".into(),
3650                })
3651            })
3652            .transpose()?;
3653        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3654            let messages = errors
3655                .iter()
3656                .filter_map(|e| e.get("message").and_then(Value::as_str))
3657                .collect::<Vec<_>>()
3658                .join("; ");
3659            let message = if messages.is_empty() {
3660                "GitHub returned GraphQL errors".into()
3661            } else {
3662                messages
3663            };
3664            let normalized = message.to_ascii_lowercase();
3665            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3666                return Err(SourceError::Auth {
3667                    message: format!(
3668                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3669                        self.credential_name
3670                    ),
3671                });
3672            }
3673            return Err(SourceError::Refused { message });
3674        }
3675        body.get("data")
3676            .filter(|data| data.is_object())
3677            .cloned()
3678            .ok_or_else(|| SourceError::Malformed {
3679                message: "GitHub response has no data object".into(),
3680            })
3681    }
3682
3683    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3684    // GraphQL cannot independently page them inside the outer item page. This source page is
3685    // deliberately bounded at that published maximum; the live drift journey exercises it.
3686    async fn board_page(
3687        &self,
3688        items_after: Option<&str>,
3689        items_first: u32,
3690    ) -> Result<Value, SourceError> {
3691        let data = self
3692            .graphql(
3693                graphql::BOARD,
3694                json!({"owner":self.owner,"number":self.project_number,
3695                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3696                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3697            )
3698            .await?;
3699        data.pointer("/owner/projectV2")
3700            .filter(|v| !v.is_null())
3701            .cloned()
3702            .ok_or_else(|| SourceError::Refused {
3703                message: format!(
3704                    "GitHub project {}/{} was not found or is not visible to the token",
3705                    self.owner, self.project_number
3706                ),
3707            })
3708    }
3709
3710    /// The search that finds the issues of this board, narrowed by `also` when it is
3711    /// given.
3712    ///
3713    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3714    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3715    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3716    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3717    /// from a task by the `parent` field each issue carries rather than by the search.
3718    fn board_search(&self, also: Option<&str>) -> String {
3719        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3720        match also {
3721            Some(also) => format!("{scope} {also}"),
3722            None => scope,
3723        }
3724    }
3725
3726    /// One issue this source reached directly, as the board item a read of the board would
3727    /// have produced — or `None` when this board does not hold it.
3728    ///
3729    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3730    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3731    /// item's own id, that item's field values, and the issue as its content. One resolver
3732    /// for both routes is what makes an issue read through a search, through its own node
3733    /// id, or through its project's sub-issues report the same title, the same status, the
3734    /// same labels and the same qualified id.
3735    ///
3736    /// An issue with no entry for *this* board is not this source's to report, which is
3737    /// what keeps an id naming some other repository's issue from being answered as an item
3738    /// of this board. That answer is given about an **exhausted** connection and never
3739    /// about an unread page: the entry is looked for on the page in hand, and only if that
3740    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3741    /// rest of it.
3742    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3743        if optional_str(issue, "__typename")? != Some("Issue") {
3744            return Ok(None);
3745        }
3746        let memberships = issue
3747            .get("projectItems")
3748            .ok_or_else(|| SourceError::Malformed {
3749                message: "GitHub issue is missing projectItems".into(),
3750            })?;
3751        let nodes = memberships
3752            .get("nodes")
3753            .and_then(Value::as_array)
3754            .ok_or_else(|| SourceError::Malformed {
3755                message: "GitHub issue projectItems.nodes is not an array".into(),
3756            })?;
3757        let held = match self.board_entry(nodes) {
3758            Some(held) => held.clone(),
3759            None => {
3760                let info = memberships
3761                    .get("pageInfo")
3762                    .ok_or_else(|| SourceError::Malformed {
3763                        message: "GitHub issue projectItems has no pageInfo".into(),
3764                    })?;
3765                // The page held no entry for this board. Whether that means the issue is
3766                // not on it is a question about the rest of the connection, and only a
3767                // connection with no rest answers it here.
3768                if !required_bool(info, "hasNextPage")? {
3769                    return Ok(None);
3770                }
3771                let cursor = required_str(info, "endCursor")?;
3772                validate_cursor_progress(None, cursor)?;
3773                let issue_id = required_str(issue, "id")?;
3774                match self.board_membership(issue_id, cursor).await? {
3775                    Some(held) => held,
3776                    None => return Ok(None),
3777                }
3778            }
3779        };
3780        let item = json!({
3781            "id": required_str(&held, "id")?,
3782            "project": held.get("project"),
3783            "fieldValues": held.get("fieldValues"),
3784            "content": issue,
3785        });
3786        self.resolve(&item)
3787    }
3788
3789    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3790    ///
3791    /// One spelling of *which membership is this board's*, so the page a read carries and
3792    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3793    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3794        nodes.iter().find(|node| {
3795            node.pointer("/project/number").and_then(Value::as_u64)
3796                == Some(u64::from(self.project_number))
3797        })
3798    }
3799
3800    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3801    ///
3802    /// The recovery read: a page of memberships that holds no entry for this board says
3803    /// nothing about the memberships past it, so the connection is walked to exhaustion
3804    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3805    /// positive answer — the whole connection was read and no entry named this board —
3806    /// rather than a failure, and the walk is held to
3807    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3808    /// with a cursor that does not advance is refused instead of spun on.
3809    async fn board_membership(
3810        &self,
3811        issue: &str,
3812        after: &str,
3813    ) -> Result<Option<Value>, SourceError> {
3814        let mut after = after.to_owned();
3815        loop {
3816            let data = self
3817                .graphql(
3818                    graphql::ISSUE_BOARD_ITEMS,
3819                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3820                           "nestedFirst":NESTED_PAGE_SIZE}),
3821                )
3822                .await?;
3823            let Some(connection) = data
3824                .pointer("/node/projectItems")
3825                .filter(|value| !value.is_null())
3826            else {
3827                // The id resolved to nothing, or to something with no memberships to walk —
3828                // which is the same answer as a connection holding no entry for this board.
3829                return Ok(None);
3830            };
3831            let nodes = connection
3832                .get("nodes")
3833                .and_then(Value::as_array)
3834                .ok_or_else(|| SourceError::Malformed {
3835                    message: "GitHub issue projectItems.nodes is not an array".into(),
3836                })?;
3837            if let Some(held) = self.board_entry(nodes) {
3838                return Ok(Some(held.clone()));
3839            }
3840            let info = connection
3841                .get("pageInfo")
3842                .ok_or_else(|| SourceError::Malformed {
3843                    message: "GitHub issue projectItems has no pageInfo".into(),
3844                })?;
3845            let next = required_bool(info, "hasNextPage")?
3846                .then(|| required_str(info, "endCursor"))
3847                .transpose()?;
3848            match next {
3849                Some(next) => {
3850                    validate_cursor_progress(Some(&after), next)?;
3851                    after = next.to_owned();
3852                }
3853                None => return Ok(None),
3854            }
3855        }
3856    }
3857
3858    /// One page of a board-scoped issue search, and where the next page resumes.
3859    async fn search_page(
3860        &self,
3861        search: &str,
3862        first: u32,
3863        after: Option<&str>,
3864    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3865        let data = self
3866            .graphql(
3867                graphql::SEARCH_ISSUES,
3868                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3869                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3870                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3871            )
3872            .await?;
3873        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3874            message: "GitHub search response has no search connection".into(),
3875        })?;
3876        let mut found = Vec::new();
3877        for node in connection
3878            .get("nodes")
3879            .and_then(Value::as_array)
3880            .ok_or_else(|| SourceError::Malformed {
3881                message: "GitHub search nodes is not an array".into(),
3882            })?
3883        {
3884            if let Some(resolved) = self.resolve_issue(node).await? {
3885                found.push(resolved);
3886            }
3887        }
3888        let info = connection
3889            .get("pageInfo")
3890            .ok_or_else(|| SourceError::Malformed {
3891                message: "GitHub search connection has no pageInfo".into(),
3892            })?;
3893        let next = required_bool(info, "hasNextPage")?
3894            .then(|| required_str(info, "endCursor"))
3895            .transpose()?
3896            .map(str::to_owned);
3897        if let Some(next) = &next {
3898            validate_cursor_progress(after, next)?;
3899        }
3900        Ok((found, next))
3901    }
3902
3903    /// Every issue this board holds, completed with what this run wrote.
3904    ///
3905    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3906    /// is an index and is eventually consistent, so an issue this run created seconds ago
3907    /// can be absent from it, and a project listed straight after being written would
3908    /// otherwise be missing from its own board. What is added back is only what this
3909    /// process itself wrote, out of [`Self::created`], which lives and dies with the
3910    /// process.
3911    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3912        let found = self.searched_issues().await?;
3913        self.completed_with_written(found, |_| true)
3914    }
3915
3916    /// Every issue this board's own search reports, walked to exhaustion, read once per
3917    /// source.
3918    ///
3919    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3920    /// needs it too and the two would otherwise walk the same search twice in one command.
3921    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3922    /// is.
3923    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3924        let cached = self.search_cache()?.clone();
3925        if let Some(held) = cached {
3926            return Ok(held);
3927        }
3928        let mut after: Option<String> = None;
3929        let mut found = Vec::new();
3930        let search = self.board_search(None);
3931        loop {
3932            let (page, next) = self
3933                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3934                .await?;
3935            found.extend(page);
3936            match next {
3937                Some(next) => after = Some(next),
3938                None => break,
3939            }
3940        }
3941        *self.search_cache()? = Some(found.clone());
3942        Ok(found)
3943    }
3944
3945    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3946    fn search_cache(
3947        &self,
3948    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3949        self.search_cache
3950            .lock()
3951            .map_err(|_| SourceError::Unavailable {
3952                message: "this source's view of the board's issues was left inconsistent by an \
3953                      earlier failure; next: run the command again"
3954                    .into(),
3955            })
3956    }
3957
3958    /// `found`, with everything this run wrote that `keep` accepts and the read did not
3959    /// report.
3960    ///
3961    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3962    /// at all: the search index is behind, and a node read of an item filed moments ago can
3963    /// be too.
3964    fn completed_with_written(
3965        &self,
3966        mut found: Vec<Resolved>,
3967        keep: impl Fn(&Resolved) -> bool,
3968    ) -> Result<Vec<Resolved>, SourceError> {
3969        for own in self.created()?.iter().filter(|own| keep(own)) {
3970            if !found.iter().any(|item| item.id == own.id) {
3971                found.push(own.clone());
3972            }
3973        }
3974        Ok(found)
3975    }
3976
3977    /// What resolving one node id reached.
3978    ///
3979    /// Three answers rather than an `Option`, because a board *draft* is none of the other
3980    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3981    /// is completed by a read of the draft itself rather than reported as nothing.
3982    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3983        let asked = self
3984            .graphql(
3985                graphql::ISSUE,
3986                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
3987                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3988            )
3989            .await;
3990        let data = match asked {
3991            Ok(data) => data,
3992            // A string that is not a node id at all is not a failure to report: it is an id
3993            // this board does not hold, which is what every read of one already answers.
3994            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3995            Err(error) => return Err(error),
3996        };
3997        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3998            return Ok(Reached::Nothing);
3999        };
4000        if optional_str(node, "__typename")? == Some("DraftIssue") {
4001            return Ok(Reached::Draft);
4002        }
4003        Ok(match self.resolve_issue(node).await? {
4004            Some(item) => Reached::Held(Box::new(item)),
4005            None => Reached::Nothing,
4006        })
4007    }
4008
4009    /// One item of this board by its own id, whatever kind it is.
4010    ///
4011    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4012    /// run wrote is read first, because a node read of an item created moments ago can
4013    /// still be behind the board field values written onto it — see [`Self::created`].
4014    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4015        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4016            return Ok(Some(own.clone()));
4017        }
4018        match self.reach(id).await? {
4019            Reached::Held(item) => Ok(Some(*item)),
4020            Reached::Nothing => Ok(None),
4021            Reached::Draft => self.draft_by_id(id).await,
4022        }
4023    }
4024
4025    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4026    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4027    /// than one request per id.
4028    ///
4029    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4030    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4031    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4032    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4033    /// is completed by a read of the draft, exactly as there.
4034    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4035        let mut found: Vec<Option<Option<Resolved>>> = {
4036            let created = self.created()?;
4037            ids.iter()
4038                .map(|id| {
4039                    created
4040                        .iter()
4041                        .find(|own| own.id == *id)
4042                        .map(|own| Some(own.clone()))
4043                })
4044                .collect()
4045        };
4046        let unread: Vec<NativeId> = ids
4047            .iter()
4048            .zip(&found)
4049            .filter(|(_, found)| found.is_none())
4050            .map(|(id, _)| id.clone())
4051            .collect();
4052        let mut read = Vec::with_capacity(unread.len());
4053        if let [one] = unread.as_slice() {
4054            read.push(self.item_by_id(one).await?);
4055        } else {
4056            for batch in unread.chunks(DETAIL_BATCH) {
4057                let data = match self
4058                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4059                    .await
4060                {
4061                    Ok(data) => data,
4062                    Err(error) if unresolvable_node(&error) => {
4063                        for id in batch {
4064                            read.push(self.item_by_id(id).await?);
4065                        }
4066                        continue;
4067                    }
4068                    Err(error) => return Err(error),
4069                };
4070                for (slot, id) in batch.iter().enumerate() {
4071                    let node =
4072                        data.get(format!("i{slot}"))
4073                            .ok_or_else(|| SourceError::Malformed {
4074                                message: format!(
4075                                    "GitHub answered a batch read with no item for {}",
4076                                    id.0
4077                                ),
4078                            })?;
4079                    read.push(if node.is_null() {
4080                        None
4081                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4082                        self.draft_by_id(id).await?
4083                    } else {
4084                        if optional_str(node, "__typename")? == Some("Issue")
4085                            && required_str(node, "id")? != id.0
4086                        {
4087                            return Err(SourceError::Malformed {
4088                                message: format!(
4089                                    "GitHub answered the read of {} with issue {}",
4090                                    id.0,
4091                                    required_str(node, "id")?
4092                                ),
4093                            });
4094                        }
4095                        self.resolve_issue(node).await?
4096                    });
4097                }
4098            }
4099        }
4100        let mut read = read.into_iter();
4101        Ok(found
4102            .iter_mut()
4103            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4104            .collect())
4105    }
4106
4107    fn resolved_cache(
4108        &self,
4109    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4110        self.resolved_cache
4111            .lock()
4112            .map_err(|_| SourceError::Unavailable {
4113                message: "resolved item records were left inconsistent; run the command again"
4114                    .into(),
4115            })
4116    }
4117
4118    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4119    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4120    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4121        let cached = self.resolved_cache()?.get(id).cloned();
4122        match cached {
4123            Some(item) => Ok(Some(item)),
4124            None => self.item_by_id(id).await,
4125        }
4126    }
4127
4128    /// One board draft by its own id, with the board item it sits in — or `None` when no
4129    /// item of this board is that draft's.
4130    ///
4131    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4132    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4133    /// links a draft to one board item, so the page this read carries is the whole of that
4134    /// connection, and a page that reports more than it holds is refused rather than read
4135    /// as an answer about memberships nobody read.
4136    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4137        let data = self
4138            .graphql(
4139                graphql::DRAFT,
4140                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4141                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4142            )
4143            .await?;
4144        // Gone between the two reads is an answer — the draft is no longer there. Anything
4145        // else than the draft [`Self::reach`] was just told this id is, is not one.
4146        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4147            return Ok(None);
4148        };
4149        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4150            return Err(SourceError::Malformed {
4151                message: format!(
4152                    "GitHub answered {} as a draft and then as something else",
4153                    id.0
4154                ),
4155            });
4156        }
4157        if required_str(draft, "id")? != id.0 {
4158            return Err(SourceError::Malformed {
4159                message: format!("GitHub answered a different draft for {}", id.0),
4160            });
4161        }
4162        let memberships = draft
4163            .get("projectV2Items")
4164            .ok_or_else(|| SourceError::Malformed {
4165                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4166            })?;
4167        let nodes = memberships
4168            .get("nodes")
4169            .and_then(Value::as_array)
4170            .ok_or_else(|| SourceError::Malformed {
4171                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4172            })?;
4173        let info = memberships
4174            .get("pageInfo")
4175            .ok_or_else(|| SourceError::Malformed {
4176                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4177            })?;
4178        // Read whether or not this board's entry is on the page: a page claiming more than
4179        // the one item GitHub links a draft to is a malformed answer either way.
4180        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4181            return Err(SourceError::Malformed {
4182                message: format!(
4183                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4184                     to",
4185                    id.0
4186                ),
4187            });
4188        }
4189        if let Some(node) = nodes.first()
4190            && node
4191                .pointer("/project/number")
4192                .and_then(Value::as_u64)
4193                .is_none()
4194        {
4195            return Err(SourceError::Malformed {
4196                message: format!(
4197                    "GitHub draft {} board item has no numeric project number",
4198                    id.0
4199                ),
4200            });
4201        }
4202        let Some(held) = self.board_entry(nodes) else {
4203            return Ok(None);
4204        };
4205        if required_str(
4206            held.get("project").ok_or_else(|| SourceError::Malformed {
4207                message: format!("GitHub draft {} board item has no project", id.0),
4208            })?,
4209            "id",
4210        )? != self.board_fields().await?.id.as_str()
4211        {
4212            return Ok(None);
4213        }
4214        let item = json!({
4215            "id": required_str(held, "id")?,
4216            "project": held.get("project"),
4217            "fieldValues": held.get("fieldValues"),
4218            "content": draft,
4219        });
4220        self.resolve(&item)
4221    }
4222
4223    /// The board's own id and field definitions, for a write whose item does not carry
4224    /// them — never its items.
4225    ///
4226    /// A board this command has already listed supplies them, since it read them beside its
4227    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4228    /// is consulted about which items the board holds: see the module documentation for
4229    /// why a question about one known item is answered by reading that item.
4230    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4231        if let Some(board) = self.board_cache()?.as_ref() {
4232            return Ok(BoardFields {
4233                id: BoardId::parse(&board.id)?,
4234                fields: board.fields.clone(),
4235            });
4236        }
4237        if let Some(held) = self.fields_cache()?.clone() {
4238            return Ok(held);
4239        }
4240        let data = self
4241            .graphql(
4242                graphql::BOARD_FIELDS,
4243                json!({"owner":self.owner,"number":self.project_number,
4244                       "nestedFirst":NESTED_PAGE_SIZE}),
4245            )
4246            .await?;
4247        self.fields_read(&data)
4248    }
4249
4250    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4251    /// the rest of this command.
4252    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4253        let board = data
4254            .pointer("/boardFields/projectV2")
4255            .filter(|value| !value.is_null())
4256            .ok_or_else(|| SourceError::Refused {
4257                message: format!(
4258                    "GitHub project {}/{} was not found or is not visible to the token",
4259                    self.owner, self.project_number
4260                ),
4261            })?;
4262        let read = BoardFields {
4263            id: BoardId::parse(required_str(board, "id")?)?,
4264            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4265        };
4266        *self.fields_cache()? = Some(read.clone());
4267        Ok(read)
4268    }
4269
4270    /// Read what creating an issue in `repository` needs and this command has not read yet —
4271    /// the board's fields and the repository's node id — in one request when it needs both.
4272    ///
4273    /// When either is already known this sends nothing, and the other is read by its own
4274    /// document where it is asked for, so no create reads anything twice.
4275    async fn creation_context(
4276        &self,
4277        repository: &RepositoryTarget,
4278        incoming: &Incoming<'_>,
4279    ) -> Result<(), SourceError> {
4280        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4281        if fields_known || self.repository_cache()?.contains_key(repository) {
4282            return Ok(());
4283        }
4284        let data = self
4285            .graphql(
4286                graphql::CREATION_CONTEXT,
4287                json!({"owner":self.owner,"number":self.project_number,
4288                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4289                       "repositoryName":repository.name}),
4290            )
4291            .await?;
4292        self.fields_read(&data)?;
4293        self.repository_read(&data, repository, incoming)?;
4294        Ok(())
4295    }
4296
4297    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4298    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4299        self.fields_cache
4300            .lock()
4301            .map_err(|_| SourceError::Unavailable {
4302                message: "this source's view of the board's fields was left inconsistent by an \
4303                      earlier failure; next: run the command again"
4304                    .into(),
4305            })
4306    }
4307
4308    /// What a write to `item` needs of the board, read off that item when it says enough and
4309    /// off [`Self::board_fields`] when it does not.
4310    ///
4311    /// A node read of an item names its board and carries the definition of every field it
4312    /// holds a value of — so an item naming its board, holding a value of the origin field,
4313    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4314    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4315    /// of may still be on the board, and a view reading it as absent would refuse a write the
4316    /// board can take or skip a field write the board needs, so such an item — and a create,
4317    /// which has no item yet — takes the board's fields from their own read instead.
4318    async fn fields_for(
4319        &self,
4320        item: Option<&Resolved>,
4321        writes_status: bool,
4322        selects_priority: bool,
4323    ) -> Result<BoardFields, SourceError> {
4324        if let Some(board) = item.and_then(Resolved::carried_board) {
4325            return Ok(board);
4326        }
4327        if let Some(item) = item
4328            && let Some(board_id) = item.named_board()
4329            && item.defines(ORIGIN_FIELD)
4330            && (!writes_status || item.defines("Status"))
4331            && (!selects_priority || item.defines(PRIORITY_FIELD))
4332        {
4333            return Ok(BoardFields {
4334                id: board_id,
4335                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4336            });
4337        }
4338        self.board_fields().await
4339    }
4340
4341    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4342    /// when that id names nothing here with a sub-issue relationship to walk.
4343    ///
4344    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4345    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4346    /// empty vector is a project that holds nothing.
4347    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4348        let mut after: Option<String> = None;
4349        let mut children = Vec::new();
4350        loop {
4351            let asked = self
4352                .graphql(
4353                    graphql::SUB_ISSUES,
4354                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4355                           "nestedFirst":NESTED_PAGE_SIZE,
4356                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4357                )
4358                .await;
4359            let data = match asked {
4360                Ok(data) => data,
4361                // A string that is not a node id at all is not a failure to report: it is
4362                // the ordinary answer to a selector naming a project by its name.
4363                Err(error) if unresolvable_node(&error) => return Ok(None),
4364                Err(error) => return Err(error),
4365            };
4366            let Some(connection) = data
4367                .pointer("/node/subIssues")
4368                .filter(|value| !value.is_null())
4369            else {
4370                // No such node, or one with no sub-issue relationship — a board draft is
4371                // the one this board can really hold.
4372                return Ok(None);
4373            };
4374            for node in connection
4375                .get("nodes")
4376                .and_then(Value::as_array)
4377                .ok_or_else(|| SourceError::Malformed {
4378                    message: "GitHub subIssues.nodes is not an array".into(),
4379                })?
4380            {
4381                if let Some(resolved) = self.resolve_issue(node).await? {
4382                    children.push(resolved);
4383                }
4384            }
4385            let info = connection
4386                .get("pageInfo")
4387                .ok_or_else(|| SourceError::Malformed {
4388                    message: "GitHub subIssues connection has no pageInfo".into(),
4389                })?;
4390            let next = required_bool(info, "hasNextPage")?
4391                .then(|| required_str(info, "endCursor"))
4392                .transpose()?;
4393            match next {
4394                Some(next) => {
4395                    validate_cursor_progress(after.as_deref(), next)?;
4396                    after = Some(next.to_owned());
4397                }
4398                None => return Ok(Some(children)),
4399            }
4400        }
4401    }
4402
4403    /// Which issue of this board a project *name* is, or `None` when none is.
4404    ///
4405    /// One bounded query which filters on that name at the server, rather than a walk of
4406    /// every issue the board holds. The name is compared again here: the qualifier narrows
4407    /// what GitHub sends, and this source decides what it names.
4408    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4409        let search = self.board_search(Some(&title_qualifier(name)));
4410        let mut after = None;
4411        loop {
4412            let (candidates, next) = self
4413                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4414                .await?;
4415            if let Some(item) = candidates.into_iter().find(|item| {
4416                item.kind == BoardKind::Work(ItemKind::Project)
4417                    && item.title.eq_ignore_ascii_case(name)
4418            }) {
4419                return Ok(Some(item.id));
4420            }
4421            match next {
4422                Some(next) => after = Some(next),
4423                None => return Ok(None),
4424            }
4425        }
4426    }
4427
4428    /// Everything filed under one project of this board: the sub-issues of the issue that
4429    /// project is.
4430    ///
4431    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4432    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4433    /// gains projects, or as another project gains tasks.
4434    ///
4435    /// A qualified id names the issue and is asked for its sub-issues directly: one
4436    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4437    /// read as a project *name*, which costs the one bounded search
4438    /// [`Self::project_by_name`] makes.
4439    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4440        let (project, children) = match self.sub_issues(selector).await? {
4441            Some(children) => (selector.clone(), children),
4442            None => match self.project_by_name(&selector.0).await? {
4443                Some(project) => {
4444                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4445                    (project, children)
4446                }
4447                None => return Ok(Vec::new()),
4448            },
4449        };
4450        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4451    }
4452
4453    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4454    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4455    ///
4456    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4457    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4458    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4459    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4460    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4461    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4462    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4463    /// rather than silently narrowing a caller's answer.
4464    ///
4465    /// The instant is written to the second, rounded down, which can only widen what the
4466    /// search returns; confirmation against each candidate's own comments is what makes the
4467    /// answer exact. The search is an index that lags a write by a second or two — the module
4468    /// documentation records it — so a caller that asks again from its last instant should
4469    /// overlap the two by more than that.
4470    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4471        let found = self.searched(&updated_qualifier(since)).await?;
4472        self.completed_with_written(found, |_| true)
4473    }
4474
4475    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4476    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4477    ///
4478    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4479    /// own record is the fresher of the two.
4480    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4481        let search = self.board_search(Some(also));
4482        let mut after: Option<String> = None;
4483        let mut found = Vec::new();
4484        loop {
4485            let (page, next) = self
4486                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4487                .await?;
4488            found.extend(page);
4489            match next {
4490                Some(next) => after = Some(next),
4491                None => return Ok(found),
4492            }
4493        }
4494    }
4495
4496    /// A bounded task answer; the versioned cursor carries the connection position, how
4497    /// many rows of the page starting there were already handed out, and the own-write ids
4498    /// already observed, including across a new source instance.
4499    ///
4500    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4501    /// sliced from the pages it needs; why is the module documentation's paging contract.
4502    async fn search_tasks(
4503        &self,
4504        query: &TaskQuery,
4505        page: &PageRequest,
4506        also: &str,
4507    ) -> Result<Page<Task>, SourceError> {
4508        let mut position = match &page.cursor {
4509            None => SearchPosition::default(),
4510            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4511                .ok()
4512                .filter(|position| {
4513                    position.version == SEARCH_CURSOR_VERSION
4514                        && position.connection.valid_resume(position.offset)
4515                })
4516                .ok_or_else(|| SourceError::Config {
4517                    message: "page cursor is invalid".into(),
4518                })?,
4519        };
4520        let search = self.board_search(Some(also));
4521        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4522        let own = self.with_own_writes(Vec::new())?;
4523        for item in &own {
4524            if !position.own.contains(&item.id) {
4525                position.own.push(item.id.clone());
4526            }
4527        }
4528        let mut tasks = Vec::new();
4529        while !position.connection.exhausted() && tasks.len() < limit {
4530            let first = SEARCH_PAGE_SIZE;
4531            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4532            let key =
4533                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4534                    .expect("search page key is serializable");
4535            let cached = if query.commented_since.is_none() {
4536                self.narrowed_cache()?.get(&key).cloned()
4537            } else {
4538                None
4539            };
4540            let (found, next) = match cached {
4541                Some(found) => {
4542                    let next = self
4543                        .search_next
4544                        .lock()
4545                        .map_err(|_| SourceError::Unavailable {
4546                            message:
4547                                "search pagination was left inconsistent; run the command again"
4548                                    .into(),
4549                        })?
4550                        .get(&key)
4551                        .cloned()
4552                        .flatten();
4553                    (found, next)
4554                }
4555                None => {
4556                    let (found, next) = self
4557                        .search_page(&search, first, position.connection.after())
4558                        .await?;
4559                    if query.commented_since.is_none() {
4560                        self.search_next
4561                            .lock()
4562                            .map_err(|_| SourceError::Unavailable {
4563                                message:
4564                                    "search pagination was left inconsistent; run the command again"
4565                                        .into(),
4566                            })?
4567                            .insert(key.clone(), next.clone());
4568                        self.narrowed_cache()?.insert(key, found.clone());
4569                    }
4570                    (found, next)
4571                }
4572            };
4573            let rows = found.len();
4574            for mut item in found.into_iter().skip(position.offset) {
4575                if tasks.len() == limit {
4576                    break;
4577                }
4578                position.offset += 1;
4579                if position.own.contains(&item.id) {
4580                    if position.seen.contains(&item.id) {
4581                        continue;
4582                    }
4583                    position.seen.push(item.id.clone());
4584                    let updated_at = item.updated_at;
4585                    let Some(written) = self.search_written(&own, &item.id).await? else {
4586                        continue;
4587                    };
4588                    item = written;
4589                    item.updated_at = item.updated_at.max(updated_at);
4590                    self.resolved_cache()?.insert(item.id.clone(), item.clone());
4591                }
4592                if item.kind == BoardKind::Work(ItemKind::Task) {
4593                    let task = item.task()?;
4594                    if task_matches(&task, query, &query.project)
4595                        && self.commented_since(&item, query.commented_since).await?
4596                    {
4597                        tasks.push(task);
4598                    }
4599                }
4600            }
4601            if position.offset < rows {
4602                continue;
4603            }
4604            position.offset = 0;
4605            position.connection = match next {
4606                Some(after) => SearchConnection::Continuing {
4607                    after: Cursor(after),
4608                },
4609                None => SearchConnection::Exhausted {},
4610            };
4611        }
4612        if position.connection.exhausted() {
4613            for id in position.own.clone() {
4614                if position.seen.contains(&id) {
4615                    continue;
4616                }
4617                if tasks.len() == limit {
4618                    break;
4619                }
4620                position.seen.push(id.clone());
4621                let Some(item) = self.search_written(&own, &id).await? else {
4622                    continue;
4623                };
4624                if item.kind == BoardKind::Work(ItemKind::Task) {
4625                    let task = item.task()?;
4626                    if task_matches(&task, query, &query.project)
4627                        && self.commented_since(&item, query.commented_since).await?
4628                    {
4629                        tasks.push(task);
4630                    }
4631                }
4632            }
4633        }
4634        let more = !position.connection.exhausted()
4635            || position.own.iter().any(|id| !position.seen.contains(id));
4636        Ok(Page {
4637            items: tasks,
4638            next: more.then(|| {
4639                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4640            }),
4641        })
4642    }
4643
4644    /// A resumed process has the ids but no write records; resolve only a record the
4645    /// current page needs, by its uncached node read rather than the lagging search index.
4646    async fn search_written(
4647        &self,
4648        own: &[Resolved],
4649        id: &NativeId,
4650    ) -> Result<Option<Resolved>, SourceError> {
4651        match own.iter().find(|item| item.id == *id) {
4652            Some(item) => Ok(Some(item.clone())),
4653            None => self.item_by_id(id).await,
4654        }
4655    }
4656
4657    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4658    /// without enumerating the board — or `None` for a query carrying none of the three, which
4659    /// keeps the reads it always had.
4660    ///
4661    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4662    /// because it names at most a handful of items. Text and metadata are answered by one
4663    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4664    /// further by `updated:>=` when the query also asks for comment activity, since both
4665    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4666    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4667    ///
4668    /// Completed with what this process wrote, its own record winning over the index's copy
4669    /// of the same item: see [`Self::with_own_writes`].
4670    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4671        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4672            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4673            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4674                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4675                None => qualifiers,
4676            }),
4677            (None, None) => return Ok(None),
4678        };
4679        // A question about comment activity is asked afresh every time, as it always was: it
4680        // is the one a caller polls from one source while waiting for the index, and an
4681        // answer held from the first poll would be the answer to every later one.
4682        let key = query.commented_since.is_none().then(|| asked.key());
4683        let cached = match &key {
4684            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4685            None => None,
4686        };
4687        let found = match cached {
4688            Some(found) => found,
4689            None => {
4690                let found = match &asked {
4691                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4692                    Narrowing::Search(also) => self.searched(also).await?,
4693                };
4694                if let Some(key) = key {
4695                    self.narrowed_cache()?.insert(key, found.clone());
4696                }
4697                found
4698            }
4699        };
4700        self.with_own_writes(found).map(Some)
4701    }
4702
4703    /// Every item of this board that may carry `origin` — a superset of those that do — found
4704    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4705    ///
4706    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4707    /// which reads the field every carrier holds, whichever release wrote it — and the
4708    /// board-scoped issue search for the same id as a phrase in the body, where this source
4709    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4710    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4711    /// query's, exactly.
4712    ///
4713    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4714    /// already ended is sent its last cursor again, which answers an empty page, so the one
4715    /// document serves every page of either. What the two leave is stated in the module
4716    /// documentation: a carrier another process added within the last second or two, before
4717    /// either index has it.
4718    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4719        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4720        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4721        let mut items_after: Option<String> = None;
4722        let mut search_after: Option<String> = None;
4723        let mut found: Vec<Resolved> = Vec::new();
4724        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4725            if !found.iter().any(|held| held.id == resolved.id) {
4726                found.push(resolved);
4727            }
4728        };
4729        loop {
4730            let data = self
4731                .graphql(
4732                    graphql::ORIGIN_LOOKUP,
4733                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4734                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4735                           "itemsAfter":items_after,"searchAfter":search_after,
4736                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4737                           "duplicates":true}),
4738                )
4739                .await?;
4740            let items = data
4741                .pointer("/originItems/projectV2/items")
4742                .filter(|value| !value.is_null())
4743                .ok_or_else(|| SourceError::Refused {
4744                    message: format!(
4745                        "GitHub project {}/{} was not found or is not visible to the token",
4746                        self.owner, self.project_number
4747                    ),
4748                })?;
4749            for item in optional_nodes(Some(items), "project items")?
4750                .into_iter()
4751                .flatten()
4752            {
4753                // The board's own items list its drafts too, and a draft is not an issue: no
4754                // narrowed read answers with one, whatever its origin field holds.
4755                if let Some(resolved) = self.resolve(item)?
4756                    && resolved.content_kind == ContentKind::Issue
4757                {
4758                    keep(resolved, &mut found);
4759                }
4760            }
4761            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4762                message: "GitHub search response has no search connection".into(),
4763            })?;
4764            for node in optional_nodes(Some(searched), "search")?
4765                .into_iter()
4766                .flatten()
4767            {
4768                if let Some(resolved) = self.resolve_issue(node).await? {
4769                    keep(resolved, &mut found);
4770                }
4771            }
4772            let items_next = resumed(items, items_after.as_deref())?;
4773            let search_next = resumed(searched, search_after.as_deref())?;
4774            if !items_next.has_more() && !search_next.has_more() {
4775                return Ok(found);
4776            }
4777            items_after = items_next.cursor();
4778            search_after = search_next.cursor();
4779        }
4780    }
4781
4782    /// `found`, with every item this process created or wrote in its place, and every one of
4783    /// them the read did not report added.
4784    ///
4785    /// This process's own record wins over the read's copy of the same item, because a read
4786    /// of an item written moments ago can still be behind what was written onto it — the
4787    /// origin field included, which is the one a narrowed read is confirmed against — and a
4788    /// read that still names an item under a predicate this process's write moved it out of
4789    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4790    /// last saw the item change, which is what a comment-activity read rules a candidate out
4791    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4792    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4793    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4794        // A board draft is not an issue, so no narrowed read returns one, and this process
4795        // having written one does not make it an answer either.
4796        let own: Vec<Resolved> = self
4797            .created()?
4798            .iter()
4799            .chain(self.updated()?.iter())
4800            .filter(|own| own.content_kind == ContentKind::Issue)
4801            .cloned()
4802            .collect();
4803        for mut own in own {
4804            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4805            match found.iter_mut().find(|read| read.id == own.id) {
4806                Some(read) => {
4807                    own.updated_at = own.updated_at.max(read.updated_at);
4808                    *read = own;
4809                }
4810                None => found.push(own),
4811            }
4812        }
4813        Ok(found)
4814    }
4815
4816    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4817    /// there is no instant to hold it to.
4818    ///
4819    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4820    /// or after the instant moved it there: an issue not updated since holds no such comment,
4821    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4822    /// only as far as the first that matches. A board draft is not an issue and has no
4823    /// comments, so it never matches.
4824    async fn commented_since(
4825        &self,
4826        item: &Resolved,
4827        since: Option<DateTime<Utc>>,
4828    ) -> Result<bool, SourceError> {
4829        let Some(since) = since else {
4830            return Ok(true);
4831        };
4832        if item.content_kind == ContentKind::DraftIssue
4833            || item.updated_at.is_some_and(|updated| updated < since)
4834        {
4835            return Ok(false);
4836        }
4837        let query = TaskQuery {
4838            commented_since: Some(since),
4839            ..TaskQuery::default()
4840        };
4841        let mut after: Option<String> = None;
4842        loop {
4843            let data = self
4844                .graphql(
4845                    graphql::ISSUE_COMMENTS,
4846                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4847                )
4848                .await?;
4849            let Some(connection) = data
4850                .get("node")
4851                .filter(|value| !value.is_null())
4852                .and_then(|node| node.get("comments"))
4853                .filter(|value| !value.is_null())
4854            else {
4855                // Removed since the search reported it: no longer an issue with comments.
4856                return Ok(false);
4857            };
4858            let comments = optional_nodes(Some(connection), "issue comments")?
4859                .into_iter()
4860                .flatten()
4861                .map(comment_from)
4862                .collect::<Result<Vec<_>, _>>()?;
4863            if query.comments_match(&comments) {
4864                return Ok(true);
4865            }
4866            match next_cursor(connection)? {
4867                Some(next) => {
4868                    validate_cursor_progress(after.as_deref(), &next.0)?;
4869                    after = Some(next.0);
4870                }
4871                None => return Ok(false),
4872            }
4873        }
4874    }
4875
4876    /// Every item on the board: the union of both enumerations GitHub offers of one.
4877    ///
4878    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4879    /// board **draft** and reads the board's own fields beside its items, and only the search
4880    /// reports an item that connection is behind on. The module documentation is where the lag and the
4881    /// measurements behind it are written down.
4882    ///
4883    /// A search result is admitted on the same terms as any other issue this source reaches
4884    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4885    /// names *this* board — so an issue the index still believes is here after it was taken
4886    /// off is refused rather than reported.
4887    ///
4888    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4889    /// which is what the cache could otherwise have broken.
4890    async fn board(&self) -> Result<Board, SourceError> {
4891        let cached = self.board_cache()?.clone();
4892        let mut board = match cached {
4893            Some(board) => board,
4894            None => {
4895                let read = self.read_board().await?;
4896                *self.board_cache()? = Some(read.clone());
4897                read
4898            }
4899        };
4900        for held in self.searched_issues().await? {
4901            if !board.items.iter().any(|item| item.id == held.id) {
4902                board.items.push(held);
4903            }
4904        }
4905        for own in self.created()?.iter() {
4906            if !board.items.iter().any(|item| item.id == own.id) {
4907                board.items.push(own.clone());
4908            }
4909        }
4910        Ok(board)
4911    }
4912
4913    /// This process's own view of the board, or the refusal a poisoned lock is.
4914    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4915        self.board_cache
4916            .lock()
4917            .map_err(|_| SourceError::Unavailable {
4918                message: "this source's view of the board was left inconsistent by an earlier \
4919                      failure; next: run the command again"
4920                    .into(),
4921            })
4922    }
4923
4924    /// Bring this process's own view of the board up to an item it has just written.
4925    ///
4926    /// A created item goes to `created`, which is what completes a board read GitHub's own
4927    /// eventual consistency has left behind. An item that was already there is replaced
4928    /// where it sits, so a second write of it in the same command reads its real parent
4929    /// rather than the one it had before the first write.
4930    ///
4931    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4932    /// that wins: an item this same run created is held in `created` and not in the cached
4933    /// board, and `board` completes the cached board *from* `created`, so replacing only
4934    /// the cached copy of such an item replaces nothing and the read still reports the
4935    /// title it was created with. The search is the third, and it is the one an item the
4936    /// board's own projection is behind on sits in *alone* — which is exactly the item this
4937    /// source is least able to re-read, so leaving it out would put the stale title back on
4938    /// the only items the completion in [`Self::board`] exists for.
4939    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4940        self.resolved_cache()?.insert(item.id.clone(), item.clone());
4941        if created {
4942            self.created()?.push(item);
4943            return Ok(());
4944        }
4945        {
4946            let mut own = self.created()?;
4947            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4948                *held = item;
4949                return Ok(());
4950            }
4951        }
4952        {
4953            let mut own = self.updated()?;
4954            match own.iter_mut().find(|held| held.id == item.id) {
4955                Some(held) => *held = item.clone(),
4956                None => own.push(item.clone()),
4957            }
4958        }
4959        if let Some(board) = self.board_cache()?.as_mut()
4960            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4961        {
4962            *held = item.clone();
4963        }
4964        if let Some(found) = self.search_cache()?.as_mut()
4965            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4966        {
4967            *held = item.clone();
4968        }
4969        for found in self.narrowed_cache()?.values_mut() {
4970            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4971                *held = item.clone();
4972            }
4973        }
4974        Ok(())
4975    }
4976
4977    /// Forget one item this process has just deleted, from every half of its own view.
4978    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4979        self.resolved_cache()?.remove(id);
4980        self.created()?.retain(|own| own.id != *id);
4981        self.updated()?.retain(|own| own.id != *id);
4982        if let Some(board) = self.board_cache()?.as_mut() {
4983            board.items.retain(|item| item.id != *id);
4984        }
4985        if let Some(found) = self.search_cache()?.as_mut() {
4986            found.retain(|item| item.id != *id);
4987        }
4988        for found in self.narrowed_cache()?.values_mut() {
4989            found.retain(|item| item.id != *id);
4990        }
4991        Ok(())
4992    }
4993
4994    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4995    fn narrowed_cache(
4996        &self,
4997    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4998        self.narrowed_cache
4999            .lock()
5000            .map_err(|_| SourceError::Unavailable {
5001                message: "this source's view of a narrowed read was left inconsistent by an \
5002                      earlier failure; next: run the command again"
5003                    .into(),
5004            })
5005    }
5006
5007    /// Every page of the board, read from GitHub.
5008    async fn read_board(&self) -> Result<Board, SourceError> {
5009        let mut after: Option<String> = None;
5010        let mut items = Vec::new();
5011        let mut board;
5012        loop {
5013            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5014            for item in page
5015                .pointer("/items/nodes")
5016                .and_then(Value::as_array)
5017                .ok_or_else(|| SourceError::Malformed {
5018                    message: "GitHub project items.nodes is not an array".into(),
5019                })?
5020            {
5021                if let Some(resolved) = self.resolve(item)? {
5022                    items.push(resolved);
5023                }
5024            }
5025            let info = page
5026                .pointer("/items/pageInfo")
5027                .ok_or_else(|| SourceError::Malformed {
5028                    message: "GitHub project items have no pageInfo".into(),
5029                })?;
5030            let has_next = required_bool(info, "hasNextPage")?;
5031            let next = has_next
5032                .then(|| required_str(info, "endCursor"))
5033                .transpose()?;
5034            board = page.clone();
5035            match next {
5036                Some(next) => {
5037                    validate_cursor_progress(after.as_deref(), next)?;
5038                    after = Some(next.to_owned());
5039                }
5040                None => break,
5041            }
5042        }
5043        Ok(Board {
5044            id: required_str(&board, "id")?.to_owned(),
5045            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5046            items,
5047        })
5048    }
5049
5050    /// The existing items this source has written, for completing a narrowed read that is
5051    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5052    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5053        self.updated.lock().map_err(|_| SourceError::Unavailable {
5054            message: "this source's record of what it wrote in this run was left inconsistent \
5055                      by an earlier failure; next: run the command again"
5056                .into(),
5057        })
5058    }
5059
5060    /// The items this source has created, for completing a board read that is behind.
5061    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5062        self.created.lock().map_err(|_| SourceError::Unavailable {
5063            message: "this source's record of what it created in this run was left \
5064                      inconsistent by an earlier failure; next: run the command again"
5065                .into(),
5066        })
5067    }
5068
5069    /// One board item as this source reports it, or `None` for content it ignores.
5070    ///
5071    /// A pull request is neither a project nor a task — it is somebody's change, not a
5072    /// unit of plan — and an item whose content the token cannot see has nothing to
5073    /// report at all.
5074    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5075        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5076            message: "GitHub project item is missing content".into(),
5077        })?;
5078        if content.is_null() {
5079            return Ok(None);
5080        }
5081        let content_kind = match required_str(content, "__typename")? {
5082            "Issue" => ContentKind::Issue,
5083            "DraftIssue" => ContentKind::DraftIssue,
5084            _ => return Ok(None),
5085        };
5086        let field_values = item
5087            .get("fieldValues")
5088            .ok_or_else(|| SourceError::Malformed {
5089                message: "GitHub project item is missing fieldValues".into(),
5090            })?;
5091        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5092        let nodes = field_values
5093            .get("nodes")
5094            .and_then(Value::as_array)
5095            .ok_or_else(|| SourceError::Malformed {
5096                message: "GitHub project item fieldValues.nodes is not an array".into(),
5097            })?;
5098        if let Some(labels) = content.get("labels") {
5099            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5100        }
5101        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5102        let (body, slot) = metadata_body(raw_body.clone())?;
5103        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5104            .map(|id| NativeId(id.to_owned()));
5105        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5106        // to read one from; it is a task, and never a project.
5107        let sub_issues = match content_kind {
5108            ContentKind::Issue => sub_issue_total(content)?,
5109            ContentKind::DraftIssue => 0,
5110        };
5111        let content_id = required_str(content, "id")?;
5112        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5113            message: format!("GitHub issue {content_id}: {message}"),
5114        })?;
5115        let raw_title = required_str(content, "title")?;
5116        // The design prefix is read *first*, before either of the two rules that separate
5117        // a project from a task. A document is not work whatever sub-issues it has and
5118        // whatever marker it carries, and reading the prefix later would make a design
5119        // issue with none of either an empty project.
5120        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5121            BoardKind::Document
5122        } else if parent.is_some() {
5123            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5124            // under a project is that project's task even when it has sub-issues of its
5125            // own.
5126            BoardKind::Work(ItemKind::Task)
5127        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5128            BoardKind::Work(ItemKind::Project)
5129        } else {
5130            BoardKind::Work(ItemKind::Task)
5131        };
5132        // The title a person wrote, which for a document is the one without the prefix —
5133        // the same way `content` above is the body without this source's metadata slot.
5134        let title = match kind {
5135            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5136            BoardKind::Work(_) => raw_title.to_owned(),
5137        };
5138        let own_repository = content
5139            .pointer("/repository/nameWithOwner")
5140            .and_then(Value::as_str)
5141            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5142            .transpose()
5143            .map_err(|message| SourceError::Malformed { message })?;
5144        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5145            Repository::from_metadata(&slot)
5146                .map_err(|message| SourceError::Malformed { message })?
5147        } else {
5148            own_repository.clone().into_iter().collect()
5149        };
5150        let id = NativeId(content_id.to_owned());
5151        // Read only for a task, because only a task has either list: a project or a
5152        // document holding one of these keys holds nothing this source reports, and the
5153        // keys are left out of its caller-visible metadata all the same.
5154        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5155            let listed = |key: &str| {
5156                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5157                    .map_err(|message| SourceError::Malformed { message })
5158            };
5159            (
5160                listed(TaskRef::DELIVERS_KEY)?,
5161                listed(TaskRef::DELIVERED_BY_KEY)?,
5162            )
5163        } else {
5164            (Vec::new(), Vec::new())
5165        };
5166        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5167        let priority = self.held_priority(nodes)?;
5168        // Present when the item was reached through its own issue, whose board entry
5169        // names the board; a read of the board's own items has the board already. An
5170        // empty id names nothing a field write could address, so it is read as absent and
5171        // the write goes back to reading the board.
5172        let board_id = item
5173            .pointer("/project/id")
5174            .and_then(Value::as_str)
5175            .filter(|id| !id.is_empty());
5176        let resolved = Resolved {
5177            item_id: required_str(item, "id")?.to_owned(),
5178            id,
5179            content_kind,
5180            kind,
5181            title,
5182            body: body.filter(|value| !value.is_empty()),
5183            raw_body,
5184            status: self.statuses.status(option, closed, reason),
5185            option: option.map(str::to_owned),
5186            priority,
5187            closed,
5188            delivers,
5189            delivered_by,
5190            labels: labels(content)?,
5191            parent,
5192            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5193            number: match content_kind {
5194                ContentKind::Issue => Some(issue_number(content)?),
5195                // A draft is filed in no repository, so nothing ever numbered it:
5196                // `DraftIssue` declares no `number` at all, exactly as it declares no
5197                // `subIssuesSummary` the branch above reads.
5198                ContentKind::DraftIssue => None,
5199            },
5200            url: optional_str(content, "url")?.map(str::to_owned),
5201            created_at: optional_time(content, "createdAt")?,
5202            updated_at: optional_time(content, "updatedAt")?,
5203            own_repository,
5204            repositories,
5205            slot,
5206            board_id: board_id.map(str::to_owned),
5207            fields: field_definitions(nodes),
5208            board_fields: Self::carried_board_fields(content, board_id)?,
5209            blocked_by: carried_blocked_by(content)?,
5210        };
5211        self.resolved_cache()?
5212            .insert(resolved.id.clone(), resolved.clone());
5213        Ok(Some(resolved))
5214    }
5215
5216    /// The field definitions of the board `board_id` names — the project this issue's own
5217    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5218    /// `None` when the read carried none, carried no entry for that board, or the board item
5219    /// named no board, which a write then answers by reading the board's fields itself.
5220    ///
5221    /// Matched by the board's node id and never by its number alone: a project number is
5222    /// unique only within its owner, so another owner's board numbered alike can sit on the
5223    /// same page, and its field and option ids address nothing on this one.
5224    fn carried_board_fields(
5225        content: &Value,
5226        board_id: Option<&str>,
5227    ) -> Result<Option<Value>, SourceError> {
5228        let (Some(nodes), Some(board_id)) = (
5229            content.pointer("/boards/nodes").and_then(Value::as_array),
5230            board_id,
5231        ) else {
5232            return Ok(None);
5233        };
5234        let Some(board) = nodes.iter().find_map(|node| {
5235            let project = node.get("project")?;
5236            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5237        }) else {
5238            return Ok(None);
5239        };
5240        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5241            return Ok(None);
5242        };
5243        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5244        Ok(Some(fields.clone()))
5245    }
5246
5247    /// What one board item's `Priority` field says, through this instance's mapping.
5248    ///
5249    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5250    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5251    /// option the mapping does not name is kept as itself — never read as a level or as
5252    /// `none` — for a read of the task to report by name.
5253    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5254        let Some(mapping) = &self.priorities else {
5255            return Ok(HeldPriority::Read(Priority::None));
5256        };
5257        // A value of the field that names no option — a text field someone called `Priority` —
5258        // is malformed rather than `none`: reading it as no priority would let the next copy
5259        // clear one a person set.
5260        let Some(option) = field_values
5261            .iter()
5262            .find(|value| {
5263                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5264            })
5265            .map(|value| required_str(value, "name"))
5266            .transpose()?
5267        else {
5268            return Ok(HeldPriority::Read(Priority::None));
5269        };
5270        Ok(mapping.priority_of(option).map_or_else(
5271            || HeldPriority::Unmapped(option.to_owned()),
5272            HeldPriority::Read,
5273        ))
5274    }
5275
5276    /// What one board item's status is read from: its `Status` option, whether its issue
5277    /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
5278    /// three into the status it reports.
5279    fn status_parts<'a>(
5280        field_values: &'a [Value],
5281        content: &'a Value,
5282    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5283        let option = field_values
5284            .iter()
5285            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5286            .map(|value| required_str(value, "name"))
5287            .transpose()?;
5288        let closed = optional_str(content, "state")? == Some("CLOSED");
5289        Ok((option, closed, optional_str(content, "stateReason")?))
5290    }
5291
5292    /// The board Status option this write selects, or the refusal that says why not.
5293    ///
5294    /// The mapped option is required for both open and terminal targets. A terminal write
5295    /// validates it before changing either representation, so it can never fall back to
5296    /// closing an issue whose board cannot display the matching status.
5297    ///
5298    /// Answers the field's id, the option's id, and the option's name as the board spells
5299    /// it — which is the name a read of the item reports once it sits there.
5300    fn column_for(
5301        &self,
5302        fields: &Value,
5303        status: &Status,
5304        target: &StatusTarget,
5305    ) -> Result<Option<(String, String, String)>, SourceError> {
5306        let wanted = match target {
5307            StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
5308            StatusTarget::Disabled => return Ok(None),
5309        };
5310        let missing = |detail: &str| SourceError::Refused {
5311            message: format!(
5312                "status {} of source {} needs the board Status option {wanted:?}, and {detail};                  add that option to the board, or point status_mapping.{} of this source at one                  it has",
5313                category_name(status.category),
5314                self.name,
5315                category_name(status.category)
5316            ),
5317        };
5318        let Some(field) = Board::field(fields, "Status")? else {
5319            return Err(missing("this board has no Status field"));
5320        };
5321        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5322            return Err(missing(
5323                "this board's Status field is not a single-select field",
5324            ));
5325        }
5326        let option = field
5327            .get("options")
5328            .and_then(Value::as_array)
5329            .and_then(|options| {
5330                options.iter().find(|option| {
5331                    option
5332                        .get("name")
5333                        .and_then(Value::as_str)
5334                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5335                })
5336            });
5337        match option {
5338            None => Err(missing("this board does not have it")),
5339            Some(option) => Ok(Some((
5340                required_str(field, "id")?.to_owned(),
5341                required_str(option, "id")?.to_owned(),
5342                required_str(option, "name")?.to_owned(),
5343            ))),
5344        }
5345    }
5346
5347    /// The refusal a status that closes an issue is answered with over a board draft.
5348    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5349        SourceError::Refused {
5350            message: format!(
5351                "status {} of source {} closes the item's issue, and GitHub draft items have \
5352                 no open or closed state",
5353                category_name(category),
5354                self.name
5355            ),
5356        }
5357    }
5358
5359    /// What a status write to one item needs of the board: the board's id and the
5360    /// definition of its `Status` field, read off the item when the item says both.
5361    ///
5362    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5363    /// and its `Status` value carries that field's definition, options and all. An item that
5364    /// does not say — no board id, or no `Status` value to read the field off — takes them
5365    /// from [`Self::board_fields`], which reads no item.
5366    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5367        if let Some(board) = item.carried_board() {
5368            return Ok(board);
5369        }
5370        if item.defines("Status")
5371            && let Some(board_id) = item.named_board()
5372        {
5373            return Ok(BoardFields {
5374                id: board_id,
5375                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5376            });
5377        }
5378        self.board_fields().await
5379    }
5380
5381    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5382    async fn set_status(
5383        &self,
5384        id: &NativeId,
5385        category: StatusCategory,
5386    ) -> Result<Option<Status>, SourceError> {
5387        // Refused before anything is read, in the words a write of the same status is.
5388        let target = self.resolved_target(category)?;
5389        let Some(mut item) = self
5390            .bound_item(id)
5391            .await?
5392            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5393        else {
5394            return Ok(None);
5395        };
5396        let board = self.status_board(&item).await?;
5397        let wanted = Status {
5398            category,
5399            name: category_name(category).to_owned(),
5400        };
5401        let (field, option, name) = self
5402            .column_for(&board.fields, &wanted, &target)?
5403            .ok_or_else(|| SourceError::Malformed {
5404                message: format!(
5405                    "status {} of source {} names no board Status option",
5406                    category_name(category),
5407                    self.name
5408                ),
5409            })?;
5410        if item.status.category == category && item.option.as_deref() == Some(&name) {
5411            return Ok(Some(item.status));
5412        }
5413        match &target {
5414            StatusTarget::Terminal(_, reason) => {
5415                if item.content_kind == ContentKind::DraftIssue {
5416                    return Err(self.closes_a_draft(category));
5417                }
5418                self.set_item_field(
5419                    board.id.as_str(),
5420                    &item.item_id,
5421                    &field,
5422                    json!({"singleSelectOptionId": option}),
5423                )
5424                .await?;
5425                self.update_content(
5426                    ContentKind::Issue,
5427                    &item.id,
5428                    json!({"stateInput": state_input(Some(&target))}),
5429                )
5430                .await?;
5431                item.closed = true;
5432                item.status = self
5433                    .statuses
5434                    .status(Some(&name), true, Some(reason.reason()));
5435                item.option = Some(name);
5436            }
5437            StatusTarget::Column(_) => {
5438                // An option is what an open item's status is, so a closed issue is reopened
5439                // first — sitting closed in the column, it would read back as closed. A draft has
5440                // no state to reopen.
5441                if item.content_kind == ContentKind::Issue && item.closed {
5442                    self.update_content(
5443                        ContentKind::Issue,
5444                        &item.id,
5445                        json!({"stateInput": state_input(Some(&target))}),
5446                    )
5447                    .await?;
5448                    item.closed = false;
5449                }
5450                self.set_item_field(
5451                    board.id.as_str(),
5452                    &item.item_id,
5453                    &field,
5454                    json!({"singleSelectOptionId": option}),
5455                )
5456                .await?;
5457                item.status = self.statuses.status(Some(&name), false, None);
5458                item.option = Some(name);
5459            }
5460            StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5461        }
5462        let status = item.status.clone();
5463        self.remember_written(item, false)?;
5464        Ok(Some(status))
5465    }
5466
5467    /// Replace one task's `delivered_by` and nothing else; see
5468    /// [`TaskSource::set_delivered_by`].
5469    ///
5470    /// One update of the body, which differs from the body GitHub holds only inside the
5471    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5472    async fn replace_delivered_by(
5473        &self,
5474        id: &NativeId,
5475        delivered_by: &[TaskRef],
5476    ) -> Result<Option<()>, SourceError> {
5477        let entries = TaskRef::listed(
5478            TaskRef::DELIVERED_BY_KEY,
5479            id,
5480            Some(&self.name),
5481            delivered_by.to_vec(),
5482        )
5483        .map_err(|message| SourceError::Refused { message })?;
5484        let Some(mut item) = self
5485            .bound_item(id)
5486            .await?
5487            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5488        else {
5489            return Ok(None);
5490        };
5491        let mut slot = item.slot.clone();
5492        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5493        self.write_slot(&mut item, &slot).await?;
5494        item.delivered_by = entries;
5495        self.remember_written(item, false)?;
5496        Ok(Some(()))
5497    }
5498
5499    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5500    /// see [`TaskSource::set_task_metadata`].
5501    ///
5502    /// `None` when this board holds no item by that id, or holds one of another kind. The
5503    /// answer is the item as this source now reads it, so what a caller is told the key
5504    /// holds is what the slot holds.
5505    ///
5506    /// A key already holding the value is answered without a write, compared as JSON rather
5507    /// than as the body's bytes: a slot a person spelled with other whitespace would
5508    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5509    async fn set_slot_key(
5510        &self,
5511        id: &NativeId,
5512        kind: BoardKind,
5513        key: &MetadataKey,
5514        value: &Value,
5515    ) -> Result<Option<Resolved>, SourceError> {
5516        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5517            return Ok(None);
5518        };
5519        if item.slot.get(key.as_str()) == Some(value) {
5520            return Ok(Some(item));
5521        }
5522        let mut slot = item.slot.clone();
5523        slot.insert(key.as_str().to_owned(), value.clone());
5524        self.write_slot(&mut item, &slot).await?;
5525        self.remember_written(item.clone(), false)?;
5526        Ok(Some(item))
5527    }
5528
5529    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5530    /// `item` up to what that write left.
5531    ///
5532    /// The body sent differs from the body GitHub holds only inside the slot — see
5533    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5534    /// the mutation the item's content takes, so a board draft's body is written with
5535    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5536    async fn write_slot(
5537        &self,
5538        item: &mut Resolved,
5539        slot: &BTreeMap<String, Value>,
5540    ) -> Result<(), SourceError> {
5541        let held = item.raw_body.clone().unwrap_or_default();
5542        let body = with_slot(&held, slot)?;
5543        if body != held {
5544            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5545                .await?;
5546        }
5547        let (visible, slot) = metadata_body(Some(body.clone()))?;
5548        item.body = visible.filter(|value| !value.is_empty());
5549        item.raw_body = Some(body);
5550        item.slot = slot;
5551        Ok(())
5552    }
5553
5554    /// This instance's target for a category, refusing one it has disabled.
5555    ///
5556    /// Nothing here mutates the board's option set to make room for a status. GitHub
5557    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5558    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5559    /// field and every item's status.
5560    fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5561        let target = self.statuses.target(category).clone();
5562        if target != StatusTarget::Disabled {
5563            return Ok(target);
5564        }
5565        Err(SourceError::Refused {
5566            message: if category == StatusCategory::Draft {
5567                format!(
5568                    "status draft is disabled for source {}: draft is incompatible with this \
5569                     integration because GitHub draft issues cannot have sub-issues, and this \
5570                     source stores a project's tasks as its issue's sub-issues",
5571                    self.name
5572                )
5573            } else if category == StatusCategory::Unknown {
5574                format!(
5575                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5576                     to one board Status option name; every word classified unknown is written \
5577                     to that one option",
5578                    category_name(category),
5579                    self.name,
5580                    category_name(category)
5581                )
5582            } else {
5583                format!(
5584                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5585                     to a board Status option name",
5586                    category_name(category),
5587                    self.name,
5588                    category_name(category)
5589                )
5590            },
5591        })
5592    }
5593
5594    /// What writing `priority` does to one item's `Priority` field on this board, or the
5595    /// refusal naming what the board lacks.
5596    ///
5597    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5598    /// none already, or of an item not created yet. Every other priority selects the option
5599    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5600    /// without that option, is refused rather than given one: reads and writes never create
5601    /// a field or an option.
5602    fn priority_write(
5603        &self,
5604        fields: &Value,
5605        existing: Option<&Resolved>,
5606        priority: Priority,
5607    ) -> Result<Option<PriorityWrite>, SourceError> {
5608        let Some(mapping) = &self.priorities else {
5609            return Err(self.holds_no_priority());
5610        };
5611        let Some(wanted) = mapping.option(priority) else {
5612            if !existing.is_some_and(Resolved::holds_priority) {
5613                return Ok(None);
5614            }
5615            let field =
5616                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5617                    message: format!(
5618                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5619                    ),
5620                })?;
5621            return Ok(Some(PriorityWrite::Clear {
5622                field: required_str(field, "id")?.to_owned(),
5623            }));
5624        };
5625        let missing = |detail: &str| SourceError::Refused {
5626            message: format!(
5627                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5628                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5629                 it, or point priority_mapping.{priority} of this source at an option the board \
5630                 has",
5631                self.name, self.name
5632            ),
5633        };
5634        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5635            return Err(missing(&format!(
5636                "this board has no {PRIORITY_FIELD} field"
5637            )));
5638        };
5639        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5640            return Err(missing(&format!(
5641                "this board's {PRIORITY_FIELD} field is not a single-select field"
5642            )));
5643        }
5644        // An options list that is absent or not a list is an answer this source cannot read,
5645        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5646        let option = field
5647            .get("options")
5648            .and_then(Value::as_array)
5649            .ok_or_else(|| SourceError::Malformed {
5650                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5651            })?
5652            .iter()
5653            .find(|option| {
5654                option
5655                    .get("name")
5656                    .and_then(Value::as_str)
5657                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5658            })
5659            .ok_or_else(|| missing("this board does not have it"))?;
5660        Ok(Some(PriorityWrite::Select {
5661            field: required_str(field, "id")?.to_owned(),
5662            option: required_str(option, "id")?.to_owned(),
5663        }))
5664    }
5665
5666    /// Apply one priority write to one board item.
5667    async fn write_priority(
5668        &self,
5669        board_id: &str,
5670        item_id: &str,
5671        write: &PriorityWrite,
5672    ) -> Result<(), SourceError> {
5673        match write {
5674            PriorityWrite::Select { field, option } => {
5675                self.set_item_field(
5676                    board_id,
5677                    item_id,
5678                    field,
5679                    json!({"singleSelectOptionId": option}),
5680                )
5681                .await
5682            }
5683            PriorityWrite::Clear { field } => {
5684                let data = self
5685                    .graphql(
5686                        graphql::CLEAR_FIELD,
5687                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5688                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5689                    )
5690                    .await?;
5691                let returned = data
5692                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5693                    .ok_or_else(|| SourceError::Malformed {
5694                        message: "GitHub field clear returned no project item".into(),
5695                    })?;
5696                if required_str(returned, "id")? != item_id {
5697                    return Err(SourceError::Malformed {
5698                        message: "GitHub field clear returned the wrong project item".into(),
5699                    });
5700                }
5701                Ok(())
5702            }
5703        }
5704    }
5705
5706    /// The refusal a priority is answered with by an instance configured with no
5707    /// `priority_mapping`, which holds none.
5708    fn holds_no_priority(&self) -> SourceError {
5709        SourceError::Refused {
5710            message: format!(
5711                "source {} holds no task priority: its configuration sets no priority_mapping; \
5712                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5713                 fields {} --apply` to set its board up",
5714                self.name, self.name
5715            ),
5716        }
5717    }
5718
5719    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5720    ///
5721    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5722    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5723    async fn set_priority(
5724        &self,
5725        id: &NativeId,
5726        priority: Priority,
5727    ) -> Result<Option<Priority>, SourceError> {
5728        if self.priorities.is_none() {
5729            return Err(self.holds_no_priority());
5730        }
5731        let Some(mut item) = self
5732            .bound_item(id)
5733            .await?
5734            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5735        else {
5736            return Ok(None);
5737        };
5738        if priority == Priority::None && !item.holds_priority() {
5739            return Ok(Some(priority));
5740        }
5741        // The item's own read carries the field's definition whenever it holds a value of
5742        // it, which a clear always does; a select onto an item holding none reads the board.
5743        let board = match (item.carried_board(), item.named_board()) {
5744            (Some(board), _) => board,
5745            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5746                id,
5747                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5748            },
5749            _ => self.board_fields().await?,
5750        };
5751        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5752            return Ok(Some(priority));
5753        };
5754        let (document, root, input) = match write {
5755            PriorityWrite::Select { field, option } => (
5756                graphql::UPDATE_FIELD,
5757                "updateProjectV2ItemFieldValue",
5758                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5759            ),
5760            PriorityWrite::Clear { field } => (
5761                graphql::CLEAR_FIELD,
5762                "clearProjectV2ItemFieldValue",
5763                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5764            ),
5765        };
5766        let data = self
5767            .graphql(
5768                document,
5769                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5770            )
5771            .await?;
5772        let returned = data
5773            .get(root)
5774            .and_then(|value| value.get("projectV2Item"))
5775            .ok_or_else(|| SourceError::Malformed {
5776                message: "GitHub priority write returned no project item".into(),
5777            })?;
5778        if required_str(returned, "id")? != item.item_id {
5779            return Err(SourceError::Malformed {
5780                message: "GitHub priority write returned the wrong project item".into(),
5781            });
5782        }
5783        let value = returned
5784            .get("fieldValueByName")
5785            .ok_or_else(|| SourceError::Malformed {
5786                message: "GitHub priority write returned no priority read-back".into(),
5787            })?;
5788        if !value.is_null()
5789            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5790        {
5791            return Err(SourceError::Malformed {
5792                message: "GitHub priority read-back is not a Priority field value".into(),
5793            });
5794        }
5795        let values = if value.is_null() {
5796            Vec::new()
5797        } else {
5798            vec![value.clone()]
5799        };
5800        item.priority = self.held_priority(&values)?;
5801        let answer = item.task()?.priority;
5802        self.remember_written(item, false)?;
5803        Ok(Some(answer))
5804    }
5805
5806    /// Replace one task's visible body and nothing else; see
5807    /// [`TaskSource::set_task_content`].
5808    ///
5809    /// One update of the body, which differs from the body GitHub holds only outside the
5810    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5811    /// this source keeps there reads back as it was. A body that would not change is not
5812    /// sent at all.
5813    async fn replace_content(
5814        &self,
5815        id: &NativeId,
5816        content: &str,
5817    ) -> Result<Option<()>, SourceError> {
5818        let Some(mut item) = self
5819            .bound_item(id)
5820            .await?
5821            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5822        else {
5823            return Ok(None);
5824        };
5825        let held = item.raw_body.clone().unwrap_or_default();
5826        let body = with_content(&held, content)?;
5827        // Checked before anything is sent: content ending in what this source reads as its own
5828        // metadata slot would read back as metadata rather than as the content it was.
5829        let (visible, slot) = metadata_body(Some(body.clone()))?;
5830        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5831            return Err(SourceError::Refused {
5832                message: format!(
5833                    "this content ends in what source {} reads as its own metadata slot \
5834                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5835                     as content; next: remove that trailing block from the content",
5836                    self.name
5837                ),
5838            });
5839        }
5840        if body != held {
5841            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5842                .await?;
5843        }
5844        item.body = visible.filter(|value| !value.is_empty());
5845        item.raw_body = Some(body);
5846        item.slot = slot;
5847        self.remember_written(item, false)?;
5848        Ok(Some(()))
5849    }
5850
5851    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5852    ///
5853    /// One read of the item — which carries the board's field definitions and the issue's
5854    /// `blockedBy`, so neither is read again — and then only what differs from it: the
5855    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5856    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5857    /// the title, the body — visible content and metadata slot together — and a state change.
5858    /// So an update naming any of title, body, metadata, status and priority is one read and
5859    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5860    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5861    /// as a whole write does; an open one selects its option and then reopens. The origin
5862    /// field is never written: an update is of an item that already exists, whose origin is
5863    /// what it is.
5864    ///
5865    /// The task answered is the item as those writes left it, built from the read and what was
5866    /// sent rather than read again — the same record a later read in this run answers from.
5867    async fn targeted_update(
5868        &self,
5869        id: &NativeId,
5870        update: &TaskUpdate,
5871    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5872        // Everything this source can refuse without reading the item is refused first, in the
5873        // words a whole write of the same fields is refused with.
5874        update.consistent()?;
5875        if update
5876            .title
5877            .as_deref()
5878            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5879        {
5880            return Err(SourceError::Refused {
5881                message: format!(
5882                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5883                     spells a document, so it would read back as one rather than as a task; \
5884                     retitle it",
5885                    self.name
5886                ),
5887            });
5888        }
5889        if let Some(delivers) = &update.delivers {
5890            TaskRef::listed(
5891                TaskRef::DELIVERS_KEY,
5892                id,
5893                Some(&self.name),
5894                delivers.clone(),
5895            )
5896            .map_err(|message| SourceError::Refused { message })?;
5897        }
5898        if self.priorities.is_none()
5899            && update
5900                .priority
5901                .is_some_and(|priority| priority != Priority::None)
5902        {
5903            return Err(self.holds_no_priority());
5904        }
5905        let target = update
5906            .status
5907            .as_ref()
5908            .map(|status| self.resolved_target(status.category))
5909            .transpose()?;
5910        let Some(mut item) = self
5911            .bound_item(id)
5912            .await?
5913            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5914        else {
5915            return Ok(None);
5916        };
5917        let before = item.task()?;
5918
5919        let mut status_move = None;
5920        if let (Some(status), Some(target)) = (&update.status, target) {
5921            let board = self.status_board(&item).await?;
5922            let (field, option, name) = self
5923                .column_for(&board.fields, status, &target)?
5924                .ok_or_else(|| SourceError::Malformed {
5925                    message: format!(
5926                        "status {} of source {} names no board Status option",
5927                        category_name(status.category),
5928                        self.name
5929                    ),
5930                })?;
5931            let terminal = matches!(target, StatusTarget::Terminal(_, _));
5932            if terminal && item.content_kind == ContentKind::DraftIssue {
5933                return Err(self.closes_a_draft(status.category));
5934            }
5935            let landed = match &target {
5936                StatusTarget::Terminal(_, reason) => {
5937                    self.statuses
5938                        .status(Some(&name), true, Some(reason.reason()))
5939                }
5940                _ => self.statuses.status(Some(&name), false, None),
5941            };
5942            let option_moves = item
5943                .option
5944                .as_deref()
5945                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5946            let state_moves = item.content_kind == ContentKind::Issue
5947                && (item.closed != terminal || (terminal && item.status != landed));
5948            if let Some(moves) = Moves::of(option_moves, state_moves) {
5949                status_move = Some(StatusMove {
5950                    board: board.id,
5951                    field,
5952                    option,
5953                    name,
5954                    target,
5955                    landed,
5956                    moves,
5957                });
5958            }
5959        }
5960
5961        let mut priority_move = None;
5962        if let Some(priority) = update.priority
5963            && self.priorities.is_some()
5964            && item.priority != HeldPriority::Read(priority)
5965        {
5966            let board = match (item.carried_board(), item.named_board()) {
5967                (Some(board), _) => board,
5968                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
5969                    id: board,
5970                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5971                },
5972                _ => self.board_fields().await?,
5973            };
5974            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5975                priority_move = Some((board.id, write, priority));
5976            }
5977        }
5978
5979        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5980        // recorded in the slot, and the slot travels in the one body update below.
5981        let edges = match &update.depends_on {
5982            Some(edges) => Some(
5983                self.partition_edges(
5984                    BoardKind::Work(ItemKind::Task),
5985                    item.content_kind,
5986                    item.blocked_by.as_deref(),
5987                    edges,
5988                )
5989                .await?,
5990            ),
5991            None => None,
5992        };
5993
5994        let mut slot = item.slot.clone();
5995        for (key, value) in &update.metadata_set {
5996            slot.insert(key.as_str().to_owned(), value.clone());
5997        }
5998        for key in &update.metadata_remove {
5999            slot.remove(key.as_str());
6000        }
6001        if let Some(delivers) = &update.delivers {
6002            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6003        }
6004        if let Some((_, recorded)) = &edges {
6005            record_edges(&mut slot, recorded);
6006        }
6007        let held = item.raw_body.clone().unwrap_or_default();
6008        let content = match &update.content {
6009            Some(content) => with_content(&held, content)?,
6010            None => held.clone(),
6011        };
6012        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6013        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6014        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6015        let body = if slot == item.slot {
6016            content
6017        } else {
6018            with_slot(&content, &slot)?
6019        };
6020        // Checked before anything is sent, as a content write checks it: content ending in
6021        // what this source reads as its own slot would read back as metadata.
6022        let (visible, read) = metadata_body(Some(body.clone()))?;
6023        let wanted = update.content.as_deref().or(item.body.as_deref());
6024        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6025            return Err(SourceError::Refused {
6026                message: format!(
6027                    "this content ends in what source {} reads as its own metadata slot \
6028                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6029                     as content; next: remove that trailing block from the content",
6030                    self.name
6031                ),
6032            });
6033        }
6034        let recorded_moves =
6035            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6036
6037        // One `updateIssue` carries all three, because every mutation spends the secondary
6038        // limiter and the title, body and state are one mutation's inputs.
6039        let mut fields = serde_json::Map::new();
6040        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6041            fields.insert("title".to_owned(), json!(title));
6042        }
6043        if body != held {
6044            fields.insert("body".to_owned(), json!(body));
6045        }
6046        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6047            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6048        }
6049        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6050        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6051        // without undoing an earlier field when a later one fails — so a body written before a
6052        // board field the board then refused would be left changed. Written after every other
6053        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6054        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6055        // first, together in one request — a terminal option selected before the issue
6056        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6057        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6058        let mut clear: Option<(&BoardId, &str)> = None;
6059        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6060            board_writes.push((
6061                &moving.board,
6062                (
6063                    moving.field.clone(),
6064                    json!({"singleSelectOptionId": moving.option}),
6065                ),
6066            ));
6067        }
6068        match &priority_move {
6069            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6070                board,
6071                (field.clone(), json!({"singleSelectOptionId": option})),
6072            )),
6073            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6074            None => {}
6075        }
6076        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6077        boards.extend(clear.map(|(board, _)| board));
6078        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6079        for board in boards {
6080            let writes = board_writes
6081                .iter()
6082                .filter(|(on, _)| on.as_str() == board.as_str())
6083                .map(|(_, write)| write.clone())
6084                .collect::<Vec<_>>();
6085            let cleared = clear
6086                .filter(|(on, _)| on.as_str() == board.as_str())
6087                .map(|(_, field)| field);
6088            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6089                .await?;
6090        }
6091        let mut blocked_by_moved = false;
6092        if let Some((native, _)) = &edges
6093            && item.content_kind == ContentKind::Issue
6094        {
6095            blocked_by_moved = self
6096                .reconcile_blocked_by(
6097                    &item.id,
6098                    native,
6099                    Issue::Existing(item.blocked_by.as_deref()),
6100                )
6101                .await?;
6102        }
6103        if !fields.is_empty() {
6104            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6105                .await?;
6106        }
6107
6108        if let Some(title) = &update.title {
6109            item.title.clone_from(title);
6110        }
6111        item.body = visible.filter(|value| !value.is_empty());
6112        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6113        item.slot = slot;
6114        if let Some(delivers) = &update.delivers {
6115            item.delivers.clone_from(delivers);
6116        }
6117        if let Some(moving) = status_move {
6118            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6119                && item.content_kind == ContentKind::Issue;
6120            item.status = moving.landed;
6121            item.option = Some(moving.name);
6122        }
6123        if let Some((_, _, priority)) = priority_move {
6124            item.priority = HeldPriority::Read(priority);
6125        }
6126        let task = item.task()?;
6127        let mut written = update.changed(&before, &task);
6128        if blocked_by_moved || recorded_moves {
6129            written.insert(UpdatedField::DependsOn);
6130        }
6131        self.remember_written(item, false)?;
6132        Ok(Some(TaskUpdateOutcome {
6133            task,
6134            written,
6135            delivers_before: before.delivers,
6136        }))
6137    }
6138
6139    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6140    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6141    ///
6142    /// One update of the body: the content outside the slot, and inside it that one entry,
6143    /// every other entry kept as it was. This source keeps no template answers — an issue has
6144    /// no room beside itself that is not its body, and answers written there would duplicate
6145    /// what the content already says and count against GitHub's body limit — so `answers`
6146    /// reaches nothing here. A body that would not change is not sent at all.
6147    async fn replace_rendering(
6148        &self,
6149        id: &NativeId,
6150        kind: BoardKind,
6151        content: &str,
6152        provenance: &Value,
6153    ) -> Result<Option<()>, SourceError> {
6154        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6155            return Ok(None);
6156        };
6157        let held = item.raw_body.clone().unwrap_or_default();
6158        let mut slot = item.slot.clone();
6159        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6160        let body = with_slot(&with_content(&held, content)?, &slot)?;
6161        // Checked before anything is sent, as a content write checks it.
6162        let (visible, read) = metadata_body(Some(body.clone()))?;
6163        if visible.as_deref().unwrap_or_default() != content || read != slot {
6164            return Err(SourceError::Refused {
6165                message: format!(
6166                    "this content ends in what source {} reads as its own metadata slot \
6167                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6168                     as content; next: remove that trailing block from the template",
6169                    self.name
6170                ),
6171            });
6172        }
6173        if body != held {
6174            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6175                .await?;
6176        }
6177        item.body = visible.filter(|value| !value.is_empty());
6178        item.raw_body = Some(body);
6179        item.slot = read;
6180        self.remember_written(item, false)?;
6181        Ok(Some(()))
6182    }
6183
6184    async fn set_item_field(
6185        &self,
6186        board_id: &str,
6187        item_id: &str,
6188        field_id: &str,
6189        value: Value,
6190    ) -> Result<(), SourceError> {
6191        let data = self
6192            .graphql(
6193                graphql::UPDATE_FIELD,
6194                json!({"input":{
6195                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6196                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6197            )
6198            .await?;
6199        let returned = data
6200            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6201            .ok_or_else(|| SourceError::Malformed {
6202                message: "GitHub field update returned no project item".into(),
6203            })?;
6204        if required_str(returned, "id")? != item_id {
6205            return Err(SourceError::Malformed {
6206                message: "GitHub field update returned the wrong project item".into(),
6207            });
6208        }
6209        Ok(())
6210    }
6211
6212    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6213    /// one request. Every returned item id is checked, including optional aliases.
6214    async fn set_item_fields(
6215        &self,
6216        board: &str,
6217        item: &str,
6218        fields: &[(String, Value)],
6219        clear: Option<&str>,
6220    ) -> Result<(), SourceError> {
6221        if fields.len() <= 1 && clear.is_none() {
6222            if let Some((field, value)) = fields.first() {
6223                self.set_item_field(board, item, field, value.clone())
6224                    .await?;
6225            }
6226            return Ok(());
6227        }
6228        if fields.is_empty() {
6229            if let Some(field) = clear {
6230                self.write_priority(
6231                    board,
6232                    item,
6233                    &PriorityWrite::Clear {
6234                        field: field.to_owned(),
6235                    },
6236                )
6237                .await?;
6238            }
6239            return Ok(());
6240        }
6241        let input = |index: usize| {
6242            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6243            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6244        };
6245        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6246            "input":input(0),"second":input(1),"third":input(2),
6247            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6248            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6249        })).await?;
6250        for alias in [
6251            Some("updateProjectV2ItemFieldValue"),
6252            (fields.len() > 1).then_some("second"),
6253            (fields.len() > 2).then_some("third"),
6254            clear.map(|_| "cleared"),
6255        ]
6256        .into_iter()
6257        .flatten()
6258        {
6259            let returned = data
6260                .get(alias)
6261                .and_then(|value| value.get("projectV2Item"))
6262                .ok_or_else(|| SourceError::Malformed {
6263                    message: format!("GitHub field update {alias} returned no project item"),
6264                })?;
6265            if required_str(returned, "id")? != item {
6266                return Err(SourceError::Malformed {
6267                    message: format!("GitHub field update {alias} returned the wrong project item"),
6268                });
6269            }
6270        }
6271        Ok(())
6272    }
6273
6274    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6275        let mut after: Option<String> = None;
6276        let mut ids = Vec::new();
6277        loop {
6278            let data = self
6279                .graphql(
6280                    graphql::ISSUE_DEPENDENCIES,
6281                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6282                )
6283                .await?;
6284            let connection =
6285                data.pointer("/node/blockedBy")
6286                    .ok_or_else(|| SourceError::Malformed {
6287                        message: "GitHub dependency response has no blockedBy connection".into(),
6288                    })?;
6289            ids.extend(
6290                connection
6291                    .get("nodes")
6292                    .and_then(Value::as_array)
6293                    .ok_or_else(|| SourceError::Malformed {
6294                        message: "GitHub dependency response nodes is not an array".into(),
6295                    })?
6296                    .iter()
6297                    .map(|value| required_str(value, "id").map(str::to_owned))
6298                    .collect::<Result<Vec<_>, _>>()?,
6299            );
6300            let next = next_cursor(connection)?;
6301            if let Some(next) = &next {
6302                validate_cursor_progress(after.as_deref(), &next.0)?;
6303            }
6304            after = next.map(|cursor| cursor.0);
6305            if after.is_none() {
6306                return Ok(ids);
6307            }
6308        }
6309    }
6310
6311    async fn dependencies(
6312        &self,
6313        id: &NativeId,
6314        near_kind: ItemKind,
6315        direction: Direction,
6316        page: &PageRequest,
6317    ) -> Result<Page<DependencyEdge>, SourceError> {
6318        validate_page(page)?;
6319        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6320        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6321        let recorded = recorded_offset(cursor, direction)?;
6322        // What this issue is blocked by, when a read of it by its own id in this command
6323        // already carried the whole connection — a copy reads the item it writes before it
6324        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6325        // after it. Answered from that read, in the shape the dependency read answers in;
6326        // anything else is asked of GitHub.
6327        let carried = match direction {
6328            Direction::DependsOn => self
6329                .resolved_cache()?
6330                .get(id)
6331                .filter(|item| item.content_kind == ContentKind::Issue)
6332                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6333            Direction::DependedOnBy => None,
6334        }
6335        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6336        // Asked for even in the recorded phase, whose page reads nothing from the
6337        // connection: `__typename` is what says whether this item has a native
6338        // relationship at all, and that is what decides which far ends the reserved key is
6339        // allowed to hold.
6340        let data = match carried {
6341            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6342                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6343            None => {
6344                self.graphql(
6345                    graphql::ISSUE_DEPENDENCIES,
6346                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6347                           "after":if recorded.is_some() {None} else {cursor}}),
6348                )
6349                .await?
6350            }
6351        };
6352        let node =
6353            data.get("node")
6354                .filter(|v| !v.is_null())
6355                .ok_or_else(|| SourceError::Refused {
6356                    message: format!(
6357                        "GitHub item {} was not found or does not support dependencies",
6358                        id.0
6359                    ),
6360                })?;
6361        let connection_name = match direction {
6362            Direction::DependsOn => "blockedBy",
6363            Direction::DependedOnBy => "blocking",
6364        };
6365        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6366        // named natively and the reserved key may hold any far end. An issue's connections
6367        // hold issues, and this source reads them at the near item's own level.
6368        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6369        if let Some(offset) = recorded {
6370            return Ok(recorded_page(
6371                self.recorded_edges(id, near_kind, direction, natively_names, node)
6372                    .await?,
6373                offset,
6374                limit,
6375            ));
6376        }
6377        if natively_names.is_none() {
6378            return Ok(recorded_page(
6379                self.recorded_edges(id, near_kind, direction, natively_names, node)
6380                    .await?,
6381                0,
6382                limit,
6383            ));
6384        }
6385        let connection = node
6386            .get(connection_name)
6387            .ok_or_else(|| SourceError::Malformed {
6388                message: "GitHub dependency response is missing its connection".into(),
6389            })?;
6390        let nodes = connection
6391            .get("nodes")
6392            .and_then(Value::as_array)
6393            .ok_or_else(|| SourceError::Malformed {
6394                message: "GitHub dependency response nodes is not an array".into(),
6395            })?;
6396        // `from` depends on `to`, always. GitHub spells the same relationship from either
6397        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6398        // it — so the near item is `from` in one direction and `to` in the other.
6399        let items = nodes
6400            .iter()
6401            .map(|value| {
6402                let related = NativeId(required_str(value, "id")?.into());
6403                let related_kind = related_kind(value)?;
6404                let (from, to) = match direction {
6405                    Direction::DependsOn => (
6406                        DependencyEndpoint::from_native(id.clone(), near_kind),
6407                        DependencyEndpoint::from_native(related, related_kind),
6408                    ),
6409                    Direction::DependedOnBy => (
6410                        DependencyEndpoint::from_native(related, related_kind),
6411                        DependencyEndpoint::from_native(id.clone(), near_kind),
6412                    ),
6413                };
6414                Ok(DependencyEdge {
6415                    from,
6416                    to,
6417                    kind: DependencyKind::Blocks,
6418                })
6419            })
6420            .collect::<Result<Vec<_>, SourceError>>()?;
6421        let mut next = next_cursor(connection)?;
6422        if let Some(next) = &next {
6423            validate_cursor_progress(cursor, &next.0)?;
6424        }
6425        if next.is_none()
6426            && !self
6427                .recorded_edges(id, near_kind, direction, natively_names, node)
6428                .await?
6429                .is_empty()
6430        {
6431            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6432        }
6433        Ok(Page { items, next })
6434    }
6435
6436    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6437    /// a far end in another source has to live: no GitHub issue relationship can name one.
6438    ///
6439    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6440    /// source never writes one down.
6441    ///
6442    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6443    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6444    /// request beyond the read already made, and reading the board for them would be a
6445    /// walk of every item for one field of one. A draft has no body in that answer, because
6446    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6447    /// listing of the board, which can be behind on the very item asked about.
6448    async fn recorded_edges(
6449        &self,
6450        id: &NativeId,
6451        near_kind: ItemKind,
6452        direction: Direction,
6453        natively_names: Option<ItemKind>,
6454        node: &Value,
6455    ) -> Result<Vec<DependencyEdge>, SourceError> {
6456        if direction != Direction::DependsOn {
6457            return Ok(Vec::new());
6458        }
6459        let slot = match node.get("body") {
6460            Some(body) if natively_names.is_some() => {
6461                metadata_body(body.as_str().map(str::to_owned))?.1
6462            }
6463            _ => {
6464                let Some(item) = self.bound_item(id).await? else {
6465                    return Ok(Vec::new());
6466                };
6467                item.slot
6468            }
6469        };
6470        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6471            .map_err(|message| SourceError::Malformed { message })
6472    }
6473
6474    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6475        self.repository
6476            .as_ref()
6477            .ok_or_else(|| SourceError::Refused {
6478                message: format!(
6479                    "source {} has no repository configured, and a GitHub Projects board has no \
6480                 repository of its own to create an issue in; set repository: owner/name on \
6481                 this source",
6482                    self.name
6483                ),
6484            })
6485    }
6486
6487    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6488    /// states.
6489    ///
6490    /// The fallback is demanded first, whichever arm answers: a write without a configured
6491    /// repository is refused naming the field exactly as it was before the rule existed,
6492    /// so a source that could not write before cannot write now, rather than writing for
6493    /// the one item whose own field happens to decide it.
6494    ///
6495    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6496    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6497    /// entry owned by someone other than the owner of the parent issue's repository —
6498    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6499    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6500    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6501    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6502    /// and is visible to the token is checked where its node id is resolved, still before
6503    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6504    /// looked up in a listing of the board, which can be minutes behind an issue its own
6505    /// `projectItems` already places on it — and that read answers first from this process's
6506    /// own record, so a project created moments ago in this command answers though GitHub
6507    /// has not caught up.
6508    async fn creation_target(
6509        &self,
6510        incoming: &Incoming<'_>,
6511    ) -> Result<RepositoryTarget, SourceError> {
6512        let fallback = self.configured_repository()?;
6513        let what = |incoming: &Incoming<'_>| {
6514            format!(
6515                "{} {:?}",
6516                incoming.written.kind().describes(),
6517                incoming.title
6518            )
6519        };
6520        let parent = match incoming.parent {
6521            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6522                SourceError::Refused {
6523                    message: format!(
6524                        "GitHub project issue {} was not found on the board of source {}, so {} \
6525                         cannot be filed under it",
6526                        parent.0,
6527                        self.name,
6528                        what(incoming)
6529                    ),
6530                }
6531            })?),
6532            None => None,
6533        };
6534        let parents_repository = parent
6535            .as_ref()
6536            .map(|parent| {
6537                // A draft is on the board and so is found, but it has no repository to
6538                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6539                // would refuse the task only once `createIssue` had made it.
6540                if parent.content_kind == ContentKind::DraftIssue {
6541                    return Err(SourceError::Refused {
6542                        message: format!(
6543                            "GitHub project item {} on the board of source {} is a draft, \
6544                             which cannot have sub-issues, so {} cannot be filed under it",
6545                            parent.id.0,
6546                            self.name,
6547                            what(incoming)
6548                        ),
6549                    });
6550                }
6551                // An issue's repository is where a sub-issue is placed and whose owner it
6552                // is compared against, so a parent whose repository this source cannot
6553                // spell as `owner/name` — GitHub's login grammar is wider than this
6554                // source's floor — is one nothing can be filed under.
6555                parent
6556                    .own_repository
6557                    .as_ref()
6558                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6559                    .ok_or_else(|| SourceError::Malformed {
6560                        message: format!(
6561                            "GitHub project issue {} on the board of source {} is in {}, which \
6562                             is not a {}/owner/name repository this source can place {} in",
6563                            parent.id.0,
6564                            self.name,
6565                            parent
6566                                .own_repository
6567                                .as_ref()
6568                                .map_or("no repository", Repository::as_str),
6569                            RepositoryTarget::HOST,
6570                            what(incoming)
6571                        ),
6572                    })
6573            })
6574            .transpose()?;
6575        match incoming.repositories {
6576            [named] => {
6577                let target =
6578                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6579                        message: format!(
6580                            "{} names repository {}, which is not a {}/owner/name repository \
6581                             source {} can create an issue in; name one that is, or name none",
6582                            what(incoming),
6583                            named.as_str(),
6584                            RepositoryTarget::HOST,
6585                            self.name
6586                        ),
6587                    })?;
6588                if let Some(parents) = &parents_repository
6589                    && parents.owner != target.owner
6590                {
6591                    return Err(SourceError::Refused {
6592                        message: format!(
6593                            "{} names repository {}, owned by {}, but its project's issue is in \
6594                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6595                             of the same owner as its parent issue; name a repository of {}, or \
6596                             name none",
6597                            what(incoming),
6598                            target.slug(),
6599                            target.owner,
6600                            parents.slug(),
6601                            parents.owner,
6602                            parents.owner
6603                        ),
6604                    });
6605                }
6606                Ok(target)
6607            }
6608            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6609        }
6610    }
6611
6612    /// The node id of the repository `incoming` is being created in, or the refusal naming
6613    /// the item and the repository the token cannot see.
6614    ///
6615    /// Resolved once per command per repository; see [`Self::repository_cache`].
6616    async fn repository_id(
6617        &self,
6618        repository: &RepositoryTarget,
6619        incoming: &Incoming<'_>,
6620    ) -> Result<String, SourceError> {
6621        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6622            return Ok(id);
6623        }
6624        let data = self
6625            .graphql(
6626                graphql::REPOSITORY,
6627                json!({"owner":repository.owner,"name":repository.name}),
6628            )
6629            .await?;
6630        self.repository_read(&data, repository, incoming)
6631    }
6632
6633    /// The repository's node id out of an answer carrying the `repository` root, held for
6634    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6635    fn repository_read(
6636        &self,
6637        data: &Value,
6638        repository: &RepositoryTarget,
6639        incoming: &Incoming<'_>,
6640    ) -> Result<String, SourceError> {
6641        let node = data
6642            .get("repository")
6643            .filter(|value| !value.is_null())
6644            .ok_or_else(|| SourceError::Refused {
6645                message: format!(
6646                    "GitHub repository {} was not found or is not visible to the token, so {} \
6647                     {:?} cannot be created in it",
6648                    repository.slug(),
6649                    incoming.written.kind().describes(),
6650                    incoming.title
6651                ),
6652            })?;
6653        let id = required_str(node, "id")?.to_owned();
6654        self.repository_cache()?
6655            .insert(repository.clone(), id.clone());
6656        Ok(id)
6657    }
6658
6659    fn repository_cache(
6660        &self,
6661    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6662        self.repository_cache
6663            .lock()
6664            .map_err(|_| SourceError::Unavailable {
6665                message: "this source's record of the destination repository was left \
6666                          inconsistent by an earlier failure; next: run the command again"
6667                    .into(),
6668            })
6669    }
6670
6671    /// Create or update one board item, whichever kind it is.
6672    async fn write_item(
6673        &self,
6674        incoming: &Incoming<'_>,
6675        target: Option<&NativeId>,
6676        depends_on: &[DependencyEdge],
6677    ) -> Result<NativeId, SourceError> {
6678        // Refused before anything is read or written: a task or a project titled the way
6679        // this board spells a document would land as an issue this same source reads back
6680        // as a document, so the field this destination cannot carry is named rather than
6681        // written and silently reclassified.
6682        if let Written::Work(kind, _) = incoming.written
6683            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6684        {
6685            return Err(SourceError::Refused {
6686                message: format!(
6687                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6688                     spells a document, so it would read back as one rather than as a {}; \
6689                     retitle it, or copy it as a document",
6690                    kind.marker(),
6691                    self.name,
6692                    kind.marker()
6693                ),
6694            });
6695        }
6696        // The destination is read by its own id, and whether this board holds it is decided
6697        // by that read — its own `projectItems` — rather than by whether a listing of the
6698        // board happens to include it yet. See the module documentation.
6699        let existing = match target {
6700            Some(target) => {
6701                Some(
6702                    self.bound_item(target)
6703                        .await?
6704                        .ok_or_else(|| SourceError::Refused {
6705                            message: format!("GitHub destination item {} was not found", target.0),
6706                        })?,
6707                )
6708            }
6709            None => None,
6710        };
6711        let existing = existing.as_ref();
6712        // An existing issue is never moved; a new one is created where the rule says — and
6713        // knowing where is what lets the board's fields and that repository's id be read
6714        // together, before anything below needs either.
6715        let creation_target = match existing {
6716            Some(_) => None,
6717            None => {
6718                let target = self.creation_target(incoming).await?;
6719                self.creation_context(&target, incoming).await?;
6720                Some(target)
6721            }
6722        };
6723        let board = self
6724            .fields_for(
6725                existing,
6726                incoming.written.status().is_some(),
6727                incoming
6728                    .priority
6729                    .is_some_and(|priority| priority != Priority::None),
6730            )
6731            .await?;
6732        let status_target = incoming
6733            .written
6734            .status()
6735            .map(|status| self.resolved_target(status.category))
6736            .transpose()?;
6737        let column = match (incoming.written.status(), status_target.as_ref()) {
6738            (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6739            _ => None,
6740        };
6741        // Resolved before anything is created, for the reason the column above is: a
6742        // priority this board has no option for is refused while nothing has been written.
6743        let priority_write = match incoming.priority {
6744            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6745            None => None,
6746        };
6747        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6748        if content_kind == ContentKind::DraftIssue {
6749            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6750                (status_target.as_ref(), incoming.written.status())
6751            {
6752                return Err(self.closes_a_draft(status.category));
6753            }
6754            if incoming.parent.is_some() {
6755                return Err(SourceError::Refused {
6756                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6757                });
6758            }
6759        }
6760        match existing {
6761            Some(item) if content_kind == ContentKind::Issue => {
6762                if item.labels != incoming.labels {
6763                    return Err(SourceError::Refused {
6764                        message: "GitHub issue labels differ from the labels being written".into(),
6765                    });
6766                }
6767            }
6768            _ => {
6769                if !incoming.labels.is_empty() {
6770                    return Err(SourceError::Refused {
6771                        message: "GitHub items created by this destination carry no labels".into(),
6772                    });
6773                }
6774            }
6775        }
6776
6777        // The repository the issue really lives in is what the slot below is written against,
6778        // so a single entry that is where the issue is created travels as no key at all, and
6779        // the read side derives it back from the issue.
6780        let own_repository = match (existing, &creation_target) {
6781            (Some(item), _) => item.own_repository.clone(),
6782            (None, Some(target)) => Some(
6783                Repository::try_from(target.origin())
6784                    .map_err(|message| SourceError::Config { message })?,
6785            ),
6786            (None, None) => None,
6787        };
6788        let (native, fallback) = self
6789            .partition_edges(
6790                incoming.written.kind(),
6791                content_kind,
6792                existing.and_then(|item| item.blocked_by.as_deref()),
6793                depends_on,
6794            )
6795            .await?;
6796        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6797        let body = compose_body(incoming.content, &slot)?;
6798        // Read before anything is created, for the reason the field below is: a value
6799        // this destination cannot store has to refuse, and refusing after `createIssue`
6800        // would leave an issue behind that nothing asked for. The engine writes a
6801        // qualified id here; a caller handing this key anything else is told so rather
6802        // than having it silently stored as no origin at all.
6803        // 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.
6804        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6805            None => "",
6806            Some(Value::String(origin)) => origin.as_str(),
6807            Some(other) => {
6808                return Err(SourceError::Refused {
6809                    message: format!(
6810                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6811                         is {other}"
6812                    ),
6813                });
6814            }
6815        };
6816        // Resolved before anything is created: a board that cannot carry the copy origin
6817        // has to refuse the write, and refusing it after `createIssue` would leave an
6818        // issue behind that nothing asked for.
6819        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6820            Some(field) => {
6821                if required_str(field, "__typename")? != "ProjectV2Field" {
6822                    return Err(SourceError::Refused {
6823                        message: format!(
6824                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6825                        ),
6826                    });
6827                }
6828                Some(required_str(field, "id")?.to_owned())
6829            }
6830            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6831                return Err(SourceError::Refused {
6832                    message: format!(
6833                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6834                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6835                         the board"
6836                    ),
6837                });
6838            }
6839            None => None,
6840        };
6841
6842        let Landed {
6843            content_id,
6844            item_id,
6845            url,
6846            number,
6847        } = match existing {
6848            // Its content is written last, below, once everything else has landed.
6849            Some(item) => Landed {
6850                content_id: item.id.clone(),
6851                item_id: item.item_id.clone(),
6852                url: item.url.clone(),
6853                number: item.number,
6854            },
6855            None => {
6856                let target = creation_target
6857                    .as_ref()
6858                    .ok_or_else(|| SourceError::Malformed {
6859                        message: "a new item was decided without a repository to create it in"
6860                            .into(),
6861                    })?;
6862                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6863                    .await?
6864            }
6865        };
6866
6867        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6868        let column = column
6869            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6870            .map(|(field, option, _)| (field, option));
6871        // Creating an item here is several calls — `createIssue`, which files it on the
6872        // board, then its board fields, the parent and the dependencies — and GitHub can fail
6873        // at any of them. Everything this source can refuse *before* the first of those is
6874        // already checked above, so what is left is GitHub itself failing part way. When it
6875        // does over an item this call created, the issue is taken back: a write that
6876        // refused must not leave an item behind that nobody asked for, and one that does
6877        // makes the retry create a second.
6878        // Whether the board-field write carrying a moved origin was answered as landing whole.
6879        // When it was refused, GitHub does not say which of its fields ran before the one that
6880        // failed, so the origin may or may not have moved.
6881        let mut origin_landed = false;
6882        let landed = self
6883            .finish_write(
6884                board.id.as_str(),
6885                incoming,
6886                &content_id,
6887                &item_id,
6888                content_kind,
6889                existing,
6890                origin_field.as_deref(),
6891                origin,
6892                column,
6893                status_target.as_ref(),
6894                priority_write.as_ref(),
6895                &native,
6896                &mut origin_landed,
6897            )
6898            .await;
6899        // An existing item's title, body and state go last, in one `updateIssue`, once its board
6900        // fields and its relationships have landed: a refusal of any of those then leaves its
6901        // body — and the metadata slot inside it — exactly as it stood.
6902        let landed = match (landed, existing) {
6903            (Ok(()), Some(item)) => {
6904                self.update_existing(item, incoming, &body, status_target.as_ref())
6905                    .await
6906            }
6907            (landed, _) => landed,
6908        };
6909        if let Err(error) = landed {
6910            match existing {
6911                // Best effort, and the write's own failure is what the caller is told: a
6912                // refusal naming the tidy-up would hide why the write failed at all.
6913                None => {
6914                    let _ = self.delete_issue(&content_id).await;
6915                }
6916                // The origin field is the one piece of an existing item's metadata written
6917                // before its body, so a write refused after it puts it back as it was. When
6918                // that is refused too, the write's own failure is still what the caller is
6919                // told — with what it left behind added, because the item's metadata is then
6920                // not as it stood and a caller retrying has to know which key moved.
6921                Some(item) => {
6922                    let before = item.origin.as_deref().unwrap_or("");
6923                    if let Some(field) = origin_field.as_deref()
6924                        && before != origin
6925                        && let Err(restore) = self
6926                            .set_item_field(
6927                                board.id.as_str(),
6928                                &item.item_id,
6929                                field,
6930                                json!({"text": before}),
6931                            )
6932                            .await
6933                    {
6934                        let left = if origin_landed {
6935                            format!(
6936                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
6937                                 not be put back to {before:?} ({restore}), so item {} still \
6938                                 holds {origin:?} there",
6939                                item.id.0
6940                            )
6941                        } else {
6942                            format!(
6943                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
6944                                 {origin:?}, GitHub does not say whether that part of it ran, \
6945                                 and putting it back to {before:?} was refused ({restore}), so \
6946                                 item {} holds {origin:?} or {before:?} there",
6947                                item.id.0
6948                            )
6949                        };
6950                        return Err(noting(
6951                            error,
6952                            &format!(
6953                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
6954                                 run the write again"
6955                            ),
6956                        ));
6957                    }
6958                }
6959            }
6960            return Err(error);
6961        }
6962
6963        let written_status = match (incoming.written.status(), status_target.as_ref()) {
6964            (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6965                self.statuses
6966                    .status(written_option.as_deref(), true, Some(reason.reason()))
6967            }
6968            (Some(_), Some(StatusTarget::Column(_))) => {
6969                self.statuses.status(written_option.as_deref(), false, None)
6970            }
6971            (Some(status), _) => status.clone(),
6972            (None, _) => Status {
6973                category: StatusCategory::Unknown,
6974                name: "Open".to_owned(),
6975            },
6976        };
6977
6978        // So the rest of this command reads what it just did rather than what the board
6979        // said before it. See `remember_written` for which half takes it.
6980        let remembered = Resolved {
6981            item_id,
6982            id: content_id.clone(),
6983            content_kind,
6984            kind: incoming.written.kind(),
6985            title: incoming.title.to_owned(),
6986            // The visible half of the body this write composed, split back off it the
6987            // way a read splits it — so what this record reports is what a read of the
6988            // same issue reports, rather than the person's text with the metadata slot
6989            // still on the end of it.
6990            body: metadata_body(body.clone())?.0,
6991            raw_body: body.clone(),
6992            // A document has no status of its own; what it reads back as is whatever
6993            // the issue's own state says, which is what a re-read reports.
6994            status: written_status,
6995            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6996            priority: match incoming.priority {
6997                Some(priority) => HeldPriority::Read(priority),
6998                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6999                    item.priority.clone()
7000                }),
7001            },
7002            // What `state_input` asked for: closed for a terminal target, open for any other
7003            // status, and the issue's own state left as it was by a document write.
7004            closed: content_kind == ContentKind::Issue
7005                && match status_target.as_ref() {
7006                    Some(StatusTarget::Terminal(_, _)) => true,
7007                    Some(_) => false,
7008                    None => existing.is_some_and(|item| item.closed),
7009                },
7010            delivers: incoming.delivers.to_vec(),
7011            delivered_by: incoming.delivered_by.to_vec(),
7012            labels: incoming.labels.to_vec(),
7013            parent: incoming.parent.cloned(),
7014            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7015            number,
7016            // In the update path this is the item's own url, read off `existing` where the
7017            // record above was bound, so one expression serves both halves.
7018            url,
7019            created_at: existing.and_then(|item| item.created_at),
7020            updated_at: existing.and_then(|item| item.updated_at),
7021            own_repository,
7022            repositories: incoming.repositories.to_vec(),
7023            slot,
7024            board_id: Some(board.id.as_str().to_owned()),
7025            fields: board
7026                .fields
7027                .get("nodes")
7028                .and_then(Value::as_array)
7029                .cloned()
7030                .unwrap_or_default(),
7031            board_fields: Some(board.fields.clone()),
7032            // What this write left the relationship holding is known by id alone, and a
7033            // later read of its edges needs each far end's kind, so it reads them again.
7034            blocked_by: None,
7035        };
7036        self.remember_written(remembered, existing.is_none())?;
7037        Ok(content_id)
7038    }
7039
7040    /// Everything a write does after the item exists: its board fields, its parent, and
7041    /// its dependencies.
7042    ///
7043    /// Split out of `write_item` so there is one place a failure past the point of no
7044    /// return is caught, rather than a tidy-up repeated at each `?` above.
7045    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7046    // so there is one place a failure past the point of no return is caught, and its
7047    // arguments are exactly the values that tail already had in scope. Bundling them into a
7048    // struct would describe no concept — it would be "the arguments of this function" — and
7049    // would put the whole of `write_item`'s locals behind one more indirection.
7050    #[allow(clippy::too_many_arguments)]
7051    async fn finish_write(
7052        &self,
7053        board_id: &str,
7054        incoming: &Incoming<'_>,
7055        content_id: &NativeId,
7056        item_id: &str,
7057        content_kind: ContentKind,
7058        existing: Option<&Resolved>,
7059        origin_field: Option<&str>,
7060        origin: &str,
7061        column: Option<(String, String)>,
7062        status_target: Option<&StatusTarget>,
7063        priority: Option<&PriorityWrite>,
7064        native: &[String],
7065        origin_landed: &mut bool,
7066    ) -> Result<(), SourceError> {
7067        let mut fields = Vec::new();
7068        if let Some(field_id) = origin_field
7069            && existing.map_or(!origin.is_empty(), |item| {
7070                item.origin.as_deref().unwrap_or("") != origin
7071            })
7072        {
7073            fields.push((field_id.to_owned(), json!({"text":origin})));
7074        }
7075        if let Some((field_id, option_id)) = column {
7076            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7077        }
7078        let clear = match priority {
7079            Some(PriorityWrite::Select { field, option }) => {
7080                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7081                None
7082            }
7083            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7084            None => None,
7085        };
7086        self.set_item_fields(board_id, item_id, &fields, clear)
7087            .await?;
7088        *origin_landed = true;
7089
7090        // An existing issue closes in the `updateIssue` its write ends with; one created just
7091        // now closes here, once its option is selected.
7092        if existing.is_none()
7093            && content_kind == ContentKind::Issue
7094            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7095        {
7096            self.update_content(
7097                ContentKind::Issue,
7098                content_id,
7099                json!({"stateInput":state_input(status_target)}),
7100            )
7101            .await?;
7102        }
7103
7104        if content_kind == ContentKind::Issue {
7105            self.reparent(
7106                existing.and_then(|item| item.parent.clone()),
7107                content_id,
7108                incoming.parent,
7109            )
7110            .await?;
7111            // A document takes part in no dependency graph, so writing one neither reads
7112            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7113            // against the empty list a document write carries would *delete* whatever
7114            // relationships a person had made on that issue, which is a write nobody
7115            // asked for.
7116            if incoming.written.kind() != BoardKind::Document {
7117                let issue = match existing {
7118                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7119                    None => Issue::Created,
7120                };
7121                self.reconcile_blocked_by(content_id, native, issue).await?;
7122            }
7123        }
7124        Ok(())
7125    }
7126
7127    /// Delete one issue, which takes its board item with it.
7128    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7129        let data = self
7130            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7131            .await?;
7132        data.pointer("/deleteIssue/repository")
7133            .filter(|value| !value.is_null())
7134            .ok_or_else(|| SourceError::Malformed {
7135                message: "GitHub issue deletion returned no repository".into(),
7136            })?;
7137        self.forget(id)?;
7138        Ok(())
7139    }
7140
7141    /// Remove one item this copy created, so a copy that could not finish leaves the board
7142    /// as it found it.
7143    ///
7144    /// Deleting the issue takes its board item with it, so there is no second mutation to
7145    /// keep in step. An id the board does not hold is not an error: the item is already
7146    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7147    /// by its own id — a listing of the board can still be missing an item it holds, and
7148    /// reading that as *already gone* would leave behind the very item this was asked to
7149    /// take back.
7150    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7151        let Some(item) = self.bound_item(id).await? else {
7152            return Ok(());
7153        };
7154        if item.content_kind == ContentKind::DraftIssue {
7155            return Err(SourceError::Refused {
7156                message: format!(
7157                    "GitHub item {} is a draft, and this source removes an item by deleting \
7158                     its issue; next: remove it from the board by hand",
7159                    id.0
7160                ),
7161            });
7162        }
7163        let data = self
7164            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7165            .await?;
7166        data.pointer("/deleteIssue/repository")
7167            .filter(|value| !value.is_null())
7168            .ok_or_else(|| SourceError::Malformed {
7169                message: "GitHub issue deletion returned no repository".into(),
7170            })?;
7171        self.forget(id)?;
7172        Ok(())
7173    }
7174
7175    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7176    /// task.
7177    ///
7178    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7179    /// read of the task cannot disagree about which ids name one: a project or a document of
7180    /// this board is not a task here either.
7181    ///
7182    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7183    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7184    /// which would read as a task nobody has commented on yet.
7185    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7186        let cached = self.resolved_cache()?.get(task).cloned();
7187        let Some(item) = (match cached {
7188            Some(item) => Some(item),
7189            None => self.item_by_id(task).await?,
7190        })
7191        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7192            return Ok(None);
7193        };
7194        if item.content_kind == ContentKind::DraftIssue {
7195            return Err(self.draft_has_no_comments(task));
7196        }
7197        Ok(Some(item.id))
7198    }
7199
7200    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7201    /// issues, and a draft is not one.
7202    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7203        SourceError::Refused {
7204            message: format!(
7205                "task {} of source {} is a draft item on the board, and GitHub keeps \
7206                 comments on issues alone, so a draft has none to read or write; next: \
7207                 convert the draft to an issue on the board, then comment on the issue it \
7208                 becomes",
7209                task.0, self.name
7210            ),
7211        }
7212    }
7213
7214    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7215    /// request — or `None` when this board holds no task by that id.
7216    ///
7217    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7218    /// is answered with the draft and the refusal, at the price of the draft's own read.
7219    async fn issue_detail(
7220        &self,
7221        id: &NativeId,
7222        page: &PageRequest,
7223    ) -> Result<Option<TaskDetailRead>, SourceError> {
7224        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7225        let asked = self
7226            .graphql(
7227                graphql::ISSUE_DETAIL,
7228                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7229                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7230                       "duplicates":true}),
7231            )
7232            .await;
7233        let data = match asked {
7234            Ok(data) => data,
7235            Err(error) if unresolvable_node(&error) => return Ok(None),
7236            Err(error) => return Err(error),
7237        };
7238        // `node` is null for an id that names nothing, and absent only from an answer this
7239        // source cannot read — never the same thing.
7240        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7241            message: format!("GitHub answered the read of {} with no node", id.0),
7242        })?;
7243        self.detail_of(id, node, true, after).await
7244    }
7245
7246    /// Several tasks, each with the first page of its comments when `comments` is set, read
7247    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7248    /// order.
7249    ///
7250    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7251    /// one item at a time, so that id is answered as missing and the others as themselves; any
7252    /// other refusal is every id of that batch's answer.
7253    async fn issue_details(
7254        &self,
7255        ids: &[NativeId],
7256        comments: Option<&PageRequest>,
7257    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7258        let mut read = Vec::with_capacity(ids.len());
7259        for batch in ids.chunks(DETAIL_BATCH) {
7260            match self
7261                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7262                .await
7263            {
7264                Ok(data) => {
7265                    for (slot, id) in batch.iter().enumerate() {
7266                        // Every alias asked for is answered, null for an id naming nothing;
7267                        // one missing is an answer this source cannot read.
7268                        let read_one = match data.get(format!("i{slot}")) {
7269                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7270                            None => Err(SourceError::Malformed {
7271                                message: format!(
7272                                    "GitHub answered a batch read with no item for {}",
7273                                    id.0
7274                                ),
7275                            }),
7276                        };
7277                        read.push(read_one);
7278                    }
7279                }
7280                Err(error) if unresolvable_node(&error) => {
7281                    for id in batch {
7282                        read.push(match comments {
7283                            Some(page) => self.issue_detail(id, page).await,
7284                            None => self.task_read(id).await,
7285                        });
7286                    }
7287                }
7288                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7289            }
7290        }
7291        read
7292    }
7293
7294    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7295    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7296        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7297            task,
7298            comments: None,
7299        }))
7300    }
7301
7302    /// What one node a detail read reached says: the task this board holds by `id`, with the
7303    /// page of comments the node carries when `commented` — or `None` for a node that is no
7304    /// task of this board.
7305    ///
7306    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7307    /// and an item this process created answers from this process's own record, which a node
7308    /// read taken moments after the write can still be behind.
7309    async fn detail_of(
7310        &self,
7311        id: &NativeId,
7312        node: &Value,
7313        commented: bool,
7314        after: Option<&str>,
7315    ) -> Result<Option<TaskDetailRead>, SourceError> {
7316        if node.is_null() {
7317            return Ok(None);
7318        }
7319        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7320        // An issue answered under one id is that id's, or the answer is not one this source
7321        // can report: reporting another issue's task and comments under the qualified id asked
7322        // for would be the one wrong answer here. A draft's own read checks the same.
7323        if !draft
7324            && optional_str(node, "__typename")? == Some("Issue")
7325            && required_str(node, "id")? != id.0
7326        {
7327            return Err(SourceError::Malformed {
7328                message: format!(
7329                    "GitHub answered the read of {} with issue {}",
7330                    id.0,
7331                    required_str(node, "id")?
7332                ),
7333            });
7334        }
7335        let item = if draft {
7336            self.draft_by_id(id).await?
7337        } else {
7338            self.resolve_issue(node).await?
7339        };
7340        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7341            return Ok(None);
7342        };
7343        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7344        let task = own.unwrap_or(item).task()?;
7345        let comments = match (commented, draft) {
7346            (false, _) => None,
7347            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7348            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7349        };
7350        Ok(Some(TaskDetailRead { task, comments }))
7351    }
7352
7353    /// Whether the comment `comment` is one of `issue`'s own.
7354    ///
7355    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7356    /// comment's id and nothing else: a comment id given against the wrong task would
7357    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7358    /// names something that is not an issue comment, is a comment this task does not have —
7359    /// which is what GitHub refusing to resolve it means too.
7360    async fn comment_is_on(
7361        &self,
7362        issue: &NativeId,
7363        comment: &NativeId,
7364    ) -> Result<bool, SourceError> {
7365        let asked = self
7366            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7367            .await;
7368        let data = match asked {
7369            Ok(data) => data,
7370            Err(error) if unresolvable_node(&error) => return Ok(false),
7371            Err(error) => return Err(error),
7372        };
7373        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7374            return Ok(false);
7375        };
7376        if optional_str(node, "__typename")? != Some("IssueComment") {
7377            return Ok(false);
7378        }
7379        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7380            message: format!("GitHub issue comment {} names no issue", comment.0),
7381        })?;
7382        Ok(required_str(on, "id")? == issue.0)
7383    }
7384
7385    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7386    async fn partition_edges(
7387        &self,
7388        near_kind: BoardKind,
7389        near_content: ContentKind,
7390        carried: Option<&[Value]>,
7391        depends_on: &[DependencyEdge],
7392    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7393        let mut native = Vec::new();
7394        let mut fallback = Vec::new();
7395        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7396            .iter()
7397            .map(|edge| {
7398                let same_source = edge
7399                    .to
7400                    .source()
7401                    .is_none_or(|source| source == self.name.as_str());
7402                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7403                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7404                // colons of its own, so the far end is everything after that one separator.
7405                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7406                let far_id = if edge.to.is_qualified() {
7407                    edge.to
7408                        .id()
7409                        .split_once(':')
7410                        .map_or(edge.to.id(), |(_, native)| native)
7411                } else {
7412                    edge.to.id()
7413                };
7414                // One that already blocks the near issue was answered by that issue's own
7415                // read, which carried each of its blockers' kinds — an issue every one — so it
7416                // is not read again.
7417                let blocking = carried.and_then(|nodes| {
7418                    nodes
7419                        .iter()
7420                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7421                });
7422                (edge, far_id, same_source, blocking)
7423            })
7424            .collect();
7425        // Every other same-source far end is read by its own id, exactly as the item it is a
7426        // far end of is: whether this board holds it is that read's answer, never a listing's.
7427        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7428        let mut unread: Vec<NativeId> = Vec::new();
7429        for (_, far_id, same_source, blocking) in &far_ends {
7430            let id = NativeId((*far_id).to_owned());
7431            if *same_source && blocking.is_none() && !unread.contains(&id) {
7432                unread.push(id);
7433            }
7434        }
7435        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7436            .iter()
7437            .cloned()
7438            .zip(self.items_by_ids(&unread).await?)
7439            .collect();
7440        for (edge, far_id, same_source, blocking) in far_ends {
7441            let far = match (same_source, blocking) {
7442                (false, _) => None,
7443                (true, Some(node)) => Some(FarEnd {
7444                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7445                        BoardKind::Document
7446                    } else {
7447                        BoardKind::Work(related_kind(node)?)
7448                    },
7449                    content_kind: ContentKind::Issue,
7450                }),
7451                (true, None) => {
7452                    let read = read
7453                        .get(&NativeId(far_id.to_owned()))
7454                        .cloned()
7455                        .flatten()
7456                        .ok_or_else(|| SourceError::Refused {
7457                            message: format!("GitHub dependency item {far_id} was not found"),
7458                        })?;
7459                    Some(FarEnd {
7460                        kind: read.kind,
7461                        content_kind: read.content_kind,
7462                    })
7463                }
7464            };
7465            let far = far.as_ref();
7466            // The caller says which kind the far end is, and this board holds the far end
7467            // itself, so a disagreement is settled here rather than stored: recorded, the
7468            // wrong kind would read back as a cross-level edge that never existed; written
7469            // natively, it would name a relationship of a different level than the caller
7470            // asked for.
7471            //
7472            // A far end this board holds as a *document* fails the same comparison and is
7473            // refused by the same sentence: `ItemKind` has no document variant because
7474            // nothing may point at one, so no caller can name it correctly and the refusal
7475            // is the only honest answer.
7476            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7477                return Err(SourceError::Refused {
7478                    message: format!(
7479                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7480                         names it as a {}; record the kind it is",
7481                        disagreeing.kind.describes(),
7482                        edge.to.kind.marker()
7483                    ),
7484                });
7485            }
7486            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7487            // however the far end is spelled — and one classified native here would be
7488            // written nowhere at all, because a draft's native reconciliation never runs.
7489            let native_here = near_content == ContentKind::Issue
7490                && far.is_some_and(|far| {
7491                    far.content_kind == ContentKind::Issue
7492                        && BoardKind::Work(edge.to.kind) == near_kind
7493                });
7494            if native_here {
7495                native.push(far_id.to_owned());
7496            } else {
7497                fallback.push(edge.clone());
7498            }
7499        }
7500        Ok((native, fallback))
7501    }
7502
7503    async fn update_existing(
7504        &self,
7505        item: &Resolved,
7506        incoming: &Incoming<'_>,
7507        body: &Option<String>,
7508        status_target: Option<&StatusTarget>,
7509    ) -> Result<(), SourceError> {
7510        let title = incoming.written_title();
7511        // A terminal status closes the issue here, in the same mutation as its body: its board
7512        // option was selected before this, so a close never lands on an item whose board cannot
7513        // show it.
7514        let fields = match item.content_kind {
7515            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7516            ContentKind::Issue => json!({"title":title,"body":body,
7517                                         "stateInput":state_input(status_target)}),
7518        };
7519        self.update_content(item.content_kind, &item.id, fields)
7520            .await
7521    }
7522
7523    /// Update one board item's content with exactly `fields` beside its id, through the
7524    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7525    /// a draft.
7526    ///
7527    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7528    /// is what lets a narrow write carry the one thing it changes and nothing else.
7529    async fn update_content(
7530        &self,
7531        kind: ContentKind,
7532        id: &NativeId,
7533        fields: Value,
7534    ) -> Result<(), SourceError> {
7535        let (operation, id_key, pointer) = match kind {
7536            ContentKind::DraftIssue => (
7537                graphql::UPDATE_DRAFT,
7538                "draftIssueId",
7539                "/updateProjectV2DraftIssue/draftIssue",
7540            ),
7541            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7542        };
7543        let mut input = fields;
7544        input[id_key] = json!(id.0);
7545        let data = self.graphql(operation, json!({"input":input})).await?;
7546        let returned = data
7547            .pointer(pointer)
7548            .ok_or_else(|| SourceError::Malformed {
7549                message: "GitHub item update returned no item".into(),
7550            })?;
7551        if required_str(returned, "id")? != id.0 {
7552            return Err(SourceError::Malformed {
7553                message: "GitHub item update returned the wrong item".into(),
7554            });
7555        }
7556        Ok(())
7557    }
7558
7559    /// Creates one issue, files it on the board, and reports what a read of it would say:
7560    /// its content id, its board item id, and the web address GitHub gave it.
7561    ///
7562    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7563    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7564    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7565    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7566    /// "Content already exists in this project". A terminal status is not written here:
7567    /// `finish_write` selects its option first and closes the issue after, so a close never
7568    /// lands on an item whose board cannot show it.
7569    ///
7570    /// The address and the number come back here because this is the only place either is
7571    /// known before GitHub's own board read catches up — an item this run created answers
7572    /// the reads that follow it out of the record below, and one remembered without them
7573    /// would report no location and no key for the rest of the run.
7574    async fn create_and_file_issue(
7575        &self,
7576        board_id: &str,
7577        repository: &RepositoryTarget,
7578        incoming: &Incoming<'_>,
7579        body: &Option<String>,
7580    ) -> Result<Landed, SourceError> {
7581        let repository_id = self.repository_id(repository, incoming).await?;
7582        let data = self
7583            .graphql(
7584                graphql::CREATE_ISSUE,
7585                json!({"input":{
7586                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7587                }}),
7588            )
7589            .await?;
7590        let created = data
7591            .pointer("/createIssue/issue")
7592            .filter(|value| !value.is_null())
7593            .ok_or_else(|| SourceError::Malformed {
7594                message: "GitHub issue creation returned no issue".into(),
7595            })?;
7596        let content_id = NativeId(required_str(created, "id")?.to_owned());
7597        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7598        // a response without it is not worth failing a landed write over — the item simply
7599        // reports no location until the board read catches up, which is what it did before.
7600        let url = optional_str(created, "url")?.map(str::to_owned);
7601        // The issue exists from here on, so an unreadable number and a refused board
7602        // filing below each try, best effort, to take it back: an issue in the repository
7603        // that is on no board is an item nobody asked for and nothing here would find again.
7604        //
7605        // Its number is optional on the same terms its address is — a landed write is not
7606        // worth failing over a member that came back missing, and such an item reports no
7607        // handle until a board read catches up. A number that is *present* and is not an
7608        // unsigned integer is still a response this source cannot read.
7609        let number = match created_issue_number(created) {
7610            Ok(number) => number,
7611            Err(error) => {
7612                let _ = self.delete_issue(&content_id).await;
7613                return Err(error);
7614            }
7615        };
7616        let added = match self
7617            .graphql(
7618                graphql::ADD_TO_BOARD,
7619                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7620            )
7621            .await
7622        {
7623            Ok(added) => added,
7624            Err(error) => {
7625                let _ = self.delete_issue(&content_id).await;
7626                return Err(error);
7627            }
7628        };
7629        let item = added
7630            .pointer("/addProjectV2ItemById/item")
7631            .filter(|value| !value.is_null())
7632            .ok_or_else(|| SourceError::Malformed {
7633                message: "GitHub board addition returned no project item".into(),
7634            })?;
7635        Ok(Landed {
7636            content_id,
7637            item_id: required_str(item, "id")?.to_owned(),
7638            url,
7639            number,
7640        })
7641    }
7642
7643    /// Move one issue under the project it now belongs to, or out of the one it left.
7644    async fn reparent(
7645        &self,
7646        held: Option<NativeId>,
7647        child: &NativeId,
7648        wanted: Option<&NativeId>,
7649    ) -> Result<(), SourceError> {
7650        if held.as_ref() == wanted {
7651            return Ok(());
7652        }
7653        if let Some(held) = &held {
7654            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7655                .await?;
7656        }
7657        if let Some(wanted) = wanted {
7658            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7659                .await?;
7660        }
7661        Ok(())
7662    }
7663
7664    async fn sub_issue(
7665        &self,
7666        operation: &str,
7667        parent: &NativeId,
7668        child: &NativeId,
7669        root: &str,
7670    ) -> Result<(), SourceError> {
7671        let data = self
7672            .graphql(
7673                operation,
7674                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7675            )
7676            .await?;
7677        let issue =
7678            data.pointer(&format!("/{root}/issue"))
7679                .ok_or_else(|| SourceError::Malformed {
7680                    message: "GitHub sub-issue update returned no issue".into(),
7681                })?;
7682        let sub =
7683            data.pointer(&format!("/{root}/subIssue"))
7684                .ok_or_else(|| SourceError::Malformed {
7685                    message: "GitHub sub-issue update returned no sub-issue".into(),
7686                })?;
7687        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7688            return Err(SourceError::Malformed {
7689                message: "GitHub sub-issue update returned the wrong issues".into(),
7690            });
7691        }
7692        Ok(())
7693    }
7694
7695    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7696    /// whether there was one.
7697    ///
7698    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7699    /// relationships are not read: there is nothing a read of them could find.
7700    async fn reconcile_blocked_by(
7701        &self,
7702        content_id: &NativeId,
7703        native: &[String],
7704        issue: Issue<'_>,
7705    ) -> Result<bool, SourceError> {
7706        let current = match issue {
7707            Issue::Created => Vec::new(),
7708            Issue::Existing(Some(held)) => held
7709                .iter()
7710                .map(|far| required_str(far, "id").map(str::to_owned))
7711                .collect::<Result<Vec<_>, _>>()?,
7712            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7713        };
7714        let mut changed = false;
7715        for (operation, far_id) in current
7716            .iter()
7717            .filter(|id| !native.contains(id))
7718            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7719            .chain(
7720                native
7721                    .iter()
7722                    .filter(|id| !current.contains(id))
7723                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7724            )
7725        {
7726            let data = self
7727                .graphql(
7728                    operation,
7729                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7730                )
7731                .await?;
7732            let root = if operation == graphql::ADD_BLOCKED_BY {
7733                "addBlockedBy"
7734            } else {
7735                "removeBlockedBy"
7736            };
7737            let issue =
7738                data.pointer(&format!("/{root}/issue"))
7739                    .ok_or_else(|| SourceError::Malformed {
7740                        message: "GitHub dependency update returned no issue".into(),
7741                    })?;
7742            let blocker = data
7743                .pointer(&format!("/{root}/blockingIssue"))
7744                .ok_or_else(|| SourceError::Malformed {
7745                    message: "GitHub dependency update returned no blocking issue".into(),
7746                })?;
7747            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7748            {
7749                return Err(SourceError::Malformed {
7750                    message: "GitHub dependency update returned the wrong issues".into(),
7751                });
7752            }
7753            changed = true;
7754        }
7755        Ok(changed)
7756    }
7757}
7758
7759/// What a write needs to know of one far end it names: which kind of item it is, and whether
7760/// it is an issue a native relationship can name.
7761struct FarEnd {
7762    kind: BoardKind,
7763    content_kind: ContentKind,
7764}
7765
7766/// Whether the issue one write reconciles was created by that write or was already there.
7767#[derive(Clone, Copy, PartialEq, Eq)]
7768enum Issue<'a> {
7769    /// Created by this write, so it holds no relationships yet.
7770    Created,
7771    /// On the board before this write, holding whatever relationships it holds — the far
7772    /// ends of its whole `blockedBy`, when the read that reached it carried them.
7773    Existing(Option<&'a [Value]>),
7774}
7775
7776/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7777enum Reached {
7778    /// An issue this board holds, resolved into everything this source reports about it.
7779    Held(Box<Resolved>),
7780    /// Nothing this board holds: no such node, or a node on some other board.
7781    Nothing,
7782    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7783    /// again by [`GitHubProjectsSource::draft_by_id`].
7784    Draft,
7785}
7786
7787/// What GitHub says when a string is not a node id it can resolve.
7788///
7789/// Matched because it is the ordinary answer to a project selector naming a project by its
7790/// *name*, and reporting that as a failure would make naming one impossible. It is read
7791/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7792/// does not define the syntax of a GitHub node id and would be wrong about it.
7793const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7794
7795/// `error` with `note` added to the end of what it says, its kind and every other member
7796/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7797/// what that failure left behind.
7798fn noting(error: SourceError, note: &str) -> SourceError {
7799    match error {
7800        SourceError::Config { message } => SourceError::Config {
7801            message: message + note,
7802        },
7803        SourceError::Auth { message } => SourceError::Auth {
7804            message: message + note,
7805        },
7806        SourceError::Refused { message } => SourceError::Refused {
7807            message: message + note,
7808        },
7809        SourceError::RateLimited {
7810            retry_after_seconds,
7811            message,
7812        } => SourceError::RateLimited {
7813            retry_after_seconds,
7814            message: Some(message.unwrap_or_default() + note),
7815        },
7816        SourceError::Unavailable { message } => SourceError::Unavailable {
7817            message: message + note,
7818        },
7819        SourceError::Malformed { message } => SourceError::Malformed {
7820            message: message + note,
7821        },
7822    }
7823}
7824
7825/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7826/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7827/// for them.
7828///
7829/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7830/// is read again at no added price.
7831fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7832    let mut variables = serde_json::Map::new();
7833    for slot in 0..DETAIL_BATCH {
7834        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7835        variables.insert(format!("id{slot}"), json!(id));
7836    }
7837    variables.insert(
7838        "first".to_owned(),
7839        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7840    );
7841    variables.insert("comments".to_owned(), json!(comments.is_some()));
7842    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7843    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7844    variables.insert("duplicates".to_owned(), json!(true));
7845    Value::Object(variables)
7846}
7847
7848/// Whether this refusal is GitHub saying the id names no node at all.
7849fn unresolvable_node(error: &SourceError) -> bool {
7850    matches!(error, SourceError::Refused { message }
7851        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7852}
7853
7854/// One project name, as a search qualifier which filters on it at the server.
7855///
7856/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7857/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7858/// the way it documents. A title matched here is still compared for equality afterwards:
7859/// the qualifier narrows what the server sends, and this source decides what it names.
7860fn title_qualifier(name: &str) -> String {
7861    format!("in:title {}", quoted(name))
7862}
7863
7864/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7865/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7866/// it documents — so a value holding a qualifier's spelling is searched for rather than
7867/// obeyed.
7868fn quoted(value: &str) -> String {
7869    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7870    format!("\"{escaped}\"")
7871}
7872
7873/// The search qualifier for the issues updated at or after `since`.
7874///
7875/// Written to the second, rounded down, which can only widen what the search returns.
7876fn updated_qualifier(since: DateTime<Utc>) -> String {
7877    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7878}
7879
7880/// The search terms that narrow a board-scoped issue search to a task query's text and
7881/// metadata predicates, or `None` when it carries neither.
7882///
7883/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7884/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7885/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7886/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7887/// a metadata value searches both fields for both — wider than asked, never narrower, and
7888/// every candidate is confirmed in process afterwards.
7889///
7890/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7891/// matches whole tokens where a substring rule would match inside a word, so an item holding
7892/// the text only inside a longer word is not returned. A text of nothing but whitespace
7893/// matches every item, so it narrows nothing and is not sent.
7894fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7895    let text = query
7896        .text
7897        .as_ref()
7898        .filter(|text| !text.terms.trim().is_empty());
7899    if text.is_none() && query.metadata.is_empty() {
7900        return None;
7901    }
7902    let (title, body) = match text.map(|text| text.fields) {
7903        None => (false, true),
7904        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7905        Some(TextFields::Content) => (false, true),
7906        Some(TextFields::TitleOrContent) => (true, true),
7907    };
7908    let fields = match (title, body) {
7909        (true, true) => "in:title,body",
7910        (true, false) => "in:title",
7911        _ => "in:body",
7912    };
7913    let phrases = text
7914        .map(|text| text.terms.clone())
7915        .into_iter()
7916        .chain(
7917            query
7918                .metadata
7919                .iter()
7920                .map(|wanted| as_stored(wanted.value())),
7921        )
7922        .map(|phrase| quoted(&phrase))
7923        .collect::<Vec<_>>();
7924    Some(format!("{fields} {}", phrases.join(" ")))
7925}
7926
7927/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7928/// before anything is asked of GitHub.
7929///
7930/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7931/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7932/// left out, the search is every issue of the board. So this source says it cannot answer
7933/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7934/// nothing GitHub could search for, and keeps the board read it always had.
7935fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7936    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7937                       letter or digit with a bounded query";
7938    if let Some(text) = &query.text
7939        && !text.terms.trim().is_empty()
7940        && !has_words(&text.terms)
7941    {
7942        return Err(SourceError::Refused {
7943            message: format!(
7944                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7945                text.terms
7946            ),
7947        });
7948    }
7949    if let Some(wanted) = query
7950        .metadata
7951        .iter()
7952        .find(|wanted| !has_words(wanted.value()))
7953    {
7954        return Err(SourceError::Refused {
7955            message: format!(
7956                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
7957                wanted.value(),
7958                std::iter::once(wanted.key())
7959                    .chain(wanted.path().iter().map(String::as_str))
7960                    .collect::<Vec<_>>()
7961                    .join("/"),
7962            ),
7963        });
7964    }
7965    Ok(())
7966}
7967
7968/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
7969fn has_words(phrase: &str) -> bool {
7970    phrase.chars().any(char::is_alphanumeric)
7971}
7972
7973/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
7974///
7975/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
7976/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
7977/// which GitHub's word match would read as different words.
7978fn as_stored(value: &str) -> String {
7979    let encoded = Value::String(value.to_owned()).to_string();
7980    encoded[1..encoded.len() - 1].to_owned()
7981}
7982
7983/// The one narrower question a task query carrying a text, metadata or origin predicate is
7984/// sent as.
7985enum Narrowing {
7986    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
7987    Origin(String),
7988    /// The board-scoped issue search narrowed by these qualifiers.
7989    Search(String),
7990}
7991
7992impl Narrowing {
7993    /// What this question is remembered under for the length of one command.
7994    fn key(&self) -> String {
7995        match self {
7996            Self::Origin(origin) => format!("origin {origin}"),
7997            Self::Search(also) => format!("search {also}"),
7998        }
7999    }
8000}
8001
8002/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8003enum Resumed {
8004    /// It reported another page, which starts after this cursor.
8005    More(String),
8006    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8007    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8008    /// can go on walking the other connection.
8009    Ended(Option<String>),
8010}
8011
8012impl Resumed {
8013    /// Whether the connection has another page.
8014    const fn has_more(&self) -> bool {
8015        matches!(self, Self::More(_))
8016    }
8017
8018    /// The cursor to send this connection next.
8019    fn cursor(self) -> Option<String> {
8020        match self {
8021            Self::More(next) => Some(next),
8022            Self::Ended(last) => last,
8023        }
8024    }
8025}
8026
8027/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8028/// with no cursor to it, or from a cursor that does not advance.
8029fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8030    let info = connection
8031        .get("pageInfo")
8032        .ok_or_else(|| SourceError::Malformed {
8033            message: "GitHub connection has no pageInfo".into(),
8034        })?;
8035    let end = optional_str(info, "endCursor")?;
8036    if required_bool(info, "hasNextPage")? {
8037        let next = end.ok_or_else(|| SourceError::Malformed {
8038            message: "GitHub connection reports another page and no endCursor".into(),
8039        })?;
8040        validate_cursor_progress(after, next)?;
8041        return Ok(Resumed::More(next.to_owned()));
8042    }
8043    Ok(Resumed::Ended(
8044        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8045    ))
8046}
8047
8048/// The board, and every item on it this source reports.
8049#[derive(Clone)]
8050struct Board {
8051    id: String,
8052    fields: Value,
8053    items: Vec<Resolved>,
8054}
8055
8056/// What a write needs of the board and nothing more: its node id and its field
8057/// definitions, in the shape a read of the board's own `fields` gives them.
8058///
8059/// Deliberately no items. A write decides which item it writes, which parent it files
8060/// under and which far ends it names by reading each of them by its own id; this is the
8061/// half of the board those reads cannot carry, and holding no item is what keeps it from
8062/// ever being asked whether an item is there.
8063#[derive(Clone)]
8064struct BoardFields {
8065    id: BoardId,
8066    fields: Value,
8067}
8068
8069/// A board's node id: what a field write and `addProjectV2ItemById` address.
8070///
8071/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8072/// refused where it is read, and one an item names blank is read as not named at all.
8073#[derive(Clone)]
8074struct BoardId(String);
8075
8076/// Where one write left its item, for the record the rest of the command reads it out of.
8077///
8078/// A named record rather than a tuple because the update arm and the create arm each fill
8079/// all four, and two `Option`s of different meaning side by side in a tuple are two
8080/// positions a reader has to count.
8081struct Landed {
8082    /// The issue's own node id, which is the [`NativeId`] this source reports.
8083    content_id: NativeId,
8084    /// The board item's id, which is what a field write addresses.
8085    // 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.
8086    item_id: String,
8087    /// The web address GitHub gave the issue, when it gave one.
8088    // 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.
8089    url: Option<String>,
8090    /// The issue's number on its repository, when GitHub reported one.
8091    number: Option<u64>,
8092}
8093
8094impl BoardId {
8095    fn parse(id: &str) -> Result<Self, SourceError> {
8096        if id.trim().is_empty() {
8097            return Err(SourceError::Malformed {
8098                message: "GitHub named a board with a blank node id".into(),
8099            });
8100        }
8101        Ok(Self(id.to_owned()))
8102    }
8103
8104    fn as_str(&self) -> &str {
8105        &self.0
8106    }
8107}
8108
8109impl Board {
8110    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8111        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8112        let nodes = fields
8113            .get("nodes")
8114            .and_then(Value::as_array)
8115            .ok_or_else(|| SourceError::Malformed {
8116                message: "GitHub project fields.nodes is not an array".into(),
8117            })?;
8118        Ok(nodes
8119            .iter()
8120            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8121    }
8122}
8123
8124/// One board item, resolved into everything this source reports about it.
8125#[derive(Clone)]
8126struct Resolved {
8127    item_id: String,
8128    id: NativeId,
8129    content_kind: ContentKind,
8130    kind: BoardKind,
8131    title: String,
8132    body: Option<String>,
8133    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8134    /// that changes the slot alone has to keep byte for byte outside it.
8135    raw_body: Option<String>,
8136    status: Status,
8137    /// The name of the board `Status` option this item sits in, as the board spells it.
8138    option: Option<String>,
8139    /// What its `Priority` field says, read through this instance's mapping.
8140    priority: HeldPriority,
8141    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8142    closed: bool,
8143    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8144    delivers: Vec<TaskRef>,
8145    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8146    /// task.
8147    delivered_by: Vec<TaskRef>,
8148    labels: Vec<Label>,
8149    parent: Option<NativeId>,
8150    // 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.
8151    origin: Option<String>,
8152    /// The issue's own number on its repository, as GitHub reports it.
8153    ///
8154    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8155    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8156    /// an issue this run created whose creating mutation answered without one, which is a
8157    /// response GitHub's own schema says cannot happen and which a landed write is not
8158    /// worth failing over. An `Issue` read off the board always has one.
8159    number: Option<u64>,
8160    url: Option<String>,
8161    created_at: Option<DateTime<Utc>>,
8162    updated_at: Option<DateTime<Utc>>,
8163    own_repository: Option<Repository>,
8164    repositories: Vec<Repository>,
8165    slot: BTreeMap<String, Value>,
8166    /// The node id of the board this item sits on, when the read that reached it said.
8167    board_id: Option<String>,
8168    /// The definition of every board field this item holds a value of, in the shape a read
8169    /// of the board's own `fields` gives one.
8170    ///
8171    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8172    /// which says nothing about whether the board has it.
8173    fields: Vec<Value>,
8174    /// Every field the board this item sits on defines, as its own read of the board's
8175    /// `fields` gives them — when the read that reached the item carried them, which a read
8176    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8177    /// of the board.
8178    board_fields: Option<Value>,
8179    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8180    /// selects one — when the read that reached it carried the connection to its end, which a
8181    /// read of it by its own id does for any issue blocked by no more than a page. What a
8182    /// write reconciles that relationship against, and what a read of its forward edges in
8183    /// the same command answers with.
8184    blocked_by: Option<Vec<Value>>,
8185}
8186
8187impl Resolved {
8188    /// The board this item's own read names it on, when that read named one this source can
8189    /// address.
8190    fn named_board(&self) -> Option<BoardId> {
8191        self.board_id
8192            .as_deref()
8193            .and_then(|id| BoardId::parse(id).ok())
8194    }
8195
8196    /// The board's id and every field it defines, when the read that reached this item
8197    /// carried both — which a read of it by its own id does.
8198    fn carried_board(&self) -> Option<BoardFields> {
8199        Some(BoardFields {
8200            id: self.named_board()?,
8201            fields: self.board_fields.clone()?,
8202        })
8203    }
8204
8205    /// Whether this item holds a value of the board field called `name`, and so carries
8206    /// that field's definition. `false` says nothing about whether the board has the field.
8207    fn defines(&self, name: &str) -> bool {
8208        self.fields
8209            .iter()
8210            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8211    }
8212
8213    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8214    /// in a field of its own, and none of the five keys that are only an encoding.
8215    ///
8216    /// The two delivery keys are left out for every kind, not only for a task: they are
8217    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8218    /// document carrying one holds nothing a caller's own metadata could mean by it.
8219    fn metadata(&self) -> BTreeMap<String, Value> {
8220        let mut metadata = self.slot.clone();
8221        metadata.remove(Repository::METADATA_KEY);
8222        metadata.remove(DependencyEdge::RECORDED_KEY);
8223        metadata.remove(ItemKind::METADATA_KEY);
8224        metadata.remove(TaskRef::DELIVERS_KEY);
8225        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8226        // The board field is the origin, and the body's copy of it is only a mirror for the
8227        // issue search to find: an item whose field holds none has none, whatever its body
8228        // says, so no reader ever sees two answers.
8229        metadata.remove(ORIGIN_KEY);
8230        if let Some(origin) = &self.origin {
8231            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8232        }
8233        metadata
8234    }
8235
8236    /// Where this item is, as a link a reader can open.
8237    ///
8238    /// A board is a hosted place and every issue on it has a web address, so that address
8239    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8240    /// of place it is, so a reader knows to open it rather than to read a file out. It
8241    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8242    /// reported before, and this says what that address *is*.
8243    ///
8244    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8245    /// rather than a third variant, which is the contract's "the source did not say". An
8246    /// issue this run created is not one of those: its address comes back from the
8247    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8248    /// rather than from whenever the board read catches up.
8249    fn location(&self) -> Option<Location> {
8250        self.url.clone().map(Location::Url)
8251    }
8252
8253    /// The short handle this board's backend shows people for a task: the issue's number
8254    /// alone, as a decimal string.
8255    ///
8256    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8257    /// value for this backend. A draft has no number and so no handle, which is the
8258    /// contract's *absent* rather than a handle of some other shape — and the native
8259    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8260    /// derives from.
8261    fn key(&self) -> Option<String> {
8262        self.number.map(|number| number.to_string())
8263    }
8264
8265    /// Whether its `Priority` field holds a value at all, mapped or not.
8266    fn holds_priority(&self) -> bool {
8267        self.priority != HeldPriority::Read(Priority::None)
8268    }
8269
8270    /// The task this item is.
8271    ///
8272    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8273    /// reading that as a level would be a guess, and reading it as `none` would let the next
8274    /// copy clear a priority a person set.
8275    fn task(&self) -> Result<Task, SourceError> {
8276        let priority = match &self.priority {
8277            HeldPriority::Read(priority) => *priority,
8278            HeldPriority::Unmapped(option) => {
8279                return Err(SourceError::Malformed {
8280                    message: format!(
8281                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8282                         this source's priority_mapping does not name, so its priority cannot be \
8283                         read; next: name {option:?} under priority_mapping, or move the item to \
8284                         a mapped option",
8285                        self.id,
8286                        self.number
8287                            .map(|number| format!(" (#{number})"))
8288                            .unwrap_or_default()
8289                    ),
8290                });
8291            }
8292        };
8293        Ok(Task {
8294            id: self.id.clone(),
8295            key: self.key(),
8296            title: self.title.clone(),
8297            content: self.body.clone(),
8298            status: self.status.clone(),
8299            priority,
8300            labels: self.labels.clone(),
8301            project: self.parent.clone(),
8302            url: self.url.clone(),
8303            location: self.location(),
8304            created_at: self.created_at,
8305            updated_at: self.updated_at,
8306            metadata: self.metadata(),
8307            repositories: self.repositories.clone(),
8308            delivers: self.delivers.clone(),
8309            delivered_by: self.delivered_by.clone(),
8310        })
8311    }
8312
8313    fn project(&self) -> Project {
8314        Project {
8315            id: self.id.clone(),
8316            title: self.title.clone(),
8317            content: self.body.clone(),
8318            status: self.status.clone(),
8319            labels: self.labels.clone(),
8320            url: self.url.clone(),
8321            location: self.location(),
8322            created_at: self.created_at,
8323            updated_at: self.updated_at,
8324            metadata: self.metadata(),
8325            repositories: self.repositories.clone(),
8326        }
8327    }
8328
8329    /// The same issue as a document: the project it is filed under, and no status and no
8330    /// dependencies, because a document is not work.
8331    fn document(&self) -> Document {
8332        Document {
8333            id: self.id.clone(),
8334            title: self.title.clone(),
8335            content: self.body.clone(),
8336            project: self.parent.clone(),
8337            labels: self.labels.clone(),
8338            url: self.url.clone(),
8339            location: self.location(),
8340            created_at: self.created_at,
8341            updated_at: self.updated_at,
8342            metadata: self.metadata(),
8343            repositories: self.repositories.clone(),
8344        }
8345    }
8346}
8347
8348/// Where one targeted update moves an item's status, and which of its two halves move.
8349struct StatusMove {
8350    /// The board the item's `Status` field is on.
8351    board: BoardId,
8352    /// The `Status` field's id.
8353    field: String,
8354    /// The option's id.
8355    option: String,
8356    /// The option's name, as the board spells it.
8357    name: String,
8358    /// What the status asks of the issue's state.
8359    target: StatusTarget,
8360    /// The status the item reads as once it is there.
8361    landed: Status,
8362    /// Which of the status's two halves differ from what the item holds.
8363    moves: Moves,
8364}
8365
8366/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8367/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8368/// and is not a value of this type.
8369#[derive(Clone, Copy, PartialEq, Eq)]
8370enum Moves {
8371    /// The option alone.
8372    Option,
8373    /// The issue's state alone: open, closed, or closed with another reason.
8374    State,
8375    /// Both.
8376    Both,
8377}
8378
8379impl Moves {
8380    /// What differs, or `None` when nothing does.
8381    const fn of(option: bool, state: bool) -> Option<Self> {
8382        match (option, state) {
8383            (true, true) => Some(Self::Both),
8384            (true, false) => Some(Self::Option),
8385            (false, true) => Some(Self::State),
8386            (false, false) => None,
8387        }
8388    }
8389
8390    /// Whether the option moves.
8391    const fn option(self) -> bool {
8392        matches!(self, Self::Option | Self::Both)
8393    }
8394
8395    /// Whether the issue's state moves.
8396    const fn state(self) -> bool {
8397        matches!(self, Self::State | Self::Both)
8398    }
8399}
8400
8401/// What one write is, and the status that comes with being it.
8402///
8403/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8404/// status and a task or a project always has one, so "a document carrying a status" and
8405/// "a task carrying none" are states a write cannot be in rather than states every use
8406/// site below has to defend against.
8407enum Written<'a> {
8408    /// A document, which is not work and so has no status at all.
8409    Document,
8410    /// A task or a project, and the status it is being written with.
8411    Work(ItemKind, &'a Status),
8412}
8413
8414impl Written<'_> {
8415    /// Which of the board's three kinds this write is.
8416    const fn kind(&self) -> BoardKind {
8417        match self {
8418            Self::Document => BoardKind::Document,
8419            Self::Work(kind, _) => BoardKind::Work(*kind),
8420        }
8421    }
8422
8423    /// The status this write carries. A document carries none, so a write of one says
8424    /// nothing about the issue's open or closed state and selects no board `Status`
8425    /// option.
8426    const fn status(&self) -> Option<&Status> {
8427        match self {
8428            Self::Document => None,
8429            Self::Work(_, status) => Some(status),
8430        }
8431    }
8432}
8433
8434/// The item being written, in the one shape all three write methods reach.
8435struct Incoming<'a> {
8436    written: Written<'a>,
8437    /// The title a person wrote. A document's goes onto the issue with
8438    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8439    title: &'a str,
8440    content: Option<&'a str>,
8441    labels: &'a [Label],
8442    metadata: &'a BTreeMap<String, Value>,
8443    repositories: &'a [Repository],
8444    parent: Option<&'a NativeId>,
8445    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8446    /// what keeps either key out of their slot.
8447    delivers: &'a [TaskRef],
8448    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8449    delivered_by: &'a [TaskRef],
8450    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8451    /// project, a document, and every write to an instance with no `priority_mapping` —
8452    /// which is what keeps such a write's requests exactly what they were before.
8453    priority: Option<Priority>,
8454}
8455
8456/// What one write does to an item's `Priority` field.
8457enum PriorityWrite {
8458    /// Select this option of this field.
8459    Select {
8460        /// The `Priority` field's id.
8461        field: String,
8462        /// The mapped option's id.
8463        option: String,
8464    },
8465    /// Clear the field's value, which is what `none` is.
8466    Clear {
8467        /// The `Priority` field's id.
8468        field: String,
8469    },
8470}
8471
8472impl Incoming<'_> {
8473    /// The title this write puts on the issue.
8474    fn written_title(&self) -> String {
8475        match self.written {
8476            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8477            Written::Work(..) => self.title.to_owned(),
8478        }
8479    }
8480}
8481
8482#[derive(Clone, Copy, PartialEq, Eq)]
8483enum ContentKind {
8484    DraftIssue,
8485    Issue,
8486}
8487
8488/// What one board issue is: a document, or the work an [`ItemKind`] names.
8489///
8490/// A type of this source's own rather than an `ItemKind` with a third variant, because
8491/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8492/// document — the contract keeps a document out of that enum deliberately. Holding the
8493/// board's three answers in one value is what makes every place that asks "which is this?"
8494/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8495/// two thirds of the board.
8496#[derive(Clone, Copy, PartialEq, Eq)]
8497enum BoardKind {
8498    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8499    Document,
8500    /// Every other issue, and every draft.
8501    Work(ItemKind),
8502}
8503
8504impl BoardKind {
8505    /// How a refusal names this kind to the person reading it.
8506    const fn describes(self) -> &'static str {
8507        match self {
8508            Self::Document => "document",
8509            Self::Work(kind) => kind.marker(),
8510        }
8511    }
8512}
8513
8514/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8515///
8516/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8517/// the shared cross-source journeys assert one answer to one question, so two sources
8518/// that disagree about what "carries the label bug" means fail them.
8519fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8520    let holds = |name: &String| {
8521        labels
8522            .iter()
8523            .any(|label| label.name.eq_ignore_ascii_case(name))
8524    };
8525    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8526        && filter.all_of.iter().all(holds)
8527        && !filter.none_of.iter().any(holds)
8528}
8529
8530/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8531/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8532fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8533    statuses.is_empty() || statuses.contains(&category)
8534}
8535
8536/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8537///
8538/// `content` is the item's own prose — the body with this source's trailing metadata
8539/// comment already taken off — so a search never matches an encoding the author of the
8540/// issue never wrote.
8541fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8542    let terms = query.terms.to_lowercase();
8543    let in_title = title.to_lowercase().contains(&terms);
8544    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8545    match query.fields {
8546        TextFields::Title => in_title,
8547        TextFields::Content => in_content,
8548        TextFields::TitleOrContent => in_title || in_content,
8549    }
8550}
8551
8552/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8553///
8554/// The project predicate is passed separately because a read narrowed to one project has
8555/// already answered it by asking *that project* for its own items — and re-applying it
8556/// there would compare the caller's selector, which may be a project's **name**, against
8557/// the id of the project that name resolved to, and keep nothing. Every other read passes
8558/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8559/// source really does apply.
8560fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8561    labels_match(&task.labels, &query.labels)
8562        && status_matches(task.status.category, &query.statuses)
8563        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8564        && match project {
8565            ProjectFilter::Any => true,
8566            ProjectFilter::Orphans => task.project.is_none(),
8567            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8568        }
8569        && query
8570            .text
8571            .as_ref()
8572            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8573        // Against the parsed metadata slot, and against the origin field, which is where
8574        // `Resolved::metadata` reads each of them from.
8575        && query.metadata_matches(&task.metadata)
8576        && query.origin_matches(&task.metadata)
8577}
8578
8579fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8580    labels_match(&project.labels, &query.labels)
8581        && status_matches(project.status.category, &query.statuses)
8582        && query
8583            .text
8584            .as_ref()
8585            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8586}
8587
8588/// The same three predicates a task query carries, minus the status filter.
8589///
8590/// A document is not work, so it has no status for one to compare against and the query
8591/// type carries none. The project predicate is the same one — a design issue filed under a
8592/// project issue is in that project, and one filed under nothing is in none — so it is
8593/// spelled the same way here rather than answered differently.
8594fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8595    labels_match(&document.labels, &query.labels)
8596        && match project {
8597            ProjectFilter::Any => true,
8598            ProjectFilter::Orphans => document.project.is_none(),
8599            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8600        }
8601        && query
8602            .text
8603            .as_ref()
8604            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8605}
8606
8607#[async_trait::async_trait]
8608impl TaskSource for GitHubProjectsSource {
8609    fn kind(&self) -> &'static str {
8610        KIND
8611    }
8612    fn capabilities(&self) -> Capabilities {
8613        Capabilities {
8614            projects: Support::Native,
8615            documents: Support::Native,
8616            comments: Support::Native,
8617            priority: if self.priorities.is_some() {
8618                Support::Native
8619            } else {
8620                Support::Unsupported
8621            },
8622            filter_by_priority: Support::Native,
8623            filter_by_comment_activity: Support::Native,
8624            filter_by_metadata: Support::Native,
8625            filter_by_origin: Support::Native,
8626            orphan_tasks: Support::Native,
8627            filter_by_label: Support::Native,
8628            filter_by_status: Support::Native,
8629            search_title: Support::Native,
8630            search_content: Support::Native,
8631            task_dependencies: DependencySupport::BothDirections,
8632            project_dependencies: DependencySupport::BothDirections,
8633            max_page_size: MAX_PAGE_SIZE,
8634        }
8635    }
8636    async fn health(&self) -> Result<Health, SourceError> {
8637        let board = self.board_page(None, 1).await?;
8638        Ok(Health {
8639            reachable: true,
8640            detail: Some(format!(
8641                "reading GitHub project {}/{} ({})",
8642                self.owner,
8643                self.project_number,
8644                required_str(&board, "title")?
8645            )),
8646        })
8647    }
8648    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8649        self.item_by_id(id)
8650            .await?
8651            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8652            .map(|item| item.task())
8653            .transpose()
8654    }
8655    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8656        Ok(self
8657            .item_by_id(id)
8658            .await?
8659            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8660            .map(|item| item.project()))
8661    }
8662    async fn query_tasks(
8663        &self,
8664        query: &TaskQuery,
8665        page: &PageRequest,
8666    ) -> Result<Page<Task>, SourceError> {
8667        validate_page(page)?;
8668        refuse_unsearchable(query)?;
8669        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8670            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8671                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8672                (Some(also), None) => Some(also),
8673                (None, Some(since)) => Some(updated_qualifier(since)),
8674                (None, None) => None,
8675            };
8676            if let Some(also) = qualifiers {
8677                return self.search_tasks(query, page, &also).await;
8678            }
8679        }
8680
8681        // A read narrowed to one project asks that project for its own tasks, so nothing
8682        // about it costs what the rest of the board holds. A read carrying a text, metadata
8683        // or origin predicate asks GitHub the narrower question those predicates are, and a
8684        // read narrowed to comment activity alone asks the board's own issue search for the
8685        // issues updated since, which is every issue a comment could have been written or
8686        // edited on since. Every other task read is a question about the whole board and is
8687        // answered by reading it.
8688        let (held, membership) = match (&query.project, query.commented_since) {
8689            (ProjectFilter::Is(project), _) => (
8690                self.project_children(project).await?,
8691                // Answered by where these items came from; see `task_matches`.
8692                &ProjectFilter::Any,
8693            ),
8694            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8695                match (self.narrowed(query).await?, since) {
8696                    (Some(narrowed), _) => (narrowed, &query.project),
8697                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8698                    (None, None) => (self.board().await?.items, &query.project),
8699                }
8700            }
8701        };
8702        // Filtered before paged: a page of a filtered result is a page of the survivors,
8703        // never the survivors of a page.
8704        let mut tasks = Vec::new();
8705        for item in held
8706            .iter()
8707            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8708        {
8709            let task = item.task()?;
8710            if task_matches(&task, query, membership)
8711                && self.commented_since(item, query.commented_since).await?
8712            {
8713                tasks.push(task);
8714            }
8715        }
8716        Ok(offset_page(
8717            tasks,
8718            numeric_cursor(page.cursor.as_ref())?,
8719            page.limit.min(MAX_PAGE_SIZE) as usize,
8720        ))
8721    }
8722    async fn query_projects(
8723        &self,
8724        query: &ProjectQuery,
8725        page: &PageRequest,
8726    ) -> Result<Page<Project>, SourceError> {
8727        validate_page(page)?;
8728        // The projects a board holds are found by an issue search scoped to that board,
8729        // never by walking the board's own item connection: what tells a project from a
8730        // task is the `parent` each issue carries, which costs nothing to read.
8731        let projects = self
8732            .board_issues()
8733            .await?
8734            .iter()
8735            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8736            .map(Resolved::project)
8737            .filter(|project| project_matches(project, query))
8738            .collect();
8739        Ok(offset_page(
8740            projects,
8741            numeric_cursor(page.cursor.as_ref())?,
8742            page.limit.min(MAX_PAGE_SIZE) as usize,
8743        ))
8744    }
8745    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8746        Ok(self
8747            .item_by_id(id)
8748            .await?
8749            .filter(|item| item.kind == BoardKind::Document)
8750            .map(|item| item.document()))
8751    }
8752    async fn query_documents(
8753        &self,
8754        query: &DocumentQuery,
8755        page: &PageRequest,
8756    ) -> Result<Page<Document>, SourceError> {
8757        validate_page(page)?;
8758        // Narrowed to one project, this is the same sub-issue read a task list scoped to
8759        // that project makes — a document filed under a project is a sub-issue of it too,
8760        // and which of them come back is the kind this caller asked for.
8761        let (held, membership) = match &query.project {
8762            ProjectFilter::Is(project) => (
8763                self.project_children(project).await?,
8764                // Answered by where these items came from; see `task_matches`.
8765                &ProjectFilter::Any,
8766            ),
8767            ProjectFilter::Any | ProjectFilter::Orphans => {
8768                (self.board().await?.items, &query.project)
8769            }
8770        };
8771        // Filtered before paged, exactly as a task read is: a page of a filtered result is
8772        // a page of the survivors, never the survivors of a page.
8773        let documents = held
8774            .iter()
8775            .filter(|item| item.kind == BoardKind::Document)
8776            .map(Resolved::document)
8777            .filter(|document| document_matches(document, query, membership))
8778            .collect();
8779        Ok(offset_page(
8780            documents,
8781            numeric_cursor(page.cursor.as_ref())?,
8782            page.limit.min(MAX_PAGE_SIZE) as usize,
8783        ))
8784    }
8785    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8786        validate_page(page)?;
8787        let offset = numeric_cursor(page.cursor.as_ref())?;
8788        let mut labels = self
8789            .board()
8790            .await?
8791            .items
8792            .into_iter()
8793            .flat_map(|item| item.labels)
8794            .fold(Vec::new(), |mut all, label| {
8795                if !all.iter().any(|x: &Label| x.id == label.id) {
8796                    all.push(label);
8797                }
8798                all
8799            });
8800        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8801        Ok(offset_page(
8802            labels,
8803            offset,
8804            page.limit.min(MAX_PAGE_SIZE) as usize,
8805        ))
8806    }
8807    async fn task_dependencies(
8808        &self,
8809        id: &NativeId,
8810        direction: Direction,
8811        page: &PageRequest,
8812    ) -> Result<Page<DependencyEdge>, SourceError> {
8813        self.dependencies(id, ItemKind::Task, direction, page).await
8814    }
8815    async fn project_dependencies(
8816        &self,
8817        id: &NativeId,
8818        direction: Direction,
8819        page: &PageRequest,
8820    ) -> Result<Page<DependencyEdge>, SourceError> {
8821        self.dependencies(id, ItemKind::Project, direction, page)
8822            .await
8823    }
8824
8825    fn writes(&self) -> WriteSupport {
8826        WriteSupport::Supported
8827    }
8828
8829    /// Create or update one task.
8830    ///
8831    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8832    /// neither may name the task itself or name one task twice — and land in the body's
8833    /// metadata slot under their reserved keys, in place of any caller metadata of those
8834    /// names.
8835    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8836        let near = write.target.as_ref().unwrap_or(&write.item.id);
8837        for (key, entries) in [
8838            (TaskRef::DELIVERS_KEY, &write.item.delivers),
8839            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8840        ] {
8841            TaskRef::listed(key, near, Some(&self.name), entries.clone())
8842                .map_err(|message| SourceError::Refused { message })?;
8843        }
8844        if self.priorities.is_none() && write.item.priority != Priority::None {
8845            return Err(self.holds_no_priority());
8846        }
8847        self.write_item(
8848            &Incoming {
8849                written: Written::Work(ItemKind::Task, &write.item.status),
8850                title: &write.item.title,
8851                content: write.item.content.as_deref(),
8852                labels: &write.item.labels,
8853                metadata: &write.item.metadata,
8854                repositories: &write.item.repositories,
8855                parent: write.item.project.as_ref(),
8856                delivers: &write.item.delivers,
8857                delivered_by: &write.item.delivered_by,
8858                priority: self.priorities.as_ref().map(|_| write.item.priority),
8859            },
8860            write.target.as_ref(),
8861            &write.depends_on,
8862        )
8863        .await
8864    }
8865
8866    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8867        self.write_item(
8868            &Incoming {
8869                written: Written::Work(ItemKind::Project, &write.item.status),
8870                title: &write.item.title,
8871                content: write.item.content.as_deref(),
8872                labels: &write.item.labels,
8873                metadata: &write.item.metadata,
8874                repositories: &write.item.repositories,
8875                parent: None,
8876                delivers: &[],
8877                delivered_by: &[],
8878                priority: None,
8879            },
8880            write.target.as_ref(),
8881            &write.depends_on,
8882        )
8883        .await
8884    }
8885
8886    /// Create or update one document, which is one issue titled the way this board spells
8887    /// a document.
8888    ///
8889    /// Everything else is exactly a task write: caller metadata goes to the same canonical
8890    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8891    /// or a field this board cannot carry is refused by name rather than dropped, a target
8892    /// naming an issue this board does not hold is refused rather than created, and an
8893    /// issue this call created is taken back when the rest of the write fails.
8894    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8895        // A document takes part in no dependency graph, so there is no far end to write
8896        // natively and none to record: a caller naming one is told so rather than having it
8897        // stored under the reserved key, where a later read would report an edge the
8898        // contract says cannot exist.
8899        if !write.depends_on.is_empty() {
8900            return Err(SourceError::Refused {
8901                message: format!(
8902                    "this write names {} dependencies for a document, and a document takes \
8903                     part in no dependency graph; next: put the dependency on the task or \
8904                     project the document is about",
8905                    write.depends_on.len()
8906                ),
8907            });
8908        }
8909        self.write_item(
8910            &Incoming {
8911                written: Written::Document,
8912                title: &write.item.title,
8913                content: write.item.content.as_deref(),
8914                labels: &write.item.labels,
8915                metadata: &write.item.metadata,
8916                repositories: &write.item.repositories,
8917                parent: write.item.project.as_ref(),
8918                delivers: &[],
8919                delivered_by: &[],
8920                priority: None,
8921            },
8922            write.target.as_ref(),
8923            &[],
8924        )
8925        .await
8926    }
8927
8928    /// Set one task's status alone.
8929    ///
8930    /// An open target reopens a closed issue with an `updateIssue` carrying only its
8931    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8932    /// terminal target selects its mapped option, then closes with its fixed reason. No
8933    /// request carries a title, a body or a label. The status
8934    /// answered is what [`StatusMapping::status`] reads off the state just written, which is
8935    /// what a re-read reports.
8936    async fn set_task_status(
8937        &self,
8938        id: &NativeId,
8939        category: StatusCategory,
8940    ) -> Result<Option<Status>, SourceError> {
8941        self.set_status(id, category).await
8942    }
8943
8944    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
8945    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
8946    /// for `none`. Refused by an instance with no `priority_mapping`.
8947    async fn set_task_priority(
8948        &self,
8949        id: &NativeId,
8950        priority: Priority,
8951    ) -> Result<Option<Priority>, SourceError> {
8952        self.set_priority(id, priority).await
8953    }
8954
8955    /// Replace one task's content with a single body update that keeps the metadata slot
8956    /// byte for byte.
8957    async fn set_task_content(
8958        &self,
8959        id: &NativeId,
8960        content: &str,
8961    ) -> Result<Option<()>, SourceError> {
8962        self.replace_content(id, content).await
8963    }
8964
8965    /// Replace one task issue's content and its provenance slot entry with a single body
8966    /// update. The answers are not kept: see `replace_rendering`.
8967    async fn set_task_rendering(
8968        &self,
8969        id: &NativeId,
8970        content: &str,
8971        provenance: &Value,
8972        _answers: &BTreeMap<String, Value>,
8973    ) -> Result<Option<()>, SourceError> {
8974        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
8975            .await
8976    }
8977
8978    /// Replace one design-document issue's content and its provenance slot entry, on exactly
8979    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
8980    async fn set_document_rendering(
8981        &self,
8982        id: &NativeId,
8983        content: &str,
8984        provenance: &Value,
8985        _answers: &BTreeMap<String, Value>,
8986    ) -> Result<Option<()>, SourceError> {
8987        self.replace_rendering(id, BoardKind::Document, content, provenance)
8988            .await
8989    }
8990
8991    /// Apply a targeted update with one read of the item and a write only for what differs:
8992    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
8993    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
8994    async fn update_task(
8995        &self,
8996        id: &NativeId,
8997        update: &TaskUpdate,
8998    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8999        self.targeted_update(id, update).await
9000    }
9001
9002    /// Replace one task's `delivered_by` with a single body update that changes the
9003    /// metadata slot and nothing outside it.
9004    async fn set_delivered_by(
9005        &self,
9006        id: &NativeId,
9007        delivered_by: &[TaskRef],
9008    ) -> Result<Option<()>, SourceError> {
9009        self.replace_delivered_by(id, delivered_by).await
9010    }
9011
9012    /// Set one key of one task issue's metadata with a single body update that changes the
9013    /// metadata slot and nothing outside it — no title, label, state or board field request —
9014    /// and sends nothing when the task already holds that value under the key.
9015    async fn set_task_metadata(
9016        &self,
9017        id: &NativeId,
9018        key: &MetadataKey,
9019        value: &Value,
9020    ) -> Result<Option<Task>, SourceError> {
9021        Ok(self
9022            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9023            .await?
9024            .map(|item| item.task())
9025            .transpose()?)
9026    }
9027
9028    /// Set one key of one project issue's metadata, on exactly the terms of
9029    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9030    async fn set_project_metadata(
9031        &self,
9032        id: &NativeId,
9033        key: &MetadataKey,
9034        value: &Value,
9035    ) -> Result<Option<Project>, SourceError> {
9036        Ok(self
9037            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9038            .await?
9039            .map(|item| item.project()))
9040    }
9041
9042    /// Set one key of one design-document issue's metadata, on exactly the terms of
9043    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9044    async fn set_document_metadata(
9045        &self,
9046        id: &NativeId,
9047        key: &MetadataKey,
9048        value: &Value,
9049    ) -> Result<Option<Document>, SourceError> {
9050        Ok(self
9051            .set_slot_key(id, BoardKind::Document, key, value)
9052            .await?
9053            .map(|item| item.document()))
9054    }
9055
9056    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9057        self.delete_item(id).await
9058    }
9059
9060    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9061        self.delete_item(id).await
9062    }
9063
9064    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9065        self.delete_item(id).await
9066    }
9067
9068    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9069    ///
9070    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9071    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9072    ///
9073    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9074    /// board is the read of its comments. A draft this process already resolved is refused
9075    /// without one.
9076    async fn task_comments(
9077        &self,
9078        task: &NativeId,
9079        page: &PageRequest,
9080    ) -> Result<Option<Page<Comment>>, SourceError> {
9081        validate_page(page)?;
9082        let cached = self.resolved_cache()?.get(task).cloned();
9083        if let Some(item) = cached {
9084            if item.kind != BoardKind::Work(ItemKind::Task) {
9085                return Ok(None);
9086            }
9087            if item.content_kind == ContentKind::DraftIssue {
9088                return Err(self.draft_has_no_comments(task));
9089            }
9090        }
9091        match self.issue_detail(task, page).await? {
9092            Some(TaskDetailRead {
9093                comments: Some(comments),
9094                ..
9095            }) => comments,
9096            _ => Ok(None),
9097        }
9098    }
9099
9100    /// Every id's task, with the first page of its comments when `comments` names it:
9101    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9102    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9103    async fn get_task_details(
9104        &self,
9105        ids: &[NativeId],
9106        comments: Option<&PageRequest>,
9107    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9108        if let Some(page) = comments
9109            && let Err(error) = validate_page(page)
9110        {
9111            return ids.iter().map(|_| Err(error.clone())).collect();
9112        }
9113        match (ids, comments) {
9114            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9115            ([id], None) => vec![self.task_read(id).await],
9116            _ => self.issue_details(ids, comments).await,
9117        }
9118    }
9119
9120    /// Add one comment to the task's issue, as the account the token belongs to.
9121    ///
9122    /// The author is refused before anything is sent — not even the task is read — because
9123    /// no answer GitHub could give would make posting under another name than the one asked
9124    /// for the right outcome.
9125    async fn add_comment(
9126        &self,
9127        task: &NativeId,
9128        comment: &NewComment,
9129    ) -> Result<Option<Comment>, SourceError> {
9130        if let Some(author) = &comment.author {
9131            return Err(SourceError::Refused {
9132                message: format!(
9133                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9134                     the token signs in as the author of every comment; next: leave --author \
9135                     out, and the comment is posted as that account",
9136                    self.name
9137                ),
9138            });
9139        }
9140        let Some(issue) = self.commented_issue(task).await? else {
9141            return Ok(None);
9142        };
9143        let data = self
9144            .graphql(
9145                graphql::ADD_COMMENT,
9146                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9147            )
9148            .await?;
9149        let subject = data
9150            .pointer("/addComment/subject")
9151            .filter(|value| !value.is_null())
9152            .ok_or_else(|| SourceError::Malformed {
9153                message: "GitHub comment addition returned no subject".into(),
9154            })?;
9155        if required_str(subject, "id")? != issue.0 {
9156            return Err(SourceError::Malformed {
9157                message: "GitHub comment addition answered about another issue".into(),
9158            });
9159        }
9160        let added = data
9161            .pointer("/addComment/commentEdge/node")
9162            .filter(|value| !value.is_null())
9163            .ok_or_else(|| SourceError::Malformed {
9164                message: "GitHub comment addition returned no comment".into(),
9165            })?;
9166        comment_from(added).map(Some)
9167    }
9168
9169    async fn edit_comment(
9170        &self,
9171        task: &NativeId,
9172        comment: &NativeId,
9173        body: &CommentBody,
9174    ) -> Result<Option<Comment>, SourceError> {
9175        let Some(issue) = self.commented_issue(task).await? else {
9176            return Ok(None);
9177        };
9178        if !self.comment_is_on(&issue, comment).await? {
9179            return Ok(None);
9180        }
9181        let data = self
9182            .graphql(
9183                graphql::UPDATE_COMMENT,
9184                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9185            )
9186            .await?;
9187        let edited = data
9188            .pointer("/updateIssueComment/issueComment")
9189            .filter(|value| !value.is_null())
9190            .ok_or_else(|| SourceError::Malformed {
9191                message: "GitHub comment update returned no comment".into(),
9192            })?;
9193        let edited = comment_from(edited)?;
9194        if edited.id != *comment {
9195            return Err(SourceError::Malformed {
9196                message: "GitHub comment update returned the wrong comment".into(),
9197            });
9198        }
9199        Ok(Some(edited))
9200    }
9201
9202    async fn delete_comment(
9203        &self,
9204        task: &NativeId,
9205        comment: &NativeId,
9206    ) -> Result<Option<NativeId>, SourceError> {
9207        let Some(issue) = self.commented_issue(task).await? else {
9208            return Ok(None);
9209        };
9210        if !self.comment_is_on(&issue, comment).await? {
9211            return Ok(None);
9212        }
9213        let data = self
9214            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9215            .await?;
9216        // The payload says nothing about the comment it removed, so what is checked is that
9217        // GitHub answered the mutation at all rather than leaving it unanswered.
9218        data.get("deleteIssueComment")
9219            .filter(|value| !value.is_null())
9220            .ok_or_else(|| SourceError::Malformed {
9221                message: "GitHub comment deletion returned no payload".into(),
9222            })?;
9223        Ok(Some(comment.clone()))
9224    }
9225
9226    /// Every request this source has recorded, and what each of GitHub's two budgets was
9227    /// attributed — read off the same accounting the session report is rendered from, so
9228    /// the two cannot count one request two ways.
9229    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9230        Ok(Some(self.ledger.snapshot().metering()))
9231    }
9232}
9233
9234/// One issue comment as the contract carries it.
9235///
9236/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9237/// and when it answers an actor with no login, because either way the source did not say who
9238/// wrote it — which is what an absent author means, rather than an author called nothing.
9239fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9240    Ok(Comment {
9241        id: NativeId(required_str(value, "id")?.to_owned()),
9242        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9243            .map(str::to_owned),
9244        created_at: optional_time(value, "createdAt")?,
9245        updated_at: optional_time(value, "updatedAt")?,
9246        body: required_str(value, "body")?.to_owned(),
9247        url: optional_str(value, "url")?.map(str::to_owned),
9248    })
9249}
9250
9251/// The page of comments one issue node carries, resumed from `after`.
9252fn comment_page(
9253    node: &Value,
9254    issue: &str,
9255    after: Option<&str>,
9256) -> Result<Page<Comment>, SourceError> {
9257    let connection = node
9258        .get("comments")
9259        .filter(|value| !value.is_null())
9260        .ok_or_else(|| SourceError::Malformed {
9261            message: format!("GitHub issue {issue} answered with no comments connection"),
9262        })?;
9263    let items = optional_nodes(Some(connection), "issue comments")?
9264        .into_iter()
9265        .flatten()
9266        .map(comment_from)
9267        .collect::<Result<Vec<_>, _>>()?;
9268    let next = next_cursor(connection)?;
9269    if let Some(next) = &next {
9270        validate_cursor_progress(after, &next.0)?;
9271    }
9272    Ok(Page { items, next })
9273}
9274
9275/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9276/// end — `None` when it carried none, or a page with more past it.
9277fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9278    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9279        return Ok(None);
9280    };
9281    if next_cursor(connection)?.is_some() {
9282        return Ok(None);
9283    }
9284    Ok(Some(
9285        optional_nodes(Some(connection), "blocked-by issues")?
9286            .into_iter()
9287            .flatten()
9288            .cloned()
9289            .collect(),
9290    ))
9291}
9292
9293/// Where the recorded tail of a dependency walk resumes; see
9294/// [`GitHubProjectsSource::recorded_edges`].
9295const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9296
9297/// The board text field this source keeps a copy's origin in.
9298///
9299/// Named after the key it holds, and held to that name by the guard below rather than by
9300/// a reader noticing.
9301const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9302
9303/// The metadata key that field holds.
9304///
9305/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9306/// constructs or interprets the qualified id it carries. This source names it only to
9307/// route it — a short, typed value belongs in a typed field rather than in the body slot
9308/// a caller's own prose shares.
9309///
9310/// Restated rather than imported, because no plugin crate may depend on the engine. What
9311/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9312/// target in `check`: it reads the engine's own literal and fails naming the file and the
9313/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9314/// that creates a second item every run instead of finding the one it wrote — and that is
9315/// too late to learn it.
9316const ORIGIN_KEY: &str = "onetaskgraph.origin";
9317
9318/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9319///
9320/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9321/// is derived from the far end, never written down on the near item — so only a forward
9322/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9323/// it did not come from, and it is told so rather than answered with an empty page that
9324/// reads as a walk which ended.
9325fn recorded_offset(
9326    cursor: Option<&str>,
9327    direction: Direction,
9328) -> Result<Option<usize>, SourceError> {
9329    cursor
9330        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9331        .map(|offset| {
9332            if direction != Direction::DependsOn {
9333                return Err(SourceError::Config {
9334                    message: format!(
9335                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9336                         reverse dependency read never issues; resume it in the direction \
9337                         that reported it"
9338                    ),
9339                });
9340            }
9341            offset.parse().map_err(|_| SourceError::Config {
9342                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9343            })
9344        })
9345        .transpose()
9346}
9347
9348fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9349    let mut page = offset_page(edges, offset, limit.max(1));
9350    page.next = page
9351        .next
9352        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9353    page
9354}
9355
9356/// The kind of one issue reached through a dependency connection.
9357///
9358/// The same questions the board scan asks, over the fields the dependency document
9359/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9360/// then anything with sub-issues or the marker is a project.
9361///
9362/// # Errors
9363///
9364/// A far end this board holds as a document is refused rather than reported. The two
9365/// answers that are not refusals would both be wrong: reporting it as a task names an id
9366/// no task read of this source can find, and reporting it as a project names one no
9367/// project read can. There is no third value to return — `ItemKind` has no document
9368/// variant, because nothing may point at a document — so the relationship itself is what
9369/// the person is told about.
9370fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9371    let id = required_str(value, "id")?;
9372    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9373        return Err(SourceError::Refused {
9374            message: format!(
9375                "GitHub issue {id} is a document of this board — its title begins \
9376                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9377                 on by one; next: remove that issue's blocking relationship on this board"
9378            ),
9379        });
9380    }
9381    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9382    if parent.is_some() {
9383        return Ok(ItemKind::Task);
9384    }
9385    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9386    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9387        message: format!("GitHub issue {id}: {message}"),
9388    })?;
9389    let sub_issues = sub_issue_total(value)?;
9390    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9391        ItemKind::Project
9392    } else {
9393        ItemKind::Task
9394    })
9395}
9396
9397/// The `IssueStateUpdateInput` one status target asks for.
9398///
9399/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9400/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9401/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9402/// would report a change forever. A document has no status at all, and asks for neither.
9403fn state_input(target: Option<&StatusTarget>) -> Value {
9404    match target {
9405        Some(StatusTarget::Terminal(_, reason)) => {
9406            json!({"value":"CLOSED","stateReason":reason.reason()})
9407        }
9408        Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
9409        // A document has no status, so a write of one says nothing about the issue's open
9410        // or closed state rather than forcing it open: `stateInput` is what carries that
9411        // instruction, and an explicit null asks for no change to it.
9412        None => Value::Null,
9413    }
9414}
9415
9416/// The metadata one write stores in the item's body slot.
9417///
9418/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9419/// rather than carried: the kind marker so an empty project stays readable, the
9420/// repository list only when it is not exactly the issue's own repository, and the far
9421/// ends no relationship here can name.
9422///
9423/// The copy origin is the one typed field that is also mirrored here, and only as a
9424/// mirror: it lands in the board's origin field as well, which stays the one every reader
9425/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9426/// and catches up with a write in seconds rather than minutes — can find the item by it.
9427/// A reader of the release before this one drops the slot's copy and reads the field, so an
9428/// item written here still reads with exactly one origin there.
9429fn slot_metadata(
9430    incoming: &Incoming<'_>,
9431    own_repository: Option<&Repository>,
9432    fallback: &[DependencyEdge],
9433) -> BTreeMap<String, Value> {
9434    let mut metadata = incoming.metadata.clone();
9435    match metadata.remove(ORIGIN_KEY) {
9436        Some(Value::String(origin)) if !origin.is_empty() => {
9437            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9438        }
9439        _ => {}
9440    }
9441    match incoming.written.kind() {
9442        BoardKind::Work(kind) => metadata.insert(
9443            ItemKind::METADATA_KEY.to_owned(),
9444            Value::String(kind.marker().to_owned()),
9445        ),
9446        // A document is told by its title, so it carries no kind marker: that key names
9447        // what a dependency endpoint points at, and nothing may point at a document.
9448        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9449    };
9450    let derivable = own_repository
9451        .map(|own| incoming.repositories == [own.clone()])
9452        .unwrap_or(incoming.repositories.is_empty());
9453    if derivable {
9454        metadata.remove(Repository::METADATA_KEY);
9455    } else {
9456        metadata.insert(
9457            Repository::METADATA_KEY.to_owned(),
9458            Value::Array(
9459                incoming
9460                    .repositories
9461                    .iter()
9462                    .map(|repository| Value::String(repository.as_str().to_owned()))
9463                    .collect(),
9464            ),
9465        );
9466    }
9467    // The typed lists are what land, whatever the caller's own metadata held under their
9468    // keys: a key of either name travelling beside the field would otherwise be a second
9469    // answer to the same question, and the field is the one the contract names.
9470    for (key, entries) in [
9471        (TaskRef::DELIVERS_KEY, incoming.delivers),
9472        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9473    ] {
9474        set_task_list(&mut metadata, key, entries);
9475    }
9476    record_edges(&mut metadata, fallback);
9477    metadata
9478}
9479
9480/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9481/// one slot's metadata, or no such key when there are none.
9482fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9483    if fallback.is_empty() {
9484        metadata.remove(DependencyEdge::RECORDED_KEY);
9485    } else {
9486        metadata.insert(
9487            DependencyEdge::RECORDED_KEY.to_owned(),
9488            Value::Array(
9489                fallback
9490                    .iter()
9491                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9492                    .collect(),
9493            ),
9494        );
9495    }
9496}
9497
9498/// Every label one item carries, from its content's own connection and nowhere else.
9499///
9500/// There is no second place to read one from: no document this source sends selects the
9501/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9502/// cannot carry one at all. The module documentation records the three schema facts that
9503/// settle it.
9504fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9505    optional_nodes(content.get("labels"), "content labels")?
9506        .into_iter()
9507        .flatten()
9508        .map(|v| {
9509            Ok(Label {
9510                id: NativeId(required_str(v, "id")?.to_owned()),
9511                name: required_str(v, "name")?.to_owned(),
9512                color: optional_str(v, "color")?.map(str::to_owned),
9513            })
9514        })
9515        .collect()
9516}
9517
9518/// The definition of each board field one item's values are values of, in the shape a read
9519/// of the board's own `fields` gives one.
9520///
9521/// A value names its field through a fragment on that field's own type, so the type is
9522/// known from which kind of value it is: a single-select value's field is a
9523/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9524/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9525fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9526    field_values
9527        .iter()
9528        .filter_map(|value| {
9529            let field = value.get("field")?.as_object()?;
9530            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9531            let typename = if value.get("text").is_some() {
9532                "ProjectV2Field"
9533            } else if value.get("name").is_some() {
9534                "ProjectV2SingleSelectField"
9535            } else {
9536                return None;
9537            };
9538            let mut defined = field.clone();
9539            defined.insert("__typename".to_owned(), json!(typename));
9540            Some(Value::Object(defined))
9541        })
9542        .collect()
9543}
9544
9545fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9546    let Some(node) = field_values
9547        .iter()
9548        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9549    else {
9550        return Ok(None);
9551    };
9552    Ok(optional_str(node, "text")?.map(str::to_owned))
9553}
9554
9555fn valid_github_owner(owner: &str) -> bool {
9556    !owner.is_empty()
9557        && owner.len() <= 39
9558        && !owner.starts_with('-')
9559        && !owner.ends_with('-')
9560        && !owner.contains("--")
9561        && owner
9562            .bytes()
9563            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9564}
9565
9566/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9567/// neither of the two names a path segment already means.
9568fn valid_github_repository_name(name: &str) -> bool {
9569    !name.is_empty()
9570        && name.len() <= 100
9571        && name != "."
9572        && name != ".."
9573        && name
9574            .bytes()
9575            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9576}
9577
9578fn valid_environment_name(name: &str) -> bool {
9579    let mut bytes = name.bytes();
9580    bytes
9581        .next()
9582        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9583        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9584}
9585
9586/// How many sub-issues one issue has.
9587///
9588/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9589/// absent or non-integer one is a response this source cannot read — and reading it as
9590/// zero would classify a project as a task, which is exactly the mistake the marker
9591/// exists to keep from happening quietly.
9592fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9593    let summary = issue
9594        .get("subIssuesSummary")
9595        .ok_or_else(|| SourceError::Malformed {
9596            message: "GitHub issue is missing subIssuesSummary".into(),
9597        })?;
9598    summary
9599        .get("total")
9600        .and_then(Value::as_u64)
9601        .ok_or_else(|| SourceError::Malformed {
9602            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9603        })
9604}
9605
9606/// One issue's own `number`.
9607///
9608/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9609/// an issue in this module asks for it. So a read of one that comes back without it, or
9610/// with something that is not an unsigned integer, is a response this source cannot read —
9611/// absence here is **not** "this issue has no number". A draft is the content that has
9612/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9613/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9614fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9615    issue
9616        .get("number")
9617        .and_then(Value::as_u64)
9618        .ok_or_else(|| SourceError::Malformed {
9619            message: "GitHub issue number is missing or is not an unsigned integer".into(),
9620        })
9621}
9622
9623/// The `number` a creating mutation answered with, and `None` when it answered without one;
9624/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9625fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9626    match created.get("number") {
9627        None | Some(Value::Null) => Ok(None),
9628        Some(value) => value
9629            .as_u64()
9630            .map(Some)
9631            .ok_or_else(|| SourceError::Malformed {
9632                message: "GitHub created issue number is not an unsigned integer".into(),
9633            }),
9634    }
9635}
9636
9637fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9638    value
9639        .get(field)
9640        .and_then(Value::as_str)
9641        .ok_or_else(|| SourceError::Malformed {
9642            message: format!("GitHub response is missing string field {field}"),
9643        })
9644}
9645
9646fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9647    let found = required_str(value, field)?;
9648    if found.trim().is_empty() {
9649        return Err(SourceError::Malformed {
9650            message: format!("GitHub response has blank string field {field}"),
9651        });
9652    }
9653    Ok(found)
9654}
9655
9656/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9657/// needs one — Linear spells them too, in its own description field.
9658///
9659/// Restated rather than shared, because a plugin crate depends on the contract crate and
9660/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9661/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9662/// source round-trips its own writes perfectly well under its own spelling.
9663const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9664const METADATA_CLOSE: &str = "\n-->";
9665
9666/// What the composer puts between a non-empty visible body and the slot, and the one thing
9667/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9668/// other trailing byte of the body comes back as it was written.
9669// 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.
9670const METADATA_SEPARATOR: &str = "\n\n";
9671
9672/// The visible body and the metadata slot at the end of it.
9673///
9674/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9675/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9676/// own content and is left alone. The visible body is everything before the slot less the
9677/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9678fn metadata_body(
9679    body: Option<String>,
9680) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9681    let Some(body) = body else {
9682        return Ok((None, BTreeMap::new()));
9683    };
9684    let Some(slot) = slot_span(&body)? else {
9685        return Ok((Some(body), BTreeMap::new()));
9686    };
9687    let metadata =
9688        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9689            SourceError::Malformed {
9690                message: format!(
9691                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9692                ),
9693            }
9694        })?;
9695    let before = &body[..slot.start];
9696    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9697    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9698}
9699
9700/// Where the metadata slot sits in one body, as byte offsets into it.
9701struct SlotSpan {
9702    /// Where [`METADATA_OPEN`] begins.
9703    start: usize,
9704    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9705    encoded_start: usize,
9706    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9707    encoded_end: usize,
9708    /// Just past [`METADATA_CLOSE`].
9709    end: usize,
9710}
9711
9712/// The slot at the very end of `body`, or `None` when it has none.
9713///
9714/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9715/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9716/// slot.
9717fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9718    let Some(start) = body.rfind(METADATA_OPEN) else {
9719        return Ok(None);
9720    };
9721    let encoded_start = start + METADATA_OPEN.len();
9722    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9723        return Err(SourceError::Malformed {
9724            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9725        });
9726    };
9727    let encoded_end = encoded_start + relative_end;
9728    let end = encoded_end + METADATA_CLOSE.len();
9729    if !body[end..].trim().is_empty() {
9730        return Ok(None);
9731    }
9732    Ok(Some(SlotSpan {
9733        start,
9734        encoded_start,
9735        encoded_end,
9736        end,
9737    }))
9738}
9739
9740/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9741/// slot as it was.
9742///
9743/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9744/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9745/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9746/// or alone in an empty body — and a body with no slot that is given no metadata is
9747/// returned as it is.
9748fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9749    let encoded = if metadata.is_empty() {
9750        None
9751    } else {
9752        Some(
9753            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9754                message: error.to_string(),
9755            })?,
9756        )
9757    };
9758    Ok(match (slot_span(body)?, encoded) {
9759        (Some(slot), Some(encoded)) => format!(
9760            "{}{encoded}{}",
9761            &body[..slot.encoded_start],
9762            &body[slot.encoded_end..]
9763        ),
9764        (Some(slot), None) => {
9765            let before = &body[..slot.start];
9766            format!(
9767                "{}{}",
9768                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
9769                &body[slot.end..]
9770            )
9771        }
9772        (None, None) => body.to_owned(),
9773        (None, Some(encoded)) if body.is_empty() => {
9774            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9775        }
9776        (None, Some(encoded)) => {
9777            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9778        }
9779    })
9780}
9781
9782/// `body` with everything before its metadata slot replaced by `content`, and the slot
9783/// itself kept byte for byte.
9784///
9785/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
9786/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
9787/// `content` is empty — so a read of the result reports `content` as the visible body and
9788/// the slot's metadata exactly as it was.
9789fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
9790    let Some(slot) = slot_span(body)? else {
9791        return Ok(content.to_owned());
9792    };
9793    let kept = &body[slot.start..];
9794    Ok(if content.is_empty() {
9795        kept.to_owned()
9796    } else {
9797        format!("{content}{METADATA_SEPARATOR}{kept}")
9798    })
9799}
9800
9801/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
9802fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
9803    if entries.is_empty() {
9804        metadata.remove(key);
9805    } else {
9806        metadata.insert(
9807            key.to_owned(),
9808            Value::Array(
9809                entries
9810                    .iter()
9811                    .map(|entry| Value::String(entry.as_str().to_owned()))
9812                    .collect(),
9813            ),
9814        );
9815    }
9816}
9817
9818fn compose_body(
9819    content: Option<&str>,
9820    metadata: &BTreeMap<String, Value>,
9821) -> Result<Option<String>, SourceError> {
9822    let visible = content.unwrap_or_default();
9823    if metadata.is_empty() {
9824        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9825    }
9826    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9827        message: error.to_string(),
9828    })?;
9829    Ok(Some(if visible.is_empty() {
9830        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9831    } else {
9832        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9833    }))
9834}
9835
9836fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9837    value
9838        .get(field)
9839        .and_then(Value::as_bool)
9840        .ok_or_else(|| SourceError::Malformed {
9841            message: format!("GitHub response is missing boolean field {field}"),
9842        })
9843}
9844fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9845    match value.get(field) {
9846        None | Some(Value::Null) => Ok(None),
9847        Some(value) => value
9848            .as_str()
9849            .map(Some)
9850            .ok_or_else(|| SourceError::Malformed {
9851                message: format!("GitHub response field {field} is not a string or null"),
9852            }),
9853    }
9854}
9855fn optional_nodes<'a>(
9856    connection: Option<&'a Value>,
9857    name: &str,
9858) -> Result<Option<&'a Vec<Value>>, SourceError> {
9859    match connection {
9860        None | Some(Value::Null) => Ok(None),
9861        Some(value) => value
9862            .get("nodes")
9863            .and_then(Value::as_array)
9864            .map(Some)
9865            .ok_or_else(|| SourceError::Malformed {
9866                message: format!("GitHub {name}.nodes is not an array"),
9867            }),
9868    }
9869}
9870fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9871    let page_info = connection
9872        .get("pageInfo")
9873        .ok_or_else(|| SourceError::Malformed {
9874            message: format!("GitHub {name} has no pageInfo"),
9875        })?;
9876    if required_bool(page_info, "hasNextPage")? {
9877        return Err(SourceError::Malformed {
9878            message: format!(
9879                "GitHub {name} exceeds the supported nested connection size of {size}"
9880            ),
9881        });
9882    }
9883    Ok(())
9884}
9885fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9886    optional_str(value, field)?
9887        .map(|timestamp| {
9888            timestamp.parse().map_err(|error| SourceError::Malformed {
9889                message: format!("GitHub response field {field} is not a timestamp: {error}"),
9890            })
9891        })
9892        .transpose()
9893}
9894fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9895    if page.limit == 0 {
9896        Err(SourceError::Config {
9897            message: "page limit must be at least 1".into(),
9898        })
9899    } else {
9900        Ok(())
9901    }
9902}
9903fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9904    let page = connection
9905        .get("pageInfo")
9906        .filter(|value| value.is_object())
9907        .ok_or_else(|| SourceError::Malformed {
9908            message: "GitHub connection is missing pageInfo".into(),
9909        })?;
9910    if required_bool(page, "hasNextPage")? {
9911        let cursor = required_str(page, "endCursor")?;
9912        validate_cursor_progress(None, cursor)?;
9913        Ok(Some(Cursor(cursor.into())))
9914    } else {
9915        Ok(None)
9916    }
9917}
9918fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9919    if next.is_empty() || previous == Some(next) {
9920        Err(SourceError::Malformed {
9921            message: "GitHub pagination cursor is empty or did not advance".into(),
9922        })
9923    } else {
9924        Ok(())
9925    }
9926}
9927/// The version of this plugin's opaque narrowing-search cursor.
9928pub const SEARCH_CURSOR_VERSION: u32 = 4;
9929
9930#[derive(Serialize, Deserialize)]
9931#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9932enum SearchConnection {
9933    Initial {},
9934    Continuing { after: Cursor },
9935    Exhausted {},
9936}
9937impl SearchConnection {
9938    fn after(&self) -> Option<&str> {
9939        match self {
9940            Self::Continuing { after } => Some(&after.0),
9941            _ => None,
9942        }
9943    }
9944    fn exhausted(&self) -> bool {
9945        matches!(self, Self::Exhausted { .. })
9946    }
9947    /// Whether a cursor naming this position, `offset` rows into its page, is one this
9948    /// plugin could have handed out: a page is resumed only part of the way through it — an
9949    /// offset of a whole page or more would skip rows nobody was given — an initial page
9950    /// only once some of it was handed out, and an exhausted connection has no page to be
9951    /// part of the way through.
9952    fn valid_resume(&self, offset: usize) -> bool {
9953        let within = offset < SEARCH_PAGE_SIZE as usize;
9954        match self {
9955            Self::Initial { .. } => offset > 0 && within,
9956            Self::Continuing { after } => !after.0.is_empty() && within,
9957            Self::Exhausted { .. } => offset == 0,
9958        }
9959    }
9960}
9961
9962/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
9963#[derive(Serialize, Deserialize)]
9964#[serde(deny_unknown_fields)]
9965struct SearchPosition {
9966    version: u32,
9967    connection: SearchConnection,
9968    /// How many rows of the page `connection` starts were already handed out.
9969    #[serde(default, skip_serializing_if = "is_zero")]
9970    offset: usize,
9971    #[serde(default, skip_serializing_if = "Vec::is_empty")]
9972    seen: Vec<NativeId>,
9973    #[serde(default, skip_serializing_if = "Vec::is_empty")]
9974    own: Vec<NativeId>,
9975}
9976impl Default for SearchPosition {
9977    fn default() -> Self {
9978        Self {
9979            version: SEARCH_CURSOR_VERSION,
9980            connection: SearchConnection::Initial {},
9981            offset: 0,
9982            seen: Vec::new(),
9983            own: Vec::new(),
9984        }
9985    }
9986}
9987
9988fn is_zero(offset: &usize) -> bool {
9989    *offset == 0
9990}
9991
9992fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
9993    cursor.map_or(Ok(0), |c| {
9994        c.0.parse().map_err(|_| SourceError::Config {
9995            message: "page cursor is invalid".into(),
9996        })
9997    })
9998}
9999fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10000    if offset > items.len() {
10001        return Page::last(vec![]);
10002    }
10003    let tail = items.split_off(offset);
10004    let mut selected = tail;
10005    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10006    selected.truncate(limit);
10007    Page {
10008        items: selected,
10009        next,
10010    }
10011}