Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
85//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
86//! option of the board's one `Status` field for a task and for a project: a bare name is
87//! the option for both kinds, `null` disables the category for both, and `{task, project}`
88//! names it per kind. A category the mapping does not mention keeps its shipped default for
89//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
90//! gets the shipped default for the kind it leaves out. Two categories one kind would read
91//! back from one option are refused as the configuration is read, ignoring case, while one
92//! option may stand for different categories of the two kinds. Writes go by the kind of the
93//! item written: a status that kind has no option for, or whose option the board lacks, is
94//! refused before any mutation, naming the source, the kind, the category and the key
95//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
96//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
97//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
98//! before selecting its option. Reads give a closed issue's reason precedence over its
99//! option, while an open issue's option decides its category through its own kind's
100//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
101//! The guarded [`GitHubProjectsSource::status_options`] and
102//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
103//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
104//! existing option id and verify the field and item assignments immediately afterwards.
105//! They ask for both kinds' options, counting a terminal category's mapped option as
106//! configured because a terminal write refuses without it. No ordinary source read or
107//! write calls that mutation, whose
108//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
109//! and a mistake destroys every item's status. A status this board cannot represent is a
110//! refusal naming the status and the instance instead.
111//!
112//! `unknown` has no shipped option because this source cannot preserve an open-ended
113//! status word: it writes an existing board option and never
114//! creates an option. An operator may map `unknown` to one existing option, in which case
115//! every unknown word lands on that option and reads back as `unknown` under the option's
116//! name. This differs from `local-md`, which writes and reads the original word itself.
117//!
118//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
119//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
120//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
121//! finished tasks were only moved to a "Done" column would read 0% complete forever.
122// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
123//!
124//! # What this source declares, field by field
125//!
126//! One verdict per field of [`Capabilities`], and what `Native` means when this source
127//! says it. *Proven* means a shared journey drives it against the real
128//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
129//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
130//! [`capabilities`](TaskSource::capabilities) from parting.
131//!
132//! | Field | Verdict |
133//! | --- | --- |
134//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
135//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
136//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
137//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
138//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
139//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so neither `ProjectV2.items` nor any issue the search did not name is read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
140//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
141//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
142//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
143//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
144//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
145//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
146//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
147//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
148//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
149//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
150//!
151//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
152//! source has documents and that its tasks have comments, both of which hold — and the three
153//! facts behind the uniform `Native` on the
154//! predicates beside it are recorded below rather than re-derived, because a reader who
155//! takes `Native` to mean *the remote service filters* will read that uniformity as a
156//! lie.
157//!
158//! First, the plugin contract defines `Support::Native` as *the source applies this
159//! predicate itself*, and says nothing about where it applies it. What the declaration
160//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
161//! — so that the engine may push it down and apply nothing of its own.
162//!
163//! Second, this source can keep that promise for every predicate at no additional API
164//! cost, because whichever of the reads below answers a query has already read every
165//! candidate that query will return before it filters anything. Filtering those items is
166//! in-process work over data already in hand.
167//!
168//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
169//! applied in process over what that question returned. A project filter has a relationship — a
170//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
171//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
172//! a search for metadata values, is the board-scoped issue search carrying the text and each
173//! value as quoted phrases; an origin is the board's own field filter over its origin field
174//! beside the same search for the id. **The text search narrows, and that is this source's
175//! declared semantics:** GitHub matches whole words where the substring rule this source and
176//! the local Markdown source confirm with would match inside one, so an item holding the text
177//! only inside a longer word is never a candidate. Every item returned does contain the text.
178//! A project query's text, and a document query's scoped to no project, is that same search
179//! and narrows on the same terms, its candidates confirmed by their kind as well.
180//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
181//! so those three are applied in process over the candidates, and a query carrying none of
182//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
183//! engine compensate for work this source has already done, and declaring `projects` native
184//! while ignoring the filter (which this source once did) silently returns another project's
185//! tasks, because the engine trusts the declaration and applies nothing locally.
186//!
187//! # The three ways this source reaches an item, and what each costs
188//!
189//! A board read is charged for what its *nested* connections could return rather than for
190//! what was asked, so one whole-board read costs the same whether the question was about
191//! one project or about all of them. That is why a question about one project is never
192//! answered by reading the board:
193//!
194//! | The question | What is sent | What it costs |
195//! | --- | --- | --- |
196//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)`, carrying the field definitions of the boards it sits on and the far ends of its `blockedBy`, which is what a write of it needs — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
197//! | one task with its first page of comments, for `task show` and a comment listing | [`graphql::ISSUE_DETAIL`] — the same `node(id:)` read with the issue's `comments` | the item and a page of its comments |
198//! | several tasks with their comments, for `task show-many` | [`graphql::ISSUE_DETAILS`] — [`DETAIL_BATCH`] aliased `node(id:)` fields per request | each item and a page of its comments |
199//! | the board's own id and field definitions, for a write whose item does not carry them | [`graphql::BOARD_FIELDS`] — the board's `id` and `fields`, and no `items` — or, for a create that needs the repository's id too, [`graphql::CREATION_CONTEXT`], both in one request | the board's fields |
200//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
201//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
202//! | which projects hold a text, or which documents do when no project narrows the question | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text as one quoted phrase, `in:title`, `in:body` or both, as a task's text is sent — walked to its end in pages of twenty | the issues that match |
203//! | which tasks were commented on since an instant | [`graphql::SEARCH_ISSUES`] — the same board-scoped search with an `updated:>=` qualifier — then [`graphql::ISSUE_COMMENTS`] for each candidate it names | the issues updated since, and their comments |
204//! | which tasks hold a text, or a metadata value | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text and each value as quoted phrases, `in:title`, `in:body` or both, and an `updated:>=` qualifier too when comment activity is asked for — in pages of twenty, only as many as the caller's rows need | the issues that match |
205//! | which tasks were copied from one origin | [`graphql::ORIGIN_LOOKUP`] — the board's own `items` under its field filter on the origin field, and the same board-scoped search for the id `in:body`, in one request, each paged at three | the carriers of that origin, which is one item |
206//! | every task, every document, every label, when nothing above narrows the question | [`graphql::BOARD`] — the board's own `items` — **and** [`graphql::SEARCH_ISSUES`], because neither enumeration of a board is complete alone; see [`GitHubProjectsSource::board`] | the board, twice over |
207//! | which board item one issue is, past the page that came with it | [`graphql::ISSUE_BOARD_ITEMS`] — that issue's own `projectItems` | one issue's memberships |
208//!
209//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
210//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
211//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
212//! equal to declared points equal to the row. They include the origin lookup and the
213//! field/repository discovery a create needs. A bound re-copy changes status, priority,
214//! content and metadata; comment recount means a subsequent detail read. Each request here
215//! costs one declared point. A membership beyond the embedded page can additionally require
216//! the one-point membership recovery described above. A bound re-copy of a task filed under a
217//! project adds one read, the engine confirming that project's link by its own id once per
218//! command; and the same-source far ends a write newly names — those that do not already block
219//! the item, whose own read answered for them — are read together by their own ids,
220//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
221//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
222//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
223//!
224//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
225//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
226//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
227//! holds both halves.
228//!
229//! **An existing item is written body last.** A bound re-copy and a `task update` send its
230//! board fields first — the `Status` option and the `Priority` together, in one request — then
231//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
232//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
233//! undoing an earlier field when a later one fails, so that order is what makes a write
234//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
235//! the one piece of metadata written before the body, an origin a copy re-points, is put back
236//! when a later write is refused — and when putting it back is refused too, the write's own
237//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
238//! of those writes in turn, whole and as one aliased field failing after the one before it.
239//!
240//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
241//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
242//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
243//!
244//! - **A board is accepted at creation but its item is not answered, so a create still files
245//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
246//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
247//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
248//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
249//!   against a real board on 2026-10-01 with a create sending the board there and reading the
250//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
251//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
252//!   refused "Content already exists in this project" — GitHub had filed the issue after
253//!   answering, and refuses a second filing rather than answering with the item it holds. A
254//!   create therefore sends no `projectV2Ids` and files the issue with
255//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
256//!   real is the read before it: the board's fields and the repository's id together, in
257//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
258//! - **A comment still reads its target first, so a comment is 2 requests.**
259//!   `AddCommentInput.subjectId: ID!` is declared there with
260//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
261//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
262//!   project's issue, a document's issue, an issue on no board of this source and a pull
263//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
264//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
265//!   refuses those by name.
266//!
267//! | Verb | Requests / points | Documents |
268//! | --- | --- | --- |
269//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
270//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
271//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS, then UPDATE_ISSUE last |
272//! | bound copy, filed under a project | 4 | the bound copy's three, and one ISSUE of the destination project its link names, read once per command |
273//! | bound copy, newly naming n dependencies | + ceil(n / DETAIL_BATCH) + n | ISSUE_DETAILS for the far ends that do not already block the item, DETAIL_BATCH (24) to a request (one alone is ISSUE), then one ADD_BLOCKED_BY each; a far end already blocking it is answered by its own read and costs nothing |
274//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
275//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
276//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
277//! | recount | 1 | ISSUE_DETAIL |
278//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
279//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
280//! | content | 2 | ISSUE, UPDATE_ISSUE |
281//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
282//! | update | 3 | `task update` naming any of title, body, metadata, status and priority — all five included: ISSUE, UPDATE_FIELDS (the status option and the priority together), UPDATE_ISSUE (title, body with its metadata slot, and state) last |
283//! | record only | 1 | ISSUE |
284//!
285//! <!-- github-search-paging:start -->
286//! Board-scoped text, metadata, project-name and comment-activity searches send every
287//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
288//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
289//! needs rows. A page is never resized to the rows still needed: GitHub orders one
290//! search differently at different page sizes, so one fixed size makes a paged walk
291//! send exactly the requests one whole read sends, and the answer's order is the order
292//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
293//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
294//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
295//! returned and fetched pages: a limit is sliced from the pages it needs, and local
296//! confirmation can require more candidates than matching rows. Walking all pages
297//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
298//! cursor and how far into that page the last answer stopped, and resumes in the same
299//! process or a new one, without duplicates or gaps. It carries no rows: one process
300//! sends each page's search once, and a new process re-reads only the page it resumes
301//! in, then sends a further page once, never as a re-read, only when its limit still
302//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
303//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
304//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
305//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
306//! not required to include the original process's writes still omitted by the index.
307//! <!-- github-search-paging:end -->
308//!
309//! The board half of an issue — its board item's id, its `Status` option and this
310//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
311//! an item reached any of those ways resolves through the same
312//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
313//! same status, the same labels and the same qualified id. That connection comes back a
314//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
315//! on the page in hand and — only if that page reports more of the connection — in the
316//! last row's read of that one issue's memberships, resumed from the page's own cursor and
317//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
318//! report, which is what keeps an id naming another repository's issue from being answered
319//! as an item of this board; and because the page is where the search starts rather than
320//! where it ends, that answer is one about a connection read to exhaustion and never about
321//! an unread page. Nothing costs the extra read but an issue on more boards than a page
322//! holds: an issue this board really does not hold reports no next page, so its
323//! memberships are already exhausted where they arrived.
324//!
325//! **No document here selects the board's own `Labels` field, and nothing is lost by
326//! that.** An item's labels are read from its content alone, wherever that content is
327//! reached: the three documents above select `Issue.labels` on the fragment, and
328//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
329//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
330//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
331//! create one, and `ProjectV2FieldValue` — the whole of what
332//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
333//! it from the content, for every content type it exists on, and there is nothing it can
334//! hold that the content does not already say: for an `Issue` it *is* that issue's own
335//! labels, so selecting it beside them unions a set with itself.
336//!
337//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
338//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
339//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
340//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
341//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
342//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
343//! label set, and that set is the fixture issue's own, by
344//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
345//! item whose content is a draft reports an empty set, by
346//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
347//! selection is held over [`graphql::DOCUMENTS`] by
348//! `no_document_selects_the_boards_own_labels_field`.
349//!
350//! The whole-board row is still the board's own item connection, and deliberately: a
351//! **draft** board item is not an issue, so no search can list one, and the reads that have
352//! to answer for the whole board are the ones whose cost is the board's size anyway.
353//!
354//! **A question about one item this source already names by id never lists the board.**
355//! Whether that item is on this board, and what its board fields are, is answered by reading
356//! that item — its own `Issue.projectItems`, walked to exhaustion by
357//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
358//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
359//! holds. That covers a write's destination, the project a new item is filed under, a
360//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
361//! and the delete that takes back an item a copy made. What such a write needs of the board
362//! and the item does not carry — the board's id, the `Status` and origin field definitions —
363//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
364//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
365//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
366//! minutes. Scanning this host's 842-item board has refused a document copy and an update
367//! even though the items' own reads named that board. A scan there gives the wrong answer
368//! as well as paying for every page. So a `board.items` lookup does not belong on any of
369//! those paths.
370//!
371//! **What a read may return is capped too, and that cap is on the document rather than on
372//! the board.** GitHub limits the number of nodes **one query may return** to
373//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
374//! an error naming the connection the count crossed at, not a slow or a partial result.
375//! Every board this source reads is refused the same way, so no board is too big for these
376//! documents and none is small enough to save one that is over.
377//!
378//! The count is arithmetic over the document's own text: each connection contributes the
379//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
380//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
381//! restate them — `github-graphql-node-count` implements them, and
382//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
383//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
384//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
385//! same text on every run and fails naming any that reaches the limit — so a connection
386//! added to a shared fragment is caught there rather than by GitHub.
387//!
388//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
389//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
390//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
391//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
392//! effectively squared there, which is why it is the one the limit is most sensitive to.
393//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
394//! page of memberships misses is recovered by one further read rather than refused, so it
395//! buys a bound every read pays for at the price of a request only a multi-board issue
396//! pays.
397//!
398//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
399//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
400//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
401//! `cost` is rate-limit points, metered per hour across everything one credential does; it
402//! is what the two limiters [`Limiter`] tells apart meter, and a document under
403//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
404//! that second number, and `tests/point_cost.rs` pins every document in
405//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
406//! one under, the pin itself is the check. The credentialed lane reconciles both figures
407//! against GitHub's own, off a probe it already sends.
408//!
409//! **What is pinned that way is a per-document price and never a session's.** The record in
410//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
411//! **requests** and **worst-case nodes** — and neither is points. What one whole session
412//! consumes of the hourly point allowance is observable only from a credentialed run's own
413//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
414//! and what `tests/live.rs` prints at the end of every run.
415//!
416//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
417//!
418//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
419//! enumerations of a board can supply one alone.** Resolving a node id is strongly
420//! consistent, so a read by id and a project's own sub-issues are already current. The
421//! other two are not, and they are behind by different amounts and in different directions:
422//!
423//! - GitHub's **issue search** is an index and answers a write made moments ago with the
424//!   value from before it — usually for a second or two.
425//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
426//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
427//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
428//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
429//!
430//! That second one is a measurement rather than a caution. This repository's own
431//! credentialed journey writes a project and waits for the board to report it, then writes a
432//! task and waits for the same thing seconds later on the same board: the project wait is
433//! answered through the search and converged in two or three attempts in each of three runs,
434//! and the task wait is answered through `ProjectV2.items` and converged in none of them
435//! inside thirty. Separately, an item added to a second and larger board was read back by
436//! `Issue.projectItems` on that board's own id while every one of that connection's nine
437//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
438//! through the lagging one alone is what had a board read deny an issue that had certainly
439//! landed on it.
440//!
441//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
442//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
443//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
444//! search reports what the projection is behind on. What closes the last
445//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
446//! source answers is completed with what this process itself wrote, so an item created
447//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
448//! nothing is written down, and the record dies with the process. **A wait that has to
449//! observe GitHub's own data cannot be answered from that record** — which is why the
450//! credentialed journey asks through a source built afresh, and why the union above rather
451//! than a longer wait is what makes such a wait converge.
452//!
453//! **A narrowed read is the same bargain, stated for each of the three predicates it
454//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
455//! than walking the board, and every such answer is completed with what this process wrote —
456//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
457//! each filtered by the same predicates as the rest — so an item this command wrote a moment
458//! ago is returned by a query that matches it whether or not the index has caught up. An item
459//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
460//! consistent. What is left is stated rather than papered over:
461//!
462//! | Read | Finds | Behind by |
463//! | --- | --- | --- |
464//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
465//! | origin, first read | the board's field filter over the origin field — every carrier, whichever release wrote it | what `ProjectV2.items` is behind on, which the measurements above put in minutes |
466//! | origin, second read | the issue search for the id in the body, where a write of this release mirrors it | a second or two, as any search |
467//! | origin, third read | this process's own writes | nothing |
468//!
469//! So an origin carrier another process added within the last second or two, before either
470//! index has it, can be missing from an origin query, and one written by the release before
471//! this one — its origin in the field alone — can be missing for as long as the board's own
472//! item connection is behind on it. A copy that must not duplicate its own earlier write
473//! relies on the link it records, not on either index. **A board draft is not an issue**, so
474//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
475//! lists one, the origin lookup drops any the board's own field filter names, and one this
476//! process wrote is not added back either.
477//!
478//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
479//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
480//! body's metadata slot, so the issue search can find it in seconds. The field is
481//! authoritative: this source reads an item's origin from the field alone, so a slot that
482//! disagrees with it, or holds one where the field holds none, is never read as a second
483//! origin — and the release before this one reads the slot, drops that key's copy for the
484//! field's, and sees the same one origin.
485//!
486//! Filtering happens before paging, so a page of a filtered result is a page of the
487//! survivors rather than the survivors of a page. Label matching and the substring rule a
488//! text candidate is confirmed by answer the same question the same way the local Markdown
489//! source's do; which candidates a text search has to confirm is GitHub's word match, which
490//! is the one place the two sources can answer the same text differently.
491//!
492//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
493//! has one source, `capabilities`, and the note above is the reasoning behind it rather
494//! than a second copy of it: without the three facts recorded here a reader takes the
495//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
496//! crate's own capabilities test, which pins every field of it against a fully spelled-out
497//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
498//! fails to compile there rather than going unasserted. -->
499//! The fixture-server tests above run wherever this crate is selected; the credentialed
500//! lane runs in the same required check, beside them, and can fail it — it verifies the
501//! current schema, then drives every field of the table above against the real board. It builds its own fixture there — two projects, one task filed under each,
502//! one filed under neither, a label on one of the three and a closed status on another —
503//! because that shape is what tells an honoured predicate from an ignored one: a board
504//! holding a single project answers a project filter the same way whether or not this
505//! source applies it, which is exactly how the defect above went unseen.
506//!
507//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
508//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
509//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
510//! nominated is what keeps a credentialed write lane off a board and a repository nobody
511//! nominated; it never asks GitHub which project was updated most recently. Before it
512//! starts, the lane also clears any item titled — and any repository label named — the way
513//! it titles and names its own artifacts, which is self-healing after an interrupted run:
514//! a process killed between its writes and its cleanup leaves artifacts the next run
515//! removes.
516//!
517//! # What a session of requests costs, and where the report is
518//!
519//! This source records **every** request it sends into [`accounting::Accounting`], at
520//! `send_once` — the one place a request leaves this crate, which is why a read path added
521//! later is counted without anybody remembering to count it. That is the whole of what this
522//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
523//! session's spend is arrived at, and what it deliberately does not know are set out.
524//!
525//! What one whole session of the live journey costs, counted that way against this crate's
526//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
527//! reduction it came out of, and with what it does and does not say about rate-limit points.
528//!
529//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
530//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
531//! code path — no environment variable, no feature, no build configuration — because an
532//! instrument nobody switches on measures nothing, and
533//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
534//! source's counts the whole session rather than this source's share. The credentialed lane
535//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
536//! passed or failed.
537//!
538//! **A live session refuses to start unless the account can afford it.** Before it does any
539//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
540//! GitHub documents as not counting against the REST rate limit and which answers both of
541//! its budgets at once — and starts only if, for each of them, what remains minus this
542//! session's estimated cost is still at least
543//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
544//! allowance. A session that cannot **declines**: it did not run, so it is
545//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
546//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
547//! waiting for the budget to come back. The estimate is derived offline from
548//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
549//! which is also where the published rule that model rests on is cited; the accounting
550//! above records the gate's own read like any other request, and
551//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
552//!
553//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
554//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
555//! text, which is what lets it run on every platform and on a pull request from a fork with
556//! no credential — and that is what actually stops a regression merging. But an offline
557//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
558//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
559//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
560//! number of nodes this query may return"* and whose `cost` is what that document would
561//! spend, both for a document **without executing it**, and the lane asks it for every query
562//! document this source sends, under the largest bindings this source sends, and fails when
563//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
564//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
565//! one; the offline pins still cover it. It records what those calls reported about the
566//! account's own allowance, because whether asking is free is a thing to observe rather than
567//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
568//! and `cost` is metered against an hourly allowance the accounting above reads off a
569//! credentialed run's own response headers.
570//!
571//! **GitHub has two rate limiters and this source is refused by both, so nothing here
572//! treats them as one thing.** The primary budget is the hourly allowance `gh api
573//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
574//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
575//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
576//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
577//! one part of.
578#![deny(missing_docs)]
579
580use std::collections::BTreeMap;
581use std::sync::{Arc, Mutex};
582use std::time::{Duration, Instant};
583
584use chrono::{DateTime, Utc};
585use onetaskgraph_plugin_api::{
586    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
587    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
588    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
589    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
590    SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
591    TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
592    UnmappedStatus, UpdatedField, WriteSupport,
593};
594use reqwest::{Client, StatusCode, Url};
595use schemars::{Schema, schema_for};
596use secrecy::{ExposeSecret, SecretString};
597use serde::{Deserialize, Serialize};
598use serde_json::{Value, json};
599
600pub mod accounting;
601
602use accounting::Accounting;
603
604/// The registry name for this plugin.
605pub const KIND: &str = "github-projects";
606/// GitHub's maximum connection page size.
607pub const MAX_PAGE_SIZE: u32 = 100;
608/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
609/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
610/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
611pub const SEARCH_PAGE_SIZE: u32 = 20;
612/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
613/// its comments: the largest batch the node-count model prices at one point.
614///
615/// Each aliased item is resolved once, and what GitHub charges for it is the connections
616/// under it — its labels, its page of board memberships, the field values of each of those
617/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
618/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
619/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
620/// one point and fails if one item more would still be priced at one.
621pub const DETAIL_BATCH: usize = 24;
622
623/// The most nodes any one document this source sends may be asked to return.
624///
625/// GitHub's own published per-query ceiling, taken from
626/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
627/// workspace cannot hold a stale copy of somebody else's number. A query above it is
628/// **refused before it is executed**, whoever is asking and whatever board they are
629/// asking about — so this is a bound on the documents rather than a budget that runs out.
630///
631/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
632/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
633/// everything the credential does — two numbers against two limits, and this constant
634/// bounds only the first. The second is computed offline too, per document:
635/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
636/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
637/// lane. There is no constant like this one to hold a price under, because points are an
638/// hourly allowance rather than a per-call bound.
639///
640/// Neither is a session's price. What `session-cost.md` records of a whole session is its
641/// **requests** and its **worst-case nodes**; what a whole session spends in points is
642/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
643/// [`accounting`]. The module section on the three ways this source reaches an item says how
644/// the count is arrived at, and which of the page sizes below decide it.
645pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
646
647/// Nested connection size for the connections that hang off one item.
648///
649/// It multiplies through every document that reaches an item under a page — the count
650/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
651/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
652/// every document under these constants and fails naming any that reaches the limit, so
653/// raising this is caught there rather than by GitHub.
654const NESTED_PAGE_SIZE: u32 = 50;
655/// How many of one issue's board memberships are read when an issue is reached directly.
656///
657/// An issue reached through a search or through its own node id carries its board half in
658/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
659/// under a page of issues, so every point of it multiplies through the whole document and
660/// is paid for whether or not any issue is on a second board — which is why it is
661/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
662///
663/// **Three, because what a page misses is now recovered rather than refused**, and the
664/// recovery is what the value is chosen against. An issue whose entry for this board sits
665/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
666/// that page's own cursor — so the value trades a bound every read pays for a request only
667/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
668/// boards would pay that request *per issue*, which is order N against the one page per
669/// hundred issues a read costs today. At three it is only reached by an issue on four or
670/// more boards at once, which keeps the recovery path exceptional rather than routine for
671/// a plausible deployment.
672const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
673/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
674/// its two connections for.
675///
676/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
677/// second is a duplicate a copy already takes the first of. Both connections are walked to
678/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
679/// never what the answer is. It is small because every point of it is paid on every lookup,
680/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
681/// worst-case nodes than the one whole-board read they replaced.
682const ORIGIN_PAGE_SIZE: u32 = 3;
683
684pub use github_graphql_node_count::{NodeCountError, Variables};
685
686/// The largest value this source can bind to each page-size variable its documents name.
687///
688/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
689/// constant above it wherever a caller's own limit could reach it — `$first` at
690/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
691/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
692/// not one configuration of it, which is what makes a bound computed under it a bound on
693/// every read.
694pub fn largest_page_sizes() -> Variables {
695    Variables::from([
696        ("first".to_owned(), MAX_PAGE_SIZE),
697        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
698        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
699        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
700    ])
701}
702
703/// The most nodes `document` could be asked to return, by GitHub's published rules.
704///
705/// Computed offline from the document's own text under [`largest_page_sizes`] — no
706/// network, no credential and no schema — by
707/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
708/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
709/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
710///
711/// # Errors
712///
713/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
714/// no single operation, or binds a page size this source does not name — each of which is
715/// a defect in the document rather than a number.
716pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
717    node_count(document, &largest_page_sizes())
718}
719
720/// The most rate-limit points one call of `document` could spend, by GitHub's published
721/// rules.
722///
723/// Computed offline from the document's own text under [`largest_page_sizes`] — no
724/// network, no credential and no schema — by
725/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
726/// This is `cost`, metered **per hour** against the allowance one credential shares across
727/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
728/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
729/// under, so what `tests/point_cost.rs` does with it is pin every document in
730/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
731/// figures against GitHub's own reported `cost`.
732///
733/// # Errors
734///
735/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
736/// no single operation, or binds a page size this source does not name — each of which is
737/// a defect in the document rather than a number.
738pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
739    github_graphql_node_count::point_cost(document, &largest_page_sizes())
740}
741
742/// The most nodes `document` could be asked to return under `variables`.
743///
744/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
745/// [`accounting`] is this under the bindings one request really sent — one spelling of the
746/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
747/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
748///
749/// # Errors
750///
751/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
752/// no single operation, or binds a page size `variables` does not name.
753pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
754    github_graphql_node_count::node_count(document, variables)
755}
756
757/// The issue-title prefix that makes a board issue a document.
758///
759/// A GitHub Projects board has no document type — it holds issues — so the discriminator
760/// is the title, and this is the whole of it: an issue whose title begins with these bytes
761/// is a document and every other issue is the task or project the sub-issue rule makes it.
762///
763/// It is spelled **once**, here, and read rather than restated everywhere else — including
764/// by the shared journeys, which take it from this constant so a board fixture cannot
765/// drift from what this source reads. `docs/metadata.md` records the two consequences that
766/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
767/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
768/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
769pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
770
771/// Exact GraphQL query documents issued by this plugin.
772///
773/// Keeping the production documents here lets the pinned-schema test validate the same
774/// bytes that are sent to GitHub, rather than a test-only copy which could drift
775/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
776/// field, and its guarded caller always supplies the complete existing option set with ids.
777pub mod graphql {
778    /// The board half of one item: the field values every document here reads it from.
779    ///
780    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
781    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
782    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
783    /// *the same value*, because
784    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
785    /// one path. Three spellings of it is what would drift, so there is one.
786    ///
787    /// The `Status` option and this source's own origin text field are the whole of it. It
788    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
789    /// content, so it holds nothing the content's own `labels` do not already say, and it
790    /// would sit a label connection two page sizes deep.
791    macro_rules! board_item_values {
792        () => {
793            r#"fieldValues(first:$nestedFirst){nodes{
794          ... on ProjectV2ItemFieldSingleSelectValue{name field{
795            ... on ProjectV2SingleSelectField{id name options{id name}}
796          }}
797          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
798        }pageInfo{hasNextPage}}"#
799        };
800    }
801
802    /// Everything this source reads about one issue, wherever it reaches that issue.
803    ///
804    /// A macro rather than a constant so the three documents below can `concat!` it: one
805    /// spelling of these fields is what makes an issue read through the board-scoped
806    /// search, through its own node id, and through its project's sub-issue relationship
807    /// resolve to *the same* item, which is the whole of what
808    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
809    ///
810    /// `projectItems` is what carries the board half of an issue: the board item's own id
811    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
812    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
813    /// issue rather than on the board, which is what makes the cost of a read proportional
814    /// to what was asked for instead of to the board's size.
815    ///
816    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
817    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
818    /// not on that page: a page here is where the search for the entry starts rather than
819    /// where it ends.
820    ///
821    /// It does **not** select the board's `Labels` field value, and that is the whole of
822    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
823    /// a label connection there sits under `fieldValues` under `projectItems` under a page
824    /// of issues, spending `$nestedFirst` twice down one path, and took
825    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
826    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
827    /// above, and that connection is where every label this source reports comes from. No
828    /// document in this module selects the board field any longer, [`BOARD`] included; the
829    /// module documentation records why nothing it could have held is lost.
830    macro_rules! board_issue {
831        () => {
832            concat!(
833                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
834      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
835      projectItems(first:$boardItems){nodes{id project{id number}
836        "#,
837                board_item_values!(),
838                r#"}pageInfo{hasNextPage endCursor}}}"#
839            )
840        };
841    }
842
843    /// Every issue of one board, found by a search scoped to that board.
844    ///
845    /// This is how the projects a board holds are listed, and it selects no `items`
846    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
847    /// container walked page by page, so nothing nested inside a board item is paid for.
848    /// Which of the issues it returns is a project is then read off `parent` — GitHub
849    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
850    /// discriminator has to be applied to the field, which is a scalar on the issue and
851    /// costs nothing.
852    pub const SEARCH_ISSUES: &str = concat!(
853        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
854      search(query:$search,type:$type,first:$first,after:$after){
855        pageInfo{hasNextPage endCursor}
856        nodes{__typename ...BoardIssue}
857      }
858    }"#,
859        board_issue!()
860    );
861
862    /// What a dependency read selects of each far end: enough to say which kind of item it
863    /// is, its body included for the kind marker.
864    macro_rules! related_issue {
865        () => {
866            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
867        };
868    }
869
870    /// One issue by its own node id, which is what a qualified id names here — with what a
871    /// write of it needs and the issue does not carry in `board_issue!`: the field
872    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
873    ///
874    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
875    /// answers a write made moments ago with the value from before it, and resolving a node
876    /// id does not.
877    ///
878    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
879    /// reads it by its own id, and with them that one read answers everything the write
880    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
881    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
882    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
883    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
884    /// they sit under one item, and this read is still one point.
885    pub const ISSUE: &str = concat!(
886        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
887      node(id:$id){__typename ...BoardIssue ... on Issue{
888        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
889          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
890          ... on ProjectV2Field{__typename id name}
891        }pageInfo{hasNextPage}}}}}
892        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
893      }}
894    }"#,
895        board_issue!(),
896        related_issue!()
897    );
898
899    /// One project's tasks: the sub-issues of the issue that project is.
900    ///
901    /// The work this costs is the project's own size. Nothing about it grows as the board
902    /// gains projects, or as those projects gain tasks.
903    pub const SUB_ISSUES: &str = concat!(
904        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
905      node(id:$id){__typename
906        ... on Issue{subIssues(first:$first,after:$after){
907          pageInfo{hasNextPage endCursor}
908          nodes{__typename ...BoardIssue}
909        }}}
910    }"#,
911        board_issue!()
912    );
913
914    /// What a read of the board's own `items` selects of each item's content.
915    ///
916    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
917    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
918    /// content by one spelling.
919    macro_rules! board_item_content {
920        () => {
921            r#" content{
922        ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
923        ... on PullRequest{__typename id}
924        ... on DraftIssue{__typename id title body createdAt updatedAt}
925      }"#
926        };
927    }
928
929    /// Reads the board's fields and one page of its items.
930    pub const BOARD: &str = concat!(
931        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
932      owner:repositoryOwner(login:$owner){
933        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
934      }
935    } fragment Board on ProjectV2 { id title
936      fields(first:$nestedFirst){nodes{
937        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
938        ... on ProjectV2Field{__typename id name}
939      }pageInfo{hasNextPage}}
940      items(first:$first,after:$after){nodes{id "#,
941        board_item_values!(),
942        board_item_content!(),
943        r#"} pageInfo{hasNextPage endCursor}}
944    }"#
945    );
946
947    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
948    /// the board.
949    ///
950    /// **`originItems`** is the board's own items narrowed by its own field filter —
951    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
952    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
953    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
954    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
955    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
956    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
957    /// fresh `addProjectV2ItemById` the way that connection does.
958    ///
959    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
960    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
961    /// indexes that comment, and the index catches up with a write in a second or two rather
962    /// than in minutes, so it finds a carrier another process wrote that the first read is
963    /// still behind on.
964    ///
965    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
966    /// — and resumes from its own cursor; a connection already walked to its end is resumed
967    /// from its last cursor, which answers an empty page. Every candidate either read returns
968    /// is confirmed against its own origin field before it is reported, so a token match of
969    /// the search or anything else the filter admits never is.
970    ///
971    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
972    /// own whole reads counts this one among them.
973    pub const ORIGIN_LOOKUP: &str = concat!(
974        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
975      originItems:repositoryOwner(login:$owner){
976        ... on ProjectV2Owner{projectV2(number:$number){
977          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
978        board_item_values!(),
979        board_item_content!(),
980        r#"} pageInfo{hasNextPage endCursor}}
981        }}
982      }
983      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
984        pageInfo{hasNextPage endCursor}
985        nodes{__typename ...BoardIssue}
986      }
987    }"#,
988        board_issue!()
989    );
990
991    /// The board's own id and field definitions, and not one of its items.
992    ///
993    /// What a write needs of the board when the item it writes does not say: the id a field
994    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
995    /// origin fields. It selects no `items`, so what it costs is the board's field list
996    /// however many items the board holds — and it decides nothing about which items those
997    /// are, which is the question a read of one item by its own id answers instead.
998    ///
999    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1000    /// board's item reads by their root counts this one among them.
1001    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1002      boardFields:repositoryOwner(login:$owner){
1003        ... on ProjectV2Owner{projectV2(number:$number){id
1004          fields(first:$nestedFirst){nodes{
1005            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1006            ... on ProjectV2Field{__typename id name}
1007          }pageInfo{hasNextPage}}
1008        }}
1009      }
1010    }"#;
1011
1012    /// One board draft by its own node id, with the board item it sits in.
1013    ///
1014    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1015    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1016    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1017    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1018    /// board listing hands it to, and nothing has to list the board to find one.
1019    pub const DRAFT: &str = concat!(
1020        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1021      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1022        projectV2Items(first:$boardItems){nodes{id project{id number}
1023        "#,
1024        board_item_values!(),
1025        r#"}pageInfo{hasNextPage endCursor}}}}
1026    }"#
1027    );
1028
1029    /// One issue's board memberships alone, walked past the page a read of it carried.
1030    ///
1031    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1032    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1033    /// boards than that page holds may have this board's entry past its end. This asks that
1034    /// one issue for its memberships and nothing else — the caller already holds the issue —
1035    /// so an answer of "this board does not hold it" is only ever given about a connection
1036    /// read to exhaustion.
1037    ///
1038    /// It selects the board item's id, its project number and the same
1039    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1040    /// very same resolver: an issue recovered this way reports the same title, the same
1041    /// status, the same labels and the same qualified id as one whose entry was on the
1042    /// page.
1043    ///
1044    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1045    /// multiplies through it and the membership connection can be walked at
1046    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1047    /// further request for any issue a person really keeps.
1048    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1049        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1050      node(id:$id){
1051        ... on Issue{projectItems(first:$first,after:$after){
1052          nodes{id project{id number}
1053        "#,
1054        board_item_values!(),
1055        r#"}
1056          pageInfo{hasNextPage endCursor}}}
1057      }
1058    }"#
1059    );
1060    /// Resolves the configured repository's node id, which creating an issue requires.
1061    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1062    /// What creating an issue needs and has not read yet: the board's own id and field
1063    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1064    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1065    ///
1066    /// Sent at the point a create knows which repository it is for, when neither half is
1067    /// already known to this process; a create needing only one of them sends that one's own
1068    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1069    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1070    /// status.
1071    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1072      boardFields:repositoryOwner(login:$owner){
1073        ... on ProjectV2Owner{projectV2(number:$number){id
1074          fields(first:$nestedFirst){nodes{
1075            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1076            ... on ProjectV2Field{__typename id name}
1077          }pageInfo{hasNextPage}}
1078        }}
1079      }
1080      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1081    }"#;
1082    /// Reads both dependency directions for one issue, with each far end's own kind — and
1083    /// the issue's own body, which is where an edge to another source is recorded, so that
1084    /// half of a dependency read needs no second read of the issue or of the board.
1085    pub const ISSUE_DEPENDENCIES: &str = concat!(
1086        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1087      ... on Issue{body
1088        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1089        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1090      }}}"#,
1091        related_issue!()
1092    );
1093    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1094    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1095    /// answered when it was.
1096    pub const CREATE_ISSUE: &str =
1097        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1098    /// Puts an existing issue on the configured board.
1099    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1100    /// Updates an issue's visible fields and its open or closed state in one call.
1101    pub const UPDATE_ISSUE: &str =
1102        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1103    /// Updates an existing draft's user-visible fields.
1104    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1105    /// Updates a text or single-select value on one project item.
1106    pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1107    /// Writes up to three board fields and an optional clear in one ordered mutation.
1108    pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeClear:Boolean!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}}}"#;
1109    /// Clears one project item's value of one field, which is what a `none` priority is.
1110    pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1111    /// Creates one single-select field with its options. Only the guarded field setup may use
1112    /// this document, and only for a field the board lacks.
1113    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1114    /// Replaces a single-select field's options. Only the guarded field setup — the
1115    /// `status-options` and `fields` operations — may use this document, because GitHub
1116    /// treats the input as the complete option list.
1117    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1118    /// A fresh snapshot of the Status field and every board item's assignment.
1119    pub const STATUS_OPTIONS_SNAPSHOT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){owner:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id fields(first:$nestedFirst){nodes{... on ProjectV2SingleSelectField{id name options{id name color description}}}pageInfo{hasNextPage}} items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{... on ProjectV2ItemFieldSingleSelectValue{name optionId field{... on ProjectV2SingleSelectField{id name}}}}pageInfo{hasNextPage}}}pageInfo{hasNextPage endCursor}}}}}}"#;
1120    /// Files one issue under another as a sub-issue, which is what project membership is.
1121    pub const ADD_SUB_ISSUE: &str =
1122        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1123    /// Takes one issue back out of its parent.
1124    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1125    /// Adds GitHub's native issue blocked-by relationship.
1126    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1127    /// Removes one native issue blocked-by relationship.
1128    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1129    /// Deletes one issue, which takes its board item with it.
1130    ///
1131    /// The engine sends this in one situation only: undoing a copy that could not finish,
1132    /// over the items that same copy created. Deleting the issue removes the board item
1133    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1134    pub const DELETE_ISSUE: &str =
1135        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1136
1137    /// Everything this source reads about one issue comment, wherever it reaches one.
1138    ///
1139    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1140    /// and a comment just edited are handed to one mapper, so they are selected by one
1141    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1142    /// longer exists, and `login` is the one member every kind of actor carries.
1143    macro_rules! issue_comment {
1144        () => {
1145            "id author{login} createdAt updatedAt body url"
1146        };
1147    }
1148
1149    /// One task's comments: a page of its issue's own `comments` connection.
1150    ///
1151    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1152    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1153    /// list every time somebody edited it; left unordered the connection answers in the order
1154    /// the comments were written, which is the order GitHub documents for the same collection
1155    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1156    /// node count and the caller's own page size is pushed straight down.
1157    pub const ISSUE_COMMENTS: &str = concat!(
1158        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1159        issue_comment!(),
1160        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1161    );
1162    /// One issue by its own node id, with a page of its comments: what `task show` and a
1163    /// comment listing read, in one request.
1164    ///
1165    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1166    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1167    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1168    /// connection there would multiply through both of those documents' price, and neither
1169    /// needs one.
1170    pub const ISSUE_DETAIL: &str = concat!(
1171        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1172      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173        issue_comment!(),
1174        r#"}pageInfo{hasNextPage endCursor}}}}
1175    }"#,
1176        board_issue!()
1177    );
1178
1179    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1180    /// page of its comments when `$comments` asks for them.
1181    macro_rules! issue_details_alias {
1182        ($n:literal) => {
1183            concat!(
1184                "\n      i",
1185                stringify!($n),
1186                ":node(id:$id",
1187                stringify!($n),
1188                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1189                issue_comment!(),
1190                "}pageInfo{hasNextPage endCursor}}}}"
1191            )
1192        };
1193    }
1194
1195    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1196    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1197    ///
1198    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1199    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1200    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1201    /// neither — so every connection under it would be priced at nothing and the pin in
1202    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1203    /// one-item read the model already prices, so the batch costs what its aliases cost.
1204    ///
1205    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1206    /// slots it has no item for to the last item it does, and reads that item again; the
1207    /// price is the document's, whatever its variables, so a short batch costs what a full
1208    /// one does and nothing more.
1209    pub const ISSUE_DETAILS: &str = concat!(
1210        r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$id21:ID!,$id22:ID!,$id23:ID!,$first:Int!,$comments:Boolean!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){"#,
1211        issue_details_alias!(0),
1212        issue_details_alias!(1),
1213        issue_details_alias!(2),
1214        issue_details_alias!(3),
1215        issue_details_alias!(4),
1216        issue_details_alias!(5),
1217        issue_details_alias!(6),
1218        issue_details_alias!(7),
1219        issue_details_alias!(8),
1220        issue_details_alias!(9),
1221        issue_details_alias!(10),
1222        issue_details_alias!(11),
1223        issue_details_alias!(12),
1224        issue_details_alias!(13),
1225        issue_details_alias!(14),
1226        issue_details_alias!(15),
1227        issue_details_alias!(16),
1228        issue_details_alias!(17),
1229        issue_details_alias!(18),
1230        issue_details_alias!(19),
1231        issue_details_alias!(20),
1232        issue_details_alias!(21),
1233        issue_details_alias!(22),
1234        issue_details_alias!(23),
1235        "\n    }",
1236        board_issue!()
1237    );
1238
1239    /// Which issue one comment is on, read before that comment is edited or removed.
1240    ///
1241    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1242    /// comment id given against the wrong task would change a comment on another issue.
1243    pub const COMMENT_ISSUE: &str =
1244        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1245    /// Adds one comment to an issue, signed as the account the token belongs to.
1246    pub const ADD_COMMENT: &str = concat!(
1247        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1248        issue_comment!(),
1249        r#"}}}}"#
1250    );
1251    /// Replaces the body of one issue comment.
1252    pub const UPDATE_COMMENT: &str = concat!(
1253        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1254        issue_comment!(),
1255        r#"}}}"#
1256    );
1257    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1258    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1259
1260    /// Every document above, with what this source is doing when it sends one.
1261    ///
1262    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1263    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1264    /// document added later with "talking to GitHub" and never say so.
1265    ///
1266    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1267    /// const` here that this list omits, so the two cannot part — which is the same guard
1268    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1269    pub const DOCUMENTS: [(&str, &str); 33] = [
1270        (SEARCH_ISSUES, "searching this board's issues"),
1271        (ISSUE, "reading one issue"),
1272        (
1273            ISSUE_BOARD_ITEMS,
1274            "reading one issue's board memberships past the page it came with",
1275        ),
1276        (SUB_ISSUES, "reading a project's tasks"),
1277        (BOARD, "reading the board"),
1278        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1279        (BOARD_FIELDS, "reading the board's fields"),
1280        (DRAFT, "reading one draft"),
1281        (REPOSITORY, "reading the destination repository"),
1282        (
1283            CREATION_CONTEXT,
1284            "reading the board's fields and the destination repository",
1285        ),
1286        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1287        (CREATE_ISSUE, "creating an issue"),
1288        (ADD_TO_BOARD, "adding an issue to the board"),
1289        (UPDATE_ISSUE, "updating an issue"),
1290        (UPDATE_DRAFT, "updating a draft item"),
1291        (UPDATE_FIELD, "writing a board field"),
1292        (UPDATE_FIELDS, "writing board fields together"),
1293        (CLEAR_FIELD, "clearing a board field"),
1294        (
1295            CREATE_FIELD,
1296            "creating a board single-select field with its options",
1297        ),
1298        (
1299            STATUS_OPTIONS_SNAPSHOT,
1300            "snapshotting board Status options and assignments",
1301        ),
1302        (
1303            STATUS_OPTIONS_UPDATE,
1304            "safely replacing the board Status option list",
1305        ),
1306        (ADD_SUB_ISSUE, "filing an issue under its project"),
1307        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1308        (ADD_BLOCKED_BY, "recording a dependency"),
1309        (REMOVE_BLOCKED_BY, "removing a dependency"),
1310        (DELETE_ISSUE, "deleting an issue"),
1311        (ISSUE_COMMENTS, "reading a task's comments"),
1312        (ISSUE_DETAIL, "reading one issue with its comments"),
1313        (
1314            ISSUE_DETAILS,
1315            "reading a batch of issues with their comments",
1316        ),
1317        (COMMENT_ISSUE, "reading which issue a comment is on"),
1318        (ADD_COMMENT, "adding a comment"),
1319        (UPDATE_COMMENT, "editing a comment"),
1320        (DELETE_COMMENT, "deleting a comment"),
1321    ];
1322}
1323
1324/// Which of GitHub's two rate limiters refused a request.
1325///
1326/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1327/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1328/// the whole reason this is carried rather than collapsed into "rate limited".
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum Limiter {
1331    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1332    Primary,
1333    /// The burst limiter over content-generating requests, which nothing reports.
1334    Secondary,
1335}
1336
1337/// The wordings GitHub answers a secondary rate limit with.
1338///
1339/// It sends them under a forbidden status, under a too-many-requests status, and inside
1340/// the `errors` of a *successful* response, which is why the text is what this matches on
1341/// rather than the status. `abuse detection` is the wording GitHub used before the
1342/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1343/// what a burst of content creation is refused with.
1344///
1345/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1346/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1347/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1348/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1349pub const SECONDARY_WORDINGS: [&str; 5] = [
1350    "secondary rate limit",
1351    "temporarily blocked from content creation",
1352    "abuse detection",
1353    "submitted too quickly",
1354    "exceeded a secondary",
1355];
1356
1357/// The wordings GitHub answers an exhausted primary budget with.
1358///
1359/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1360/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1361/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1362/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1363/// two phrases is a substring of it, so without it that answer read as a refusal that will
1364/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1365/// one reason.
1366pub const PRIMARY_WORDINGS: [&str; 4] = [
1367    "api rate limit exceeded",
1368    "api rate limit already exceeded",
1369    "rate limit exceeded",
1370    "rate_limited",
1371];
1372
1373/// What a response *says about itself*, which is the only place a refusal can be read.
1374///
1375/// Deliberately not the whole response body. A board is a place people write about their
1376/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1377/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1378/// reported. So the item data is never read: what is read is GitHub's own REST-style
1379/// `message` envelope, which is what a forbidden status carries, and the `message` and
1380/// `type` of each GraphQL error, which is where a *successful* response says it.
1381///
1382/// A body that is not JSON at all has nothing structured to read, so only a failing
1383/// response's own text is taken — a successful response that is not JSON is malformed
1384/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1385fn refusal_wording(status: StatusCode, body: &str) -> String {
1386    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1387        return if status.is_success() {
1388            String::new()
1389        } else {
1390            body.to_owned()
1391        };
1392    };
1393    let mut said: Vec<&str> = parsed
1394        .get("message")
1395        .and_then(Value::as_str)
1396        .into_iter()
1397        .collect();
1398    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1399        for error in errors {
1400            said.extend(
1401                ["message", "type"]
1402                    .into_iter()
1403                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1404            );
1405        }
1406    }
1407    said.join("; ")
1408}
1409
1410impl Limiter {
1411    /// Which limiter refused this response, or `None` when none of them did.
1412    ///
1413    /// The wording is read first and the status only decides what carries none of it,
1414    /// because GitHub answers a secondary limit with a forbidden status far more often
1415    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1416    /// really is a credential this token lacks.
1417    ///
1418    /// A response is a refusal because of its status or its own wording. A spent budget
1419    /// only ever explains one; it never turns an answer into a refusal.
1420    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1421        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1422        if SECONDARY_WORDINGS
1423            .iter()
1424            .any(|wording| normalized.contains(wording))
1425        {
1426            return Some(Self::Secondary);
1427        }
1428        if status == StatusCode::TOO_MANY_REQUESTS {
1429            return Some(Self::Primary);
1430        }
1431        // An exhausted budget *explains* a response that failed; it does not make one that
1432        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1433        // request the budget allowed as well as on the ones it then refuses, so reading
1434        // the header alone threw away a good answer — and, once refusals were retried,
1435        // replayed a request that had already taken effect.
1436        if !status.is_success() && budget_exhausted {
1437            return Some(Self::Primary);
1438        }
1439        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1440        // `errors` of an HTTP 200, where nothing about the status says so at all.
1441        if status.is_success()
1442            && PRIMARY_WORDINGS
1443                .iter()
1444                .any(|wording| normalized.contains(wording))
1445        {
1446            return Some(Self::Primary);
1447        }
1448        None
1449    }
1450
1451    /// What this limiter is called where an operator can look it up.
1452    const fn name(self) -> &'static str {
1453        match self {
1454            Self::Primary => "GitHub's primary API rate limit",
1455            Self::Secondary => "GitHub's secondary rate limit",
1456        }
1457    }
1458
1459    /// What the endpoint an operator would go and check says about this limiter.
1460    const fn where_to_look(self) -> &'static str {
1461        match self {
1462            Self::Primary => {
1463                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1464                 comes back."
1465            }
1466            Self::Secondary => {
1467                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1468                 primary budget and does not report this one, so budget showing there says \
1469                 nothing about this refusal, and every further attempt extends it."
1470            }
1471        }
1472    }
1473
1474    /// The next step this limiter actually calls for.
1475    const fn what_to_do(self) -> &'static str {
1476        match self {
1477            Self::Primary => {
1478                "wait for the reset `gh api rate_limit` reports, then run the command again."
1479            }
1480            Self::Secondary => {
1481                "leave this board alone for a few minutes, then run the command again — or \
1482                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1483            }
1484        }
1485    }
1486}
1487
1488/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1489#[derive(Debug, Clone, Copy)]
1490struct Limited {
1491    limiter: Limiter,
1492    hint: Option<u64>,
1493}
1494
1495impl Limited {
1496    /// What the caller is told once this source has waited as long as it may.
1497    ///
1498    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1499    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1500    /// about *which* limiter it was makes it a different kind of failure. What differs is
1501    /// the operator's next step, and that is what the message carries — a secondary
1502    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1503    /// budget looks fine, and then back to retry the very burst that was refused.
1504    fn exhausted(
1505        self,
1506        doing: &str,
1507        waits: u32,
1508        waited: Duration,
1509        needed: Duration,
1510        budget: Duration,
1511    ) -> SourceError {
1512        SourceError::RateLimited {
1513            retry_after_seconds: self.hint,
1514            message: Some(format!(
1515                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1516                 again, and the next wait of {} would take it past the {} one call may spend \
1517                 waiting. {} next: {}",
1518                self.limiter.name(),
1519                plural(waits, "refusal"),
1520                seconds(waited),
1521                seconds(needed),
1522                seconds(budget),
1523                self.limiter.where_to_look(),
1524                self.limiter.what_to_do(),
1525            )),
1526        }
1527    }
1528}
1529
1530/// One HTTP attempt's result, with what its response said about the rate limit.
1531///
1532/// The two travel together so the record and the outcome are written from the same place:
1533/// what a response said about the budget is only readable while that response is in hand,
1534/// and what the attempt *meant* is only decidable once its body has been read.
1535struct Attempted {
1536    result: Result<Value, Attempt>,
1537    limits: accounting::RateLimit,
1538    /// GitHub's own reported cost for this call, for a document that asked for it.
1539    reported_cost: Option<u64>,
1540}
1541
1542/// One attempt's outcome: an error to report, or a rate limit to wait out.
1543enum Attempt {
1544    Failed(SourceError),
1545    Limited(Limited),
1546}
1547
1548fn plural(count: u32, thing: &str) -> String {
1549    if count == 1 {
1550        format!("{count} {thing}")
1551    } else {
1552        format!("{count} {thing}s")
1553    }
1554}
1555
1556fn seconds(duration: Duration) -> String {
1557    format!("{:.1}s", duration.as_secs_f64())
1558}
1559
1560/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1561///
1562/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1563/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1564/// header, and neither is what makes a response a refusal — so the whole cost of one this
1565/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1566/// instead. Refusing the response over the header would turn a readable refusal into an
1567/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1568fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1569    value
1570        .and_then(|value| value.to_str().ok())
1571        .and_then(|value| value.trim().parse::<u64>().ok())
1572}
1573
1574/// Every mutation this source sends creates content — an issue, a board item, a field of
1575/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1576/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1577/// and what the keyword says are the same set. That is what makes the keyword a sound test
1578/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1579/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1580fn is_mutation(query: &str) -> bool {
1581    query.trim_start().starts_with("mutation")
1582}
1583
1584/// What this source was doing, for a diagnostic that has to say so.
1585///
1586/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1587/// a document added without a description is caught by that list's own gate instead of
1588/// falling through to the vague arm below.
1589fn operation_description(query: &str) -> &'static str {
1590    graphql::DOCUMENTS
1591        .iter()
1592        .find(|(document, _)| *document == query)
1593        .map_or("talking to GitHub", |(_, doing)| *doing)
1594}
1595
1596/// GitHub's published ceiling on content-generating requests, per minute.
1597///
1598/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1599/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1600/// from it, so a pacing value checked only against itself cannot go stale here.
1601pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1602/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1603/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1604/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1605pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1606/// Shortest interval between two content-creating mutations, in milliseconds.
1607///
1608/// GitHub documents two secondary limits on content-generating requests:
1609/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1610/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1611/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1612/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1613/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1614/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1615/// deliberately *not* what this paces at. An installation that wants the hourly bound
1616/// honoured for a long sequence of copies says so through
1617/// `pacing.min_mutation_interval_ms`.
1618pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1619/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1620///
1621/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1622/// own advice for a secondary limit — wait, and wait longer each time — without spending
1623/// the first minute of a transient refusal doing nothing.
1624pub const RETRY_BACKOFF_MS: u64 = 1_000;
1625/// Total time one call may spend waiting out rate limits before it reports a failure.
1626///
1627/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1628/// short enough that a command an operator is watching returns. The bound is what makes
1629/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1630/// the limiter, not in a process nobody can tell from a wedged one.
1631pub const RETRY_BUDGET_MS: u64 = 120_000;
1632
1633fn default_token_env() -> String {
1634    "GH_PROJECTS_TOKEN".to_owned()
1635}
1636fn default_endpoint() -> String {
1637    "https://api.github.com/graphql".to_owned()
1638}
1639
1640/// The name of a `Status` single-select option on the board.
1641///
1642/// Validated on the way in rather than checked later, so a blank option name — which
1643/// nothing on a board can be — is a state this type cannot hold.
1644#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1645#[serde(try_from = "String")]
1646#[schemars(extend("minLength" = 1))]
1647pub struct ColumnName(String);
1648
1649impl ColumnName {
1650    /// The option name, as the board spells it.
1651    fn as_str(&self) -> &str {
1652        &self.0
1653    }
1654}
1655
1656impl TryFrom<String> for ColumnName {
1657    type Error = String;
1658
1659    fn try_from(name: String) -> Result<Self, Self::Error> {
1660        if name.trim().is_empty() {
1661            return Err("a status_mapping option name cannot be blank".to_owned());
1662        }
1663        Ok(Self(name))
1664    }
1665}
1666
1667/// The two closed states this product can mean.
1668///
1669/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1670/// work nor abandoned work, so nothing here ever writes it.
1671#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1672#[serde(rename_all = "kebab-case")]
1673pub enum ClosedState {
1674    /// `COMPLETED` — precisely done.
1675    Completed,
1676    /// `NOT_PLANNED` — precisely cancelled.
1677    NotPlanned,
1678}
1679
1680impl ClosedState {
1681    const fn reason(self) -> &'static str {
1682        match self {
1683            Self::Completed => "COMPLETED",
1684            Self::NotPlanned => "NOT_PLANNED",
1685        }
1686    }
1687}
1688
1689/// Configuration for one GitHub Projects v2 board.
1690#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1691#[serde(default, deny_unknown_fields)]
1692pub struct GitHubProjectsConfig {
1693    /// Login of the user or organization which owns the board.
1694    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1695    /// The project number shown in the board's GitHub URL.
1696    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1697    // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This doc is the field's schema description, which is what a person configuring the source reads, so it has to say when the field decides an issue's repository and when the item's own field does; the rule's one executable source is `GitHubProjectsSource::creation_target`, and `tests/plugin.rs` drives each case named here against the loopback board.
1698    /// `owner/name` of the repository this source creates an issue in when the item's own
1699    /// `repositories` field does not decide it.
1700    ///
1701    /// An item naming exactly one repository is created there; a task or a document naming
1702    /// none or several is created in its parent project's repository; and a project, or a
1703    /// task or document with no parent, naming none or several is created here. A board
1704    /// has no repository of its own and `createIssue` requires one, so a write without
1705    /// this is refused naming the field. Reads never need it.
1706    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1707    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1708    /// Environment variable containing a fine-grained token with Projects and Issues
1709    /// read/write plus Pull requests read-only access for every repository represented on
1710    /// the board.
1711    #[serde(default = "default_token_env")]
1712    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1713    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1714    #[serde(default = "default_endpoint")]
1715    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1716    /// Per-instance mapping from a status category to the option of the board's one
1717    /// `Status` field it lands on, for a task and for a project.
1718    ///
1719    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1720    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1721    /// where a kind left out leaves the category unmapped for that kind. A category this
1722    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1723    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1724    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1725    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1726    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1727    /// either kind. No two categories may name one option for the same kind, ignoring case.
1728    /// `unknown` may name one existing option; every unknown word then lands on it and
1729    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1730    /// each unknown word because it never creates board options.
1731    #[serde(default)]
1732    pub status_mapping: StatusMapping,
1733    /// Per-instance mapping from a task's priority to an option of this board's
1734    /// single-select field named `Priority`.
1735    ///
1736    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1737    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1738    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1739    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1740    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1741    /// no two levels may name one option. Reads and writes never create the field or an
1742    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1743    /// the board lacks is refused pointing there.
1744    #[serde(default)]
1745    pub priority_mapping: Option<PriorityMappingConfig>,
1746    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1747    ///
1748    /// Every field keeps its shipped default when it is absent, and the defaults are
1749    /// GitHub's own published limits rather than taste. See [`Pacing`].
1750    #[serde(default)]
1751    pub pacing: PacingConfig,
1752}
1753
1754/// Which option of the board's `Priority` field each priority lands on.
1755///
1756/// One member per level rather than a map, so a key that is not a level is refused where
1757/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1758/// value in the field, not an option of it.
1759#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1760#[serde(default, deny_unknown_fields)]
1761pub struct PriorityMappingConfig {
1762    /// The option `urgent` lands on; `Urgent` when absent.
1763    pub urgent: Option<PriorityOptionName>,
1764    /// The option `high` lands on; `High` when absent.
1765    pub high: Option<PriorityOptionName>,
1766    /// The option `medium` lands on; `Medium` when absent.
1767    pub medium: Option<PriorityOptionName>,
1768    /// The option `low` lands on; `Low` when absent.
1769    pub low: Option<PriorityOptionName>,
1770}
1771
1772/// The name of an option of the board's `Priority` single-select field.
1773///
1774/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1775/// blank name.
1776#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1777#[serde(try_from = "String")]
1778#[schemars(extend("minLength" = 1))]
1779pub struct PriorityOptionName(String);
1780
1781impl PriorityOptionName {
1782    /// The option name, as the board spells it.
1783    fn as_str(&self) -> &str {
1784        &self.0
1785    }
1786}
1787
1788impl TryFrom<String> for PriorityOptionName {
1789    type Error = String;
1790
1791    fn try_from(name: String) -> Result<Self, Self::Error> {
1792        if name.trim().is_empty() {
1793            return Err("a priority_mapping option name cannot be blank".to_owned());
1794        }
1795        Ok(Self(name))
1796    }
1797}
1798
1799/// The name of the board field a priority is held in.
1800pub const PRIORITY_FIELD: &str = "Priority";
1801
1802/// The four priorities a board option can hold, in the order a new `Priority` field lists
1803/// them. `none` is not among them: it is the field holding no value.
1804///
1805/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1806/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1807/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1808/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1809pub const PRIORITY_LEVELS: [Priority; 4] = [
1810    Priority::Urgent,
1811    Priority::High,
1812    Priority::Medium,
1813    Priority::Low,
1814];
1815
1816/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1817/// see that list for what this pins.
1818#[must_use]
1819pub const fn level_position(priority: Priority) -> Option<usize> {
1820    match priority {
1821        Priority::None => None,
1822        Priority::Urgent => Some(0),
1823        Priority::High => Some(1),
1824        Priority::Medium => Some(2),
1825        Priority::Low => Some(3),
1826    }
1827}
1828
1829/// This instance's complete priority-to-option mapping, read in both directions.
1830///
1831/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1832/// two levels name one option.
1833#[derive(Debug, Clone)]
1834struct PriorityMapping {
1835    options: [PriorityOptionName; 4],
1836}
1837
1838impl PriorityMapping {
1839    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1840        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1841        let mapping = Self {
1842            options: [
1843                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1844                config.high.unwrap_or_else(|| shipped("High")),
1845                config.medium.unwrap_or_else(|| shipped("Medium")),
1846                config.low.unwrap_or_else(|| shipped("Low")),
1847            ],
1848        };
1849        for (index, option) in mapping.options.iter().enumerate() {
1850            if let Some(earlier) = mapping.options[..index]
1851                .iter()
1852                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1853            {
1854                return Err(SourceError::Config {
1855                    message: format!(
1856                        "priority_mapping of source {instance} sends both {} and {} to the board \
1857                         option {:?}; one option cannot read back as two priorities",
1858                        PRIORITY_LEVELS[earlier],
1859                        PRIORITY_LEVELS[index],
1860                        option.as_str()
1861                    ),
1862                });
1863            }
1864        }
1865        Ok(mapping)
1866    }
1867
1868    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1869    fn option(&self, priority: Priority) -> Option<&str> {
1870        level_position(priority).map(|index| self.options[index].as_str())
1871    }
1872
1873    /// The priority a board option name reports, or `None` when nothing maps to it.
1874    fn priority_of(&self, option: &str) -> Option<Priority> {
1875        self.options
1876            .iter()
1877            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1878            .map(|index| PRIORITY_LEVELS[index])
1879    }
1880
1881    /// Every mapped option name, in the order a new `Priority` field lists them.
1882    fn names(&self) -> impl Iterator<Item = &str> {
1883        self.options.iter().map(PriorityOptionName::as_str)
1884    }
1885}
1886
1887/// What one item's `Priority` field says, read through this instance's mapping.
1888#[derive(Debug, Clone, PartialEq, Eq)]
1889enum HeldPriority {
1890    /// A priority this source reports: an option the mapping names, or no value (`none`).
1891    Read(Priority),
1892    /// An option the mapping does not name, which is never read as a level or as `none`.
1893    Unmapped(String),
1894}
1895
1896/// How fast this source writes, and how long it waits out a rate-limit refusal.
1897///
1898/// Configurable because a GitHub Enterprise installation sets its own limits and an
1899/// operator who has already been refused may want to go slower still — not because the
1900/// defaults are guesses.
1901#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1902#[serde(default, deny_unknown_fields)]
1903pub struct PacingConfig {
1904    /// Shortest interval between two content-creating mutations, in milliseconds.
1905    ///
1906    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1907    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1908    pub min_mutation_interval_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
1909    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1910    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1911    /// zero while there is a budget to spend, because a schedule of zero-length waits
1912    /// consumes none of it and so never ends.
1913    pub retry_backoff_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` refuses a non-progressing zero and bounds the rest before the private validated `Pacing` is built.
1914    /// Total time one call may spend waiting out rate limits, in milliseconds.
1915    ///
1916    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1917    /// the bound is what makes this a wait rather than a hang.
1918    pub retry_budget_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
1919}
1920
1921/// The largest any pacing setting may be, in milliseconds.
1922///
1923/// One hour. GitHub's own harshest published bound on content-generating requests works
1924/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1925/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1926/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1927/// and an interval beyond it is a command that never sends its second mutation. It also
1928/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1929/// what an `Instant` can hold on every platform.
1930pub const MAX_PACING_MS: u64 = 3_600_000;
1931
1932/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1933/// source holds.
1934#[derive(Debug, Clone, Copy)]
1935struct Pacing {
1936    min_mutation_interval: Duration,
1937    retry_backoff: Duration,
1938    retry_budget: Duration,
1939}
1940
1941impl Pacing {
1942    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1943    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1944        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1945            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1946                message: format!(
1947                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1948                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1949                     GitHub's own harshest published limit"
1950                ),
1951            }),
1952            Some(value) => Ok(Duration::from_millis(value)),
1953            None => Ok(Duration::from_millis(default)),
1954        };
1955        let retry_backoff = bounded(
1956            config.retry_backoff_ms,
1957            RETRY_BACKOFF_MS,
1958            "retry_backoff_ms",
1959        )?;
1960        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1961        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1962            return Err(SourceError::Config {
1963                message: format!(
1964                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1965                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1966                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1967                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1968                     waiting at all",
1969                    retry_budget.as_millis()
1970                ),
1971            });
1972        }
1973        Ok(Self {
1974            min_mutation_interval: bounded(
1975                config.min_mutation_interval_ms,
1976                MIN_MUTATION_INTERVAL_MS,
1977                "min_mutation_interval_ms",
1978            )?,
1979            retry_backoff,
1980            retry_budget,
1981        })
1982    }
1983}
1984
1985/// Factory for [`GitHubProjectsSource`].
1986#[derive(Debug, Clone, Copy, Default)]
1987pub struct Plugin;
1988
1989impl SourcePlugin for Plugin {
1990    fn kind(&self) -> &'static str {
1991        KIND
1992    }
1993    fn config_schema(&self) -> Schema {
1994        schema_for!(GitHubProjectsConfig)
1995    }
1996    fn build(
1997        &self,
1998        name: &SourceName,
1999        config: &Value,
2000        secrets: &dyn SecretResolver,
2001    ) -> Result<Box<dyn TaskSource>, SourceError> {
2002        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2003    }
2004}
2005
2006impl Plugin {
2007    /// Build a source recording every request it sends into an accounting the caller holds.
2008    ///
2009    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2010    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2011    /// session total rather than two — see [`accounting`] and
2012    /// [`GitHubProjectsSource::recording_into`].
2013    ///
2014    /// # Errors
2015    ///
2016    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2017    /// [`SourceError::Config`] for configuration this plugin cannot use and
2018    /// [`SourceError::Auth`] for a credential it cannot find.
2019    pub fn build_recording_into(
2020        &self,
2021        name: &SourceName,
2022        config: &Value,
2023        secrets: &dyn SecretResolver,
2024        ledger: Arc<Accounting>,
2025    ) -> Result<Box<dyn TaskSource>, SourceError> {
2026        let config: GitHubProjectsConfig =
2027            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2028                message: format!("source {name}: {e}"),
2029            })?;
2030        let prefix = format!("source {name}: ");
2031        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2032            |error| match error {
2033                // The shared `StatusMapping::distinct` names the source itself.
2034                SourceError::Config { message } if message.starts_with(&prefix) => {
2035                    SourceError::Config { message }
2036                }
2037                SourceError::Config { message } => SourceError::Config {
2038                    message: format!("{prefix}{message}"),
2039                },
2040                SourceError::Auth { message } => SourceError::Auth {
2041                    message: format!("source {name}: {message}"),
2042                },
2043                other => other,
2044            },
2045        )?;
2046        Ok(Box::new(source))
2047    }
2048}
2049
2050/// Where a status category lands on this board, once configuration is resolved.
2051#[derive(Debug, Clone, PartialEq, Eq)]
2052enum StatusTarget {
2053    /// Not usable against this instance for this kind, and why.
2054    Disabled(UnmappedStatus),
2055    /// The board's `Status` option of this name.
2056    Column(ColumnName),
2057    /// A closed issue, with both its board option and the reason that says which closed it means.
2058    // llmlint: ignore[invalid_states_unrepresentable] The reason is fixed by the category — `done` closes as completed, `cancelled` as not planned — and this private enum is built in one place, `BoardStatuses::resolve`, which pairs each from the category's own slot. Carrying the reason on the target is what lets every write site that holds only a target derive its `stateInput` from that one resolved model rather than re-deriving it from a category and risking a disagreement with the mapping.
2059    Terminal(ColumnName, ClosedState),
2060}
2061
2062/// Every status category, in the order the vocabulary declares them.
2063///
2064/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2065/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2066/// added to the shared vocabulary fails to compile until it is named there, and this
2067/// crate's suite reconciles this list against that enum's own derived schema, which is
2068/// generated from the variants rather than written beside them. The schema is what
2069/// catches a list left one short — a list checking only the positions it already holds
2070/// would pass while every mapping indexed by the new position panicked.
2071pub const CATEGORIES: [StatusCategory; 8] = [
2072    StatusCategory::Draft,
2073    StatusCategory::Backlog,
2074    StatusCategory::Todo,
2075    StatusCategory::Queued,
2076    StatusCategory::InProgress,
2077    StatusCategory::Done,
2078    StatusCategory::Cancelled,
2079    StatusCategory::Unknown,
2080];
2081
2082/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2083#[must_use]
2084pub const fn category_position(category: StatusCategory) -> usize {
2085    match category {
2086        StatusCategory::Draft => 0,
2087        StatusCategory::Backlog => 1,
2088        StatusCategory::Todo => 2,
2089        StatusCategory::Queued => 3,
2090        StatusCategory::InProgress => 4,
2091        StatusCategory::Done => 5,
2092        StatusCategory::Cancelled => 6,
2093        StatusCategory::Unknown => 7,
2094    }
2095}
2096
2097/// The spelling a status category is configured and reported under.
2098fn category_name(category: StatusCategory) -> &'static str {
2099    match category {
2100        StatusCategory::Draft => "draft",
2101        StatusCategory::Backlog => "backlog",
2102        StatusCategory::Todo => "todo",
2103        StatusCategory::Queued => "queued",
2104        StatusCategory::InProgress => "in-progress",
2105        StatusCategory::Done => "done",
2106        StatusCategory::Cancelled => "cancelled",
2107        StatusCategory::Unknown => "unknown",
2108    }
2109}
2110
2111/// A shipped default's option name.
2112///
2113/// The literals below are this file's own and non-blank, and they are validated by the
2114/// one constructor a configured name goes through rather than beside it.
2115fn shipped_column(name: &'static str) -> ColumnName {
2116    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2117}
2118
2119/// The shipped default for one category this instance's `status_mapping` does not mention,
2120/// for either kind.
2121fn shipped_default(category: StatusCategory) -> StatusTarget {
2122    match category {
2123        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2124        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2125        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2126        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2127        StatusCategory::Done => {
2128            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2129        }
2130        StatusCategory::Cancelled => {
2131            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2132        }
2133        StatusCategory::Draft | StatusCategory::Unknown => {
2134            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2135        }
2136    }
2137}
2138
2139/// The two kinds a status is written and read for, each with its own half of the mapping.
2140const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2141
2142/// This instance's complete category-to-target mapping for each kind, read in both
2143/// directions.
2144///
2145/// One target per category per kind, held at that category's own [`category_position`], so
2146/// a category missing from the mapping, named twice in it, or filed out of order is a state
2147/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2148/// targets are options of the board's one `Status` field.
2149#[derive(Debug, Clone)]
2150struct BoardStatuses {
2151    tasks: [StatusTarget; CATEGORIES.len()],
2152    projects: [StatusTarget; CATEGORIES.len()],
2153}
2154
2155impl BoardStatuses {
2156    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2157    /// would read back from one option.
2158    ///
2159    /// A category the mapping does not mention keeps its shipped default for both kinds; one
2160    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2161    /// omits unmapped rather than defaulted.
2162    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2163        let resolve_kind =
2164            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2165                // `CATEGORIES[position] == category` for every category — the crate's suite
2166                // asserts it — so mapping the list in order fills each category's own slot.
2167                let mut targets = CATEGORIES.map(shipped_default);
2168                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2169                    if !configured.mentions(category) {
2170                        continue;
2171                    }
2172                    *slot = match configured.name_for(category, kind) {
2173                        Err(why) => StatusTarget::Disabled(why),
2174                        Ok(name) => {
2175                            let option = ColumnName::try_from(name.as_str().to_owned())
2176                                .map_err(|message| SourceError::Config { message })?;
2177                            match category {
2178                                StatusCategory::Done => {
2179                                    StatusTarget::Terminal(option, ClosedState::Completed)
2180                                }
2181                                StatusCategory::Cancelled => {
2182                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2183                                }
2184                                _ => StatusTarget::Column(option),
2185                            }
2186                        }
2187                    };
2188                }
2189                StatusMapping::distinct(
2190                    instance,
2191                    kind,
2192                    CATEGORIES
2193                        .iter()
2194                        .zip(&targets)
2195                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2196                )?;
2197                Ok(targets)
2198            };
2199        Ok(Self {
2200            tasks: resolve_kind(ItemKind::Task)?,
2201            projects: resolve_kind(ItemKind::Project)?,
2202        })
2203    }
2204
2205    /// Every category's target for `kind`, in category order.
2206    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2207        match kind {
2208            ItemKind::Task => &self.tasks,
2209            ItemKind::Project => &self.projects,
2210        }
2211    }
2212
2213    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2214        &self.targets(kind)[category_position(category)]
2215    }
2216
2217    /// The category a board option name reports for `kind`, or `None` when nothing of that
2218    /// kind maps to it.
2219    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2220        CATEGORIES.into_iter().find(|category| {
2221            self.target(kind, *category)
2222                .option()
2223                .is_some_and(|name| name.eq_ignore_ascii_case(option))
2224        })
2225    }
2226
2227    /// Every option name either kind maps a category to, each once ignoring case, in
2228    /// category order with a task's name before a project's — what the guarded setup asks
2229    /// the `Status` field to hold.
2230    fn wanted(&self) -> Vec<String> {
2231        let mut wanted: Vec<String> = Vec::new();
2232        for category in CATEGORIES {
2233            for kind in STATUS_KINDS {
2234                if let Some(name) = self.target(kind, category).option()
2235                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2236                {
2237                    wanted.push(name.to_owned());
2238                }
2239            }
2240        }
2241        wanted
2242    }
2243
2244    /// The status an item of `kind` reports, from the three things a read of it says: its
2245    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2246    ///
2247    /// The closed state decides the category and the `Status` option decides the name, so
2248    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2249    /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2250    /// a duplicate is not finished work, and calling it done is a lie the next copy would
2251    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2252    /// it is read permissively rather than refused — reads are faithful, and refusals
2253    /// belong on writes. An open item's option reads through its own kind's mapping, and an
2254    /// option that mapping does not name reads as `Unknown` under its own name.
2255    ///
2256    /// One function of those three rather than of a response, so a narrow status write can
2257    /// answer what a re-read would report by applying it to the state it has just written.
2258    fn status(
2259        &self,
2260        kind: ItemKind,
2261        option: Option<&str>,
2262        closed: bool,
2263        reason: Option<&str>,
2264    ) -> Status {
2265        if closed {
2266            let category = match reason {
2267                None | Some("COMPLETED") => StatusCategory::Done,
2268                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2269                Some(_) => StatusCategory::Unknown,
2270            };
2271            let fallback = match category {
2272                StatusCategory::Done => "Done",
2273                StatusCategory::Cancelled => "Cancelled",
2274                _ => "Closed",
2275            };
2276            return Status {
2277                category,
2278                name: option.unwrap_or(fallback).to_owned(),
2279            };
2280        }
2281        let name = option.unwrap_or("Open").to_owned();
2282        Status {
2283            category: self
2284                .category_of(kind, &name)
2285                .unwrap_or(StatusCategory::Unknown),
2286            name,
2287        }
2288    }
2289}
2290
2291impl BoardStatuses {
2292    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2293    /// case; a kind lacking none is left out.
2294    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2295        STATUS_KINDS
2296            .into_iter()
2297            .filter_map(|kind| {
2298                let missing: Vec<String> = self
2299                    .targets(kind)
2300                    .iter()
2301                    .filter_map(StatusTarget::option)
2302                    .filter(|wanted| {
2303                        !existing
2304                            .iter()
2305                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2306                    })
2307                    .map(str::to_owned)
2308                    .collect();
2309                (!missing.is_empty()).then_some(KindMissing { kind, missing })
2310            })
2311            .collect()
2312    }
2313}
2314
2315impl StatusTarget {
2316    /// The board option this target selects, or `None` for an unmapped one.
2317    fn option(&self) -> Option<&str> {
2318        match self {
2319            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2320            Self::Disabled(_) => None,
2321        }
2322    }
2323}
2324
2325// llmlint: ignore-block[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate] Every `createIssue` names one of these, and which one is the rule — a reader who reaches the type from `create_and_file_issue` gets the rule in one sentence here without the method's refusals, which stay on `creation_target`, the rule's one executable source; `tests/plugin.rs` drives every arm of it against the loopback board.
2326/// One repository this source can create an issue in, as `owner/name`.
2327///
2328/// Every `createIssue` this source sends names one of these: the item's own single
2329/// `repositories` entry, else its parent project issue's repository, else the configured
2330/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2331/// that choice and says what it refuses before `createIssue`.
2332// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2333#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2334struct RepositoryTarget {
2335    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2336    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2337}
2338
2339impl RepositoryTarget {
2340    fn parse(value: &str) -> Result<Self, SourceError> {
2341        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2342            message: format!(
2343                "repository must be spelled owner/name; {value:?} names no repository"
2344            ),
2345        })?;
2346        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2347            return Err(SourceError::Config {
2348                message: format!(
2349                    "repository must be spelled owner/name with a GitHub login and one \
2350                     repository name; {value:?} is not"
2351                ),
2352            });
2353        }
2354        Ok(Self {
2355            owner: owner.to_owned(),
2356            name: name.to_owned(),
2357        })
2358    }
2359
2360    /// The one host whose repositories this source creates issues in, spelled once: it is
2361    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2362    const HOST: &str = "github.com";
2363
2364    fn origin(&self) -> String {
2365        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2366    }
2367
2368    /// The repository a normalized origin names, or why it is none this source can create
2369    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2370    fn from_origin(origin: &Repository) -> Result<Self, String> {
2371        let not_here = || {
2372            format!(
2373                "{} is not a {}/owner/name repository",
2374                origin.as_str(),
2375                Self::HOST
2376            )
2377        };
2378        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2379        if host != Self::HOST {
2380            return Err(not_here());
2381        }
2382        Self::parse(rest).map_err(|_| not_here())
2383    }
2384
2385    fn slug(&self) -> String {
2386        format!("{}/{}", self.owner, self.name)
2387    }
2388}
2389
2390/// A source which reads GitHub afresh for every operation.
2391pub struct GitHubProjectsSource {
2392    /// This source's configured name, used both to tell a far end naming this source
2393    /// from one naming a system it knows nothing about, and to name the instance a
2394    /// status refusal is about.
2395    name: SourceName,
2396    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2397    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2398    repository: Option<RepositoryTarget>,
2399    endpoint: Url,
2400    token: SecretString,
2401    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2402    statuses: BoardStatuses,
2403    /// Where each priority lands on this board, or `None` when this instance holds none.
2404    priorities: Option<PriorityMapping>,
2405    client: Client,
2406    /// Every item this source has created in this command, in the order it created them —
2407    /// dropped by [`TaskSource::end_command`].
2408    ///
2409    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2410    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2411    /// a copy resolving a dependency on an item it had just created refused it as not
2412    /// found. A board read is completed from this — an item remembered here and absent from
2413    /// the read is added back, because the board really does hold it and only the read is
2414    /// behind.
2415    ///
2416    /// It is not a cache of a user's work: nothing is remembered that this process did not
2417    /// itself just write, it lives and dies with the process, and it is never consulted for
2418    /// an item this source did not create.
2419    created: Mutex<Vec<Resolved>>,
2420    /// Every item that already existed and that this source has written in this command, as
2421    /// it wrote it — dropped by [`TaskSource::end_command`].
2422    ///
2423    /// The other half of [`Self::created`], held on the same terms and for the reason a
2424    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2425    /// filter is an index behind a write this process made moments ago, so a query matching
2426    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2427    /// is remembered that this process did not itself just write.
2428    updated: Mutex<Vec<Resolved>>,
2429    /// How fast this source writes, and how long it waits out a refusal.
2430    pacing: Pacing,
2431    /// When the last content-creating mutation finished, or the moment the furthest-out
2432    /// reserved slot releases the next one, whichever is later — so the one after it can be
2433    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2434    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2435    /// what it is measured from.
2436    last_mutation: Mutex<Option<Instant>>,
2437    /// The board as this process last read it, for the length of one command — dropped by
2438    /// [`TaskSource::end_command`].
2439    ///
2440    /// A copy of a project used to re-read the whole board, paged, before writing each of
2441    /// its items, which is by far the largest part of a copy's request count and none of
2442    /// its work. Nothing else changes this board while a command runs — this source's own
2443    /// writes are the only writer — so one read answers them all.
2444    ///
2445    /// It is not a store of a user's work and it is not the cache the no-persistence
2446    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2447    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2448    /// an item this command created and then depends on resolves whether or not GitHub's
2449    /// own eventually-consistent read has caught up. A write to an item already on the
2450    /// board updates the entry here too, so what this holds is the last read plus this
2451    /// process's own writes rather than a snapshot taken before them.
2452    board_cache: Mutex<Option<Board>>,
2453    /// Every issue this board's own search reported, for the length of one command — dropped
2454    /// by [`TaskSource::end_command`].
2455    ///
2456    /// The second half of a board read, and cached for the same reason and on the same
2457    /// terms as the first: it lives and dies with the process, nothing is written down, and
2458    /// a write this process makes updates the entry here exactly as it updates the one in
2459    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2460    /// that lists this board's projects and its tasks pays for one search rather than two.
2461    search_cache: Mutex<Option<Vec<Resolved>>>,
2462    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2463    /// the length of one command — dropped by [`TaskSource::end_command`].
2464    ///
2465    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2466    /// and dies with the process, nothing is written down, a write this process makes
2467    /// updates the entry here as it updates the other two, and every answer is completed
2468    /// with this process's own writes each time it is given. A command that asks the same
2469    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2470    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2471    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2472    search_next: Mutex<BTreeMap<String, Option<String>>>,
2473    /// Records already resolved in this command, reused by writes and for comment identity.
2474    /// Explicit item reads still reach GitHub. Nothing is persisted, and
2475    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2476    /// its item as a person has since left it.
2477    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2478    /// The board's own id and field definitions as this process last read them on their
2479    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2480    ///
2481    /// What a write needs of the board and its item does not say, read once per command
2482    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2483    /// lives and dies with the process and nothing is written down. It holds no item and so
2484    /// can answer no question about one — see [`Self::board_fields`].
2485    fields_cache: Mutex<Option<BoardFields>>,
2486    /// Each destination repository's node id, resolved once per repository
2487    /// rather than per issue created.
2488    ///
2489    /// A repository's node id does not change, and re-reading it for every issue of a copy
2490    /// spent one request per item on an answer this source already had. It is a map rather
2491    /// than one entry because a copy files each item in the repository its own
2492    /// `repositories` field names, so a plan across five repositories asks GitHub five
2493    /// times and not once per item.
2494    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2495    /// What every request this source sends is recorded into.
2496    ///
2497    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2498    /// a request leaves this crate, so nothing has to be switched on for a session to be
2499    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2500    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2501    /// source's reads and writes — adds up one accounting instead of two. See
2502    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2503    ledger: Arc<Accounting>,
2504}
2505
2506/// GitHub's closed single-select color vocabulary.
2507#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2508#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2509pub enum StatusOptionColor {
2510    /// Gray.
2511    Gray,
2512    /// Blue.
2513    Blue,
2514    /// Green.
2515    Green,
2516    /// Yellow.
2517    Yellow,
2518    /// Purple.
2519    Purple,
2520    /// Red.
2521    Red,
2522    /// Orange.
2523    Orange,
2524    /// Pink.
2525    Pink,
2526}
2527
2528/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2529/// applies its additions.
2530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2531pub enum SetupMode {
2532    /// Read without mutation.
2533    Plan,
2534    /// Apply and verify.
2535    Apply,
2536}
2537
2538/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2539/// against it goes on compiling.
2540pub type StatusOptionsMode = SetupMode;
2541
2542/// The explicit result of the requested operation.
2543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2544#[serde(rename_all = "kebab-case")]
2545pub enum StatusOptionsOutcome {
2546    /// A read-only plan.
2547    Planned,
2548    /// Apply found nothing missing.
2549    Unchanged,
2550    /// Additions were applied and verified.
2551    Applied,
2552}
2553
2554/// A GitHub single-select option's opaque GraphQL node identifier.
2555#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2556#[serde(transparent)]
2557pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2558
2559impl TryFrom<String> for StatusOptionId {
2560    type Error = String;
2561
2562    fn try_from(id: String) -> Result<Self, Self::Error> {
2563        if id.trim().is_empty() {
2564            return Err("a GitHub Status option id cannot be blank".to_owned());
2565        }
2566        Ok(Self(id))
2567    }
2568}
2569
2570/// One existing or proposed option in a guarded Status-field update.
2571#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2572pub struct StatusOption {
2573    /// GitHub's stable id.
2574    pub id: StatusOptionId,
2575    /// The visible option name.
2576    pub name: ColumnName,
2577    /// GitHub's single-select color token.
2578    pub color: StatusOptionColor,
2579    /// The option description, including an empty one.
2580    pub description: String,
2581}
2582
2583/// One board item's Status assignment, retained as recovery data.
2584#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2585pub struct StatusAssignment {
2586    /// The project item id whose assignment this is.
2587    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2588    // carried verbatim as operator recovery data; introducing a semantic type would claim
2589    // validation rules GitHub does not publish and no operation here interprets.
2590    pub item_id: String,
2591    /// The selected option, absent when the item has no status.
2592    #[serde(skip_serializing_if = "Option::is_none")]
2593    pub option: Option<AssignedStatusOption>,
2594}
2595
2596/// The inseparable id and name of an assigned option.
2597#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2598pub struct AssignedStatusOption {
2599    /// GitHub's stable id.
2600    pub id: StatusOptionId,
2601    /// The visible name.
2602    pub name: ColumnName,
2603}
2604
2605/// The plan and verified outcome of reconciling configured Status options.
2606#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2607pub struct StatusOptionsReport {
2608    /// The configured source name.
2609    pub source: SourceName,
2610    /// Configured option names absent before the operation.
2611    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2612    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2613    // serialized string here preserves the report's intentionally simple public contract.
2614    pub missing: Vec<String>,
2615    /// What the requested operation did.
2616    pub outcome: StatusOptionsOutcome,
2617    /// The complete option list observed before any mutation.
2618    pub existing: Vec<StatusOption>,
2619}
2620
2621#[derive(Debug, Clone, PartialEq, Eq)]
2622struct StatusSnapshot {
2623    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2624    // passed back as the mutation's project identity; a newtype could enforce no stronger
2625    // invariant because GitHub publishes no grammar for it.
2626    board_id: String,
2627    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2628    // passed back as the mutation's field identity; a newtype could enforce no stronger
2629    // invariant because GitHub publishes no grammar for it.
2630    field_id: String,
2631    options: Vec<StatusOption>,
2632    assignments: Vec<StatusAssignment>,
2633}
2634
2635/// The name of the board field a status is held in.
2636const STATUS_FIELD: &str = "Status";
2637
2638/// Every item's value of each field `report` names, as it stood before the setup wrote
2639/// anything — what a person puts back when the setup is refused part way.
2640fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2641    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2642        .fields
2643        .iter()
2644        .map(|field| (field.field.name(), before.assignments(field.field)))
2645        .collect();
2646    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2647        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2648    })
2649}
2650
2651/// One board field the guarded setup reads and writes — every one it reads, and the only
2652/// ones it writes.
2653#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2654pub enum BoardField {
2655    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2656    Status,
2657    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2658    Priority,
2659}
2660
2661impl BoardField {
2662    /// The field's name on the board.
2663    #[must_use]
2664    pub const fn name(self) -> &'static str {
2665        match self {
2666            Self::Status => STATUS_FIELD,
2667            Self::Priority => PRIORITY_FIELD,
2668        }
2669    }
2670
2671    /// The field a board calls `name`, or `None` for one this setup does not own.
2672    fn named(name: &str) -> Option<Self> {
2673        [Self::Status, Self::Priority]
2674            .into_iter()
2675            .find(|field| field.name() == name)
2676    }
2677}
2678
2679/// What the guarded setup did to one field.
2680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2681#[serde(rename_all = "kebab-case")]
2682pub enum FieldOutcome {
2683    /// A read-only plan.
2684    Planned,
2685    /// Apply found the field there with every configured option.
2686    Unchanged,
2687    /// Missing options were added to the field that was there, and verified.
2688    Applied,
2689    /// The field was not there; it was created holding the configured options, and verified.
2690    Created,
2691}
2692
2693/// One field's plan, or its verified outcome.
2694#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2695pub struct FieldReport {
2696    /// Which field.
2697    pub field: BoardField,
2698    /// Whether the board had the field before the operation.
2699    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2700    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2701    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2702    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2703    // one constructor, and it derives `outcome` from `exists` in one match.
2704    pub exists: bool,
2705    /// Configured option names the field lacked before the operation — every one of them,
2706    /// in the order a new field lists them, when the field was not there at all.
2707    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2708    // mapping name and has therefore already passed its nonblank validation; the serialized
2709    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2710    pub missing: Vec<String>,
2711    /// For the `Status` field, which item kind each missing name is configured for: one
2712    /// entry per kind `status_mapping` names a missing option for, task before project, each
2713    /// listing that kind's missing names in category order. A name both kinds use is in
2714    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2715    /// `Priority`, which only a task holds.
2716    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2717    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2718    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2719    #[schemars(!skip_serializing_if)]
2720    pub kinds: Vec<KindMissing>,
2721    /// What the requested operation did.
2722    pub outcome: FieldOutcome,
2723    /// The field's complete option list observed before any mutation; empty when the field
2724    /// was not there.
2725    pub existing: Vec<StatusOption>,
2726}
2727
2728/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2729#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2730pub struct KindMissing {
2731    /// The kind these names are configured for.
2732    pub kind: ItemKind,
2733    /// The names that kind maps a category to and the field lacked, in category order.
2734    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2735    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2736    // intentionally simple public contract.
2737    pub missing: Vec<String>,
2738}
2739
2740/// The plan and verified outcome of setting up every field a source's configuration names.
2741#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2742pub struct FieldsReport {
2743    /// The configured source name.
2744    pub source: SourceName,
2745    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2746    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2747    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2748    // per field would change a published JSON shape. The states the list could hold and the
2749    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2750    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2751    pub fields: Vec<FieldReport>,
2752}
2753
2754/// Which options one field is configured with, in the order a new field would list them.
2755struct FieldPlan {
2756    field: BoardField,
2757    wanted: Vec<String>,
2758}
2759
2760/// One single-select field as the guarded setup snapshots it.
2761#[derive(Debug, Clone, PartialEq, Eq)]
2762struct SnapshotField {
2763    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2764    // passed back as the mutation's field identity; a newtype could enforce no stronger
2765    // invariant because GitHub publishes no grammar for it.
2766    field_id: String,
2767    options: Vec<StatusOption>,
2768}
2769
2770/// Every single-select field of a board and every item's value of each.
2771#[derive(Debug, Clone, PartialEq, Eq)]
2772struct BoardSnapshot {
2773    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2774    // passed back as the mutation's project identity; a newtype could enforce no stronger
2775    // invariant because GitHub publishes no grammar for it.
2776    board_id: String,
2777    fields: BTreeMap<BoardField, SnapshotField>,
2778    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2779    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2780}
2781
2782impl BoardSnapshot {
2783    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2784    /// carries.
2785    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2786        self.items
2787            .iter()
2788            .map(|(item_id, values)| StatusAssignment {
2789                item_id: item_id.clone(),
2790                option: values.get(&field).cloned(),
2791            })
2792            .collect()
2793    }
2794}
2795
2796impl GitHubProjectsSource {
2797    /// Report missing configured Status options and, when `apply` is true, add them with
2798    /// a whole-list mutation that preserves every existing id and verifies the result.
2799    ///
2800    /// # Errors
2801    ///
2802    /// Refuses a board without a single-select `Status` field. A post-write difference in
2803    /// any pre-existing option id or item assignment is refused with the complete pre-write
2804    /// assignment snapshot in the diagnostic for recovery.
2805    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2806    // successful mutation, both drift refusals, source selection, missing Status, casing,
2807    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2808    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2809    // responses from entering the defensive malformed-response branches below.
2810    pub async fn status_options(
2811        &self,
2812        mode: StatusOptionsMode,
2813    ) -> Result<StatusOptionsReport, SourceError> {
2814        let before = self.status_snapshot().await?;
2815        // A terminal category's option is as configured as an open one's: a terminal
2816        // write validates it before closing and refuses when the board lacks it. Both
2817        // kinds' names are options of the one field, so both are asked for.
2818        let missing = self
2819            .statuses
2820            .wanted()
2821            .into_iter()
2822            .filter(|wanted| {
2823                !before
2824                    .options
2825                    .iter()
2826                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2827            })
2828            .collect::<Vec<_>>();
2829        let report = StatusOptionsReport {
2830            source: self.name.clone(),
2831            missing: missing.clone(),
2832            outcome: match (mode, missing.is_empty()) {
2833                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2834                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2835                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2836            },
2837            existing: before.options.clone(),
2838        };
2839        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2840            return Ok(report);
2841        }
2842        let mut options = before
2843            .options
2844            .iter()
2845            .map(|option| {
2846                json!({
2847                    "id": option.id, "name": option.name, "color": option.color,
2848                    "description": option.description,
2849                })
2850            })
2851            .collect::<Vec<_>>();
2852        options.extend(missing.iter().map(|name| {
2853            json!({
2854                "name": name, "color": "GRAY", "description": ""
2855            })
2856        }));
2857        self.graphql(
2858            graphql::STATUS_OPTIONS_UPDATE,
2859            json!({"input": {
2860                "projectId": before.board_id, "fieldId": before.field_id,
2861                "singleSelectOptions": options,
2862            }}),
2863        )
2864        .await?;
2865        let after = self.status_snapshot().await?;
2866        let options_preserved = before
2867            .options
2868            .iter()
2869            .all(|old| after.options.iter().any(|new| new == old));
2870        let additions_present = missing.iter().all(|wanted| {
2871            after
2872                .options
2873                .iter()
2874                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2875        });
2876        if !options_preserved || !additions_present || after.assignments != before.assignments {
2877            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2878                SourceError::Malformed {
2879                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2880                }
2881            })?;
2882            return Err(SourceError::Refused {
2883                message: format!(
2884                    "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}"
2885                ),
2886            });
2887        }
2888        Ok(report)
2889    }
2890
2891    /// A fresh snapshot of the Status field and every board item's assignment of it.
2892    ///
2893    /// # Errors
2894    ///
2895    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2896    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2897        // Status alone, as this operation has always read it: a `Priority` field is another
2898        // operation's, so nothing about it can refuse this one.
2899        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2900        let field = board
2901            .fields
2902            .remove(&BoardField::Status)
2903            .ok_or_else(|| self.no_status_field())?;
2904        Ok(StatusSnapshot {
2905            assignments: board.assignments(BoardField::Status),
2906            board_id: board.board_id,
2907            field_id: field.field_id,
2908            options: field.options,
2909        })
2910    }
2911
2912    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2913    fn no_status_field(&self) -> SourceError {
2914        SourceError::Refused {
2915            message: format!("source {} board has no Status field", self.name),
2916        }
2917    }
2918
2919    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2920    // the real CLI loopback journey, including pagination. The individual malformed guards
2921    // are defensive validation of a schema-pinned third-party response, not separate user
2922    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2923    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2924    /// every board item's value of each, walked to the end of the board's items. A field not
2925    /// in `owned` is read past whatever it holds.
2926    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2927        let mut after: Option<String> = None;
2928        let mut snapshot: Option<BoardSnapshot> = None;
2929        loop {
2930            let data = self
2931                .graphql(
2932                    graphql::STATUS_OPTIONS_SNAPSHOT,
2933                    json!({
2934                        "owner": self.owner, "number": self.project_number,
2935                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2936                    }),
2937                )
2938                .await?;
2939            let board = data
2940                .pointer("/owner/projectV2")
2941                .filter(|board| board.is_object())
2942                .ok_or_else(|| SourceError::Refused {
2943                    message: format!(
2944                        "source {} has no accessible GitHub Projects board",
2945                        self.name
2946                    ),
2947                })?;
2948            if board
2949                .pointer("/fields/pageInfo/hasNextPage")
2950                .and_then(Value::as_bool)
2951                != Some(false)
2952            {
2953                return Err(SourceError::Malformed {
2954                    message:
2955                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2956                            .into(),
2957                });
2958            }
2959            let mut fields = BTreeMap::new();
2960            // Only the fields this setup owns, by name: a node the single-select fragment did not
2961            // match carries no name, and a person's own single-select field — a `Size`, a
2962            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2963            // `Status` or `Priority` field without its options is malformed, not absent.
2964            // 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.
2965            for (owned, field) in board
2966                .pointer("/fields/nodes")
2967                .and_then(Value::as_array)
2968                .ok_or_else(|| SourceError::Malformed {
2969                    message: "GitHub project fields.nodes is not an array".into(),
2970                })?
2971                .iter()
2972                .filter_map(|field| {
2973                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2974                    owned.contains(&named).then_some((named, field))
2975                })
2976            {
2977                let options = field
2978                    .get("options")
2979                    .and_then(Value::as_array)
2980                    .ok_or_else(|| SourceError::Malformed {
2981                        message: "GitHub single-select field options is not an array".into(),
2982                    })?
2983                    .iter()
2984                    .map(|option| {
2985                        Ok(StatusOption {
2986                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2987                                .map_err(|message| SourceError::Malformed { message })?,
2988                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2989                                .map_err(|message| SourceError::Malformed {
2990                                    message: format!(
2991                                        "GitHub single-select option name is invalid: {message}"
2992                                    ),
2993                                })?,
2994                            color: serde_json::from_value(
2995                                option.get("color").cloned().unwrap_or(Value::Null),
2996                            )
2997                            .map_err(|error| {
2998                                SourceError::Malformed {
2999                                    message: format!(
3000                                        "GitHub single-select option color is invalid: {error}"
3001                                    ),
3002                                }
3003                            })?,
3004                            description: optional_str(option, "description")?
3005                                .unwrap_or_default()
3006                                .to_owned(),
3007                        })
3008                    })
3009                    .collect::<Result<Vec<_>, SourceError>>()?;
3010                let snapshot = SnapshotField {
3011                    field_id: required_nonblank_str(field, "id")?.to_owned(),
3012                    options,
3013                };
3014                // A board's field names are unique, so a second one is an answer that cannot
3015                // say which field the setup would act on — refused rather than one chosen.
3016                if fields.insert(owned, snapshot).is_some() {
3017                    return Err(SourceError::Malformed {
3018                        message: format!(
3019                            "GitHub answered two {} fields for this board",
3020                            owned.name()
3021                        ),
3022                    });
3023                }
3024            }
3025            let board_id = required_nonblank_str(board, "id")?.to_owned();
3026            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3027                board_id,
3028                fields,
3029                items: Vec::new(),
3030            });
3031            let items = board
3032                .pointer("/items/nodes")
3033                .and_then(Value::as_array)
3034                .ok_or_else(|| SourceError::Malformed {
3035                    message: "GitHub project items.nodes is not an array".into(),
3036                })?;
3037            for item in items {
3038                let field_values =
3039                    item.get("fieldValues")
3040                        .ok_or_else(|| SourceError::Malformed {
3041                            message: "GitHub project item is missing fieldValues".into(),
3042                        })?;
3043                if field_values
3044                    .pointer("/pageInfo/hasNextPage")
3045                    .and_then(Value::as_bool)
3046                    != Some(false)
3047                {
3048                    return Err(SourceError::Malformed {
3049                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3050                    });
3051                }
3052                let values = item
3053                    .pointer("/fieldValues/nodes")
3054                    .and_then(Value::as_array)
3055                    .ok_or_else(|| SourceError::Malformed {
3056                        message: "GitHub project item fieldValues.nodes is not an array".into(),
3057                    })?;
3058                let item_id = required_nonblank_str(item, "id")?;
3059                let mut assigned = BTreeMap::new();
3060                for value in values {
3061                    let Some(field) = value
3062                        .pointer("/field/name")
3063                        .and_then(Value::as_str)
3064                        .and_then(BoardField::named)
3065                        .filter(|field| owned.contains(field))
3066                    else {
3067                        continue;
3068                    };
3069                    let held = assigned.insert(
3070                        field,
3071                        AssignedStatusOption {
3072                            id: StatusOptionId::try_from(
3073                                required_str(value, "optionId")?.to_owned(),
3074                            )
3075                            .map_err(|message| SourceError::Malformed { message })?,
3076                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3077                                .map_err(|message| SourceError::Malformed {
3078                                    message: format!(
3079                                        "GitHub assigned {} name is invalid: {message}",
3080                                        field.name()
3081                                    ),
3082                                })?,
3083                        },
3084                    );
3085                    // An item holds one value of a field, so a second one leaves no way to
3086                    // tell which it holds — and a verification or recovery built on either
3087                    // could restore the wrong one.
3088                    if held.is_some() {
3089                        return Err(SourceError::Malformed {
3090                            message: format!(
3091                                "GitHub answered two {} values for board item {item_id}",
3092                                field.name()
3093                            ),
3094                        });
3095                    }
3096                }
3097                current.items.push((item_id.to_owned(), assigned));
3098            }
3099            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3100                message: "GitHub project is missing items".into(),
3101            })?;
3102            let has_next = page
3103                .pointer("/pageInfo/hasNextPage")
3104                .and_then(Value::as_bool)
3105                .ok_or_else(|| SourceError::Malformed {
3106                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3107                })?;
3108            if !has_next {
3109                break;
3110            }
3111            let next =
3112                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3113            validate_cursor_progress(after.as_deref(), next)?;
3114            after = Some(next.to_owned());
3115        }
3116        snapshot.ok_or_else(|| SourceError::Malformed {
3117            message: "GitHub returned no board field snapshot".into(),
3118        })
3119    }
3120    // llmlint: ignore-end[changed_behavior_has_e2e]
3121
3122    /// Report every board field this source's configuration names and, with
3123    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3124    /// the `Priority` field when the board has none.
3125    ///
3126    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3127    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3128    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3129    /// color and description: the whole option list goes back with every existing id, because
3130    /// a re-minted id clears every item's value.
3131    ///
3132    /// # Errors
3133    ///
3134    /// Refuses a board without a single-select `Status` field. After an apply the board is
3135    /// read again, and a pre-existing option or any item's value of either field that moved is
3136    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3137    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3138    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3139    // with no Status field and a non-github-projects source through the compiled CLI against
3140    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3141    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3142        let owned: Vec<BoardField> = if self.priorities.is_some() {
3143            vec![BoardField::Status, BoardField::Priority]
3144        } else {
3145            vec![BoardField::Status]
3146        };
3147        let before = self.board_snapshot(&owned).await?;
3148        let mut plans = vec![FieldPlan {
3149            field: BoardField::Status,
3150            wanted: self.statuses.wanted(),
3151        }];
3152        if !before.fields.contains_key(&BoardField::Status) {
3153            return Err(self.no_status_field());
3154        }
3155        if let Some(mapping) = &self.priorities {
3156            plans.push(FieldPlan {
3157                field: BoardField::Priority,
3158                wanted: mapping.names().map(str::to_owned).collect(),
3159            });
3160        }
3161        // The snapshot reads single-select fields alone, so a field it did not find may still
3162        // be on the board under the name, of another type: creating one beside it would fail
3163        // part way, or leave two fields of one name. Asked of the board's own field list, and
3164        // only when a field is missing.
3165        if plans
3166            .iter()
3167            .any(|plan| !before.fields.contains_key(&plan.field))
3168        {
3169            let board = self.board_fields().await?;
3170            for plan in plans
3171                .iter()
3172                .filter(|plan| !before.fields.contains_key(&plan.field))
3173            {
3174                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3175                    return Err(SourceError::Refused {
3176                        message: format!(
3177                            "source {}'s board has a {} field that is not a single-select field \
3178                             (it is a {}), so it cannot hold this source's options; next: rename \
3179                             or remove that field, then run this again",
3180                            self.name,
3181                            plan.field.name(),
3182                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3183                        ),
3184                    });
3185                }
3186            }
3187        }
3188        let mut reports = Vec::new();
3189        for plan in &plans {
3190            let held = before.fields.get(&plan.field);
3191            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3192            let mut missing: Vec<String> = Vec::new();
3193            for wanted in &plan.wanted {
3194                let present = existing
3195                    .iter()
3196                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3197                    || missing
3198                        .iter()
3199                        .any(|named| named.eq_ignore_ascii_case(wanted));
3200                if !present {
3201                    missing.push(wanted.clone());
3202                }
3203            }
3204            let kinds = match plan.field {
3205                BoardField::Status => self.statuses.missing_by_kind(&existing),
3206                BoardField::Priority => Vec::new(),
3207            };
3208            reports.push(FieldReport {
3209                field: plan.field,
3210                exists: held.is_some(),
3211                kinds,
3212                outcome: match (mode, held.is_some(), missing.is_empty()) {
3213                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3214                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3215                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3216                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3217                },
3218                missing,
3219                existing,
3220            });
3221        }
3222        let report = FieldsReport {
3223            source: self.name.clone(),
3224            fields: reports,
3225        };
3226        let writes: Vec<&FieldReport> = report
3227            .fields
3228            .iter()
3229            .filter(|field| !field.missing.is_empty() || !field.exists)
3230            .collect();
3231        if mode == SetupMode::Plan || writes.is_empty() {
3232            return Ok(report);
3233        }
3234        let mut landed: Vec<&str> = Vec::new();
3235        for field in &writes {
3236            let added = field
3237                .missing
3238                .iter()
3239                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3240            let sent = match before.fields.get(&field.field) {
3241                Some(held) => {
3242                    let mut options = held
3243                        .options
3244                        .iter()
3245                        .map(|option| {
3246                            json!({
3247                                "id": option.id, "name": option.name, "color": option.color,
3248                                "description": option.description,
3249                            })
3250                        })
3251                        .collect::<Vec<_>>();
3252                    options.extend(added);
3253                    self.graphql(
3254                        graphql::STATUS_OPTIONS_UPDATE,
3255                        json!({"input": {
3256                            "projectId": before.board_id, "fieldId": held.field_id,
3257                            "singleSelectOptions": options,
3258                        }}),
3259                    )
3260                    .await
3261                }
3262                None => {
3263                    self.graphql(
3264                        graphql::CREATE_FIELD,
3265                        json!({"input": {
3266                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3267                            "name": field.field.name(),
3268                            "singleSelectOptions": added.collect::<Vec<_>>(),
3269                        }}),
3270                    )
3271                    .await
3272                }
3273            };
3274            // A mutation that failed does not establish that GitHub left its field as it was,
3275            // so every failure from here on carries the recovery data a drift refusal does.
3276            match sent {
3277                Ok(_) => landed.push(field.field.name()),
3278                Err(error) => {
3279                    let changed = if landed.is_empty() {
3280                        String::new()
3281                    } else {
3282                        format!("changed the {} field and then ", landed.join(" and "))
3283                    };
3284                    return Err(SourceError::Refused {
3285                        message: format!(
3286                            "the guarded field setup {changed}failed on the {} field, which it may \
3287                             have changed part way: {error}; the pre-write item assignments \
3288                             are:\n{}",
3289                            field.field.name(),
3290                            recovery(&report, &before)?
3291                        ),
3292                    });
3293                }
3294            }
3295        }
3296        // The board has been written, so a verification read that fails leaves it unverified
3297        // rather than unchanged, and says what to put back.
3298        let after = match self.board_snapshot(&owned).await {
3299            Ok(after) => after,
3300            Err(error) => {
3301                return Err(SourceError::Refused {
3302                    message: format!(
3303                        "the guarded field setup changed the {} field and then could not read the \
3304                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3305                        landed.join(" and "),
3306                        recovery(&report, &before)?
3307                    ),
3308                });
3309            }
3310        };
3311        let mut moved = Vec::new();
3312        for field in &report.fields {
3313            let name = field.field.name();
3314            let now = after
3315                .fields
3316                .get(&field.field)
3317                .map(|held| held.options.as_slice())
3318                .unwrap_or_default();
3319            if !field.existing.iter().all(|old| now.contains(old)) {
3320                moved.push(format!(
3321                    "a pre-existing {name} option id, name, color or description"
3322                ));
3323            }
3324            if !field.missing.iter().all(|wanted| {
3325                now.iter()
3326                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3327            }) {
3328                moved.push(format!("an added {name} option"));
3329            }
3330            if after.assignments(field.field) != before.assignments(field.field) {
3331                moved.push(format!("an item's {name} value"));
3332            }
3333        }
3334        if !moved.is_empty() {
3335            return Err(SourceError::Refused {
3336                message: format!(
3337                    "GitHub changed {} after the guarded field setup; the pre-write item \
3338                     assignments are:\n{}",
3339                    moved.join(", "),
3340                    recovery(&report, &before)?
3341                ),
3342            });
3343        }
3344        Ok(report)
3345    }
3346
3347    /// Validate configuration and capture the named credential without exposing it.
3348    ///
3349    /// # Errors
3350    ///
3351    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3352    /// [`SourceError::Auth`] when the named credential is missing or empty.
3353    pub fn new(
3354        name: &SourceName,
3355        config: GitHubProjectsConfig,
3356        secrets: &dyn SecretResolver,
3357    ) -> Result<Self, SourceError> {
3358        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3359    }
3360
3361    /// The same, recording every request it sends into an accounting the caller holds too.
3362    ///
3363    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3364    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3365    /// up — passes the one it records those into, so the session total accounts for the
3366    /// whole session rather than for this source's share of it.
3367    ///
3368    /// # Errors
3369    ///
3370    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3371    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3372    pub fn recording_into(
3373        name: &SourceName,
3374        config: GitHubProjectsConfig,
3375        secrets: &dyn SecretResolver,
3376        ledger: Arc<Accounting>,
3377    ) -> Result<Self, SourceError> {
3378        if !valid_github_owner(&config.owner) {
3379            return Err(SourceError::Config {
3380                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3381            });
3382        }
3383        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3384            return Err(SourceError::Config {
3385                message: format!("project_number must be between 1 and {}", i32::MAX),
3386            });
3387        }
3388        if !valid_environment_name(&config.token_env) {
3389            return Err(SourceError::Config {
3390                message: "token_env must be a valid environment-variable name".into(),
3391            });
3392        }
3393        let repository = config
3394            .repository
3395            .as_deref()
3396            .map(RepositoryTarget::parse)
3397            .transpose()?;
3398        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3399            message: format!("endpoint is not a valid URL: {e}"),
3400        })?;
3401        if endpoint.scheme() != "https"
3402            && !(endpoint.scheme() == "http"
3403                && endpoint
3404                    .host_str()
3405                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3406        {
3407            return Err(SourceError::Config {
3408                message:
3409                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3410                        .into(),
3411            });
3412        }
3413        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3414            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),
3415        })?;
3416        Ok(Self {
3417            name: name.clone(),
3418            owner: config.owner,
3419            project_number: config.project_number,
3420            repository,
3421            endpoint,
3422            token,
3423            credential_name: config.token_env,
3424            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3425            priorities: config
3426                .priority_mapping
3427                .map(|mapping| PriorityMapping::resolve(mapping, name))
3428                .transpose()?,
3429            client: Client::builder()
3430                .user_agent("onetaskgraph")
3431                .build()
3432                .map_err(|e| SourceError::Config {
3433                    message: format!("cannot build HTTP client: {e}"),
3434                })?,
3435            created: Mutex::new(Vec::new()),
3436            updated: Mutex::new(Vec::new()),
3437            pacing: Pacing::resolve(config.pacing, name)?,
3438            last_mutation: Mutex::new(None),
3439            board_cache: Mutex::new(None),
3440            search_cache: Mutex::new(None),
3441            narrowed_cache: Mutex::new(BTreeMap::new()),
3442            resolved_cache: Mutex::new(BTreeMap::new()),
3443            search_next: Mutex::new(BTreeMap::new()),
3444            fields_cache: Mutex::new(None),
3445            repository_cache: Mutex::new(BTreeMap::new()),
3446            ledger,
3447        })
3448    }
3449
3450    /// A snapshot of every request this source has sent, and what each cost.
3451    ///
3452    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3453    /// of them can sit side by side. When this source was built with
3454    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3455    /// point of building it that way.
3456    #[must_use]
3457    pub fn accounting(&self) -> accounting::Session {
3458        self.ledger.snapshot()
3459    }
3460
3461    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3462    /// rate limit rather than handing it straight back as an error.
3463    ///
3464    /// Retrying is safe for every document here, including the mutations, and the reason
3465    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3466    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3467    /// this replays has already taken effect. An outcome this source cannot know — the
3468    /// send failed, or the body could not be read, so the mutation may well have landed —
3469    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3470    /// attempt. A duplicate write would come from replaying one of those, and none is
3471    /// replayed.
3472    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3473        if is_mutation(query)
3474            && ![
3475                graphql::ADD_COMMENT,
3476                graphql::UPDATE_COMMENT,
3477                graphql::DELETE_COMMENT,
3478            ]
3479            .contains(&query)
3480        {
3481            let mut cache = self.resolved_cache()?;
3482            for argument in ["input", "second", "third", "clear"] {
3483                if let Some(input) = variables.get(argument) {
3484                    cache.retain(|id, item| {
3485                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3486                            input
3487                                .get(key)
3488                                .and_then(Value::as_str)
3489                                .is_some_and(|value| value == id.0 || value == item.item_id)
3490                        })
3491                    });
3492                }
3493            }
3494        }
3495        let doing = operation_description(query);
3496        let mut waited = Duration::ZERO;
3497        let mut waits = 0_u32;
3498        let mut backoff = self.pacing.retry_backoff;
3499        loop {
3500            if is_mutation(query) {
3501                let spacing = self.reserve_mutation_slot();
3502                if !spacing.is_zero() {
3503                    tokio::time::sleep(spacing).await;
3504                }
3505            }
3506            let attempt = self.send_once(query, &variables).await;
3507            if is_mutation(query) {
3508                self.finish_mutation();
3509            }
3510            let limited = match attempt {
3511                Ok(data) => return Ok(data),
3512                Err(Attempt::Failed(error)) => return Err(error),
3513                Err(Attempt::Limited(limited)) => limited,
3514            };
3515            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3516            // move that extends a secondary limit, so a hint below the schedule's own next
3517            // wait is raised to it.
3518            let wait = match limited.hint {
3519                Some(hint) => Duration::from_secs(hint).max(backoff),
3520                None => backoff,
3521            };
3522            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3523            // A wait of nothing spends none of the budget, so it is exhaustion rather
3524            // than a retry. `Pacing::resolve` rules out every way of configuring one
3525            // except a budget of zero, where reporting the first refusal is the ask.
3526            if wait.is_zero() || wait > remaining {
3527                return Err(limited.exhausted(
3528                    doing,
3529                    waits,
3530                    waited,
3531                    wait,
3532                    self.pacing.retry_budget,
3533                ));
3534            }
3535            tokio::time::sleep(wait).await;
3536            waited += wait;
3537            waits += 1;
3538            backoff = backoff.saturating_mul(2);
3539        }
3540    }
3541
3542    /// The next moment a content-creating mutation may leave this source, as a wait from
3543    /// now.
3544    ///
3545    /// The slot is reserved under the lock and the waiting happens outside it, so two
3546    /// callers take two slots rather than the same one — and no lock is held across an
3547    /// await.
3548    ///
3549    /// The moment it is spaced from is the previous mutation's *completion*, which
3550    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3551    /// own is the wrong thing to measure from.
3552    fn reserve_mutation_slot(&self) -> Duration {
3553        if self.pacing.min_mutation_interval.is_zero() {
3554            return Duration::ZERO;
3555        }
3556        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3557        // it would turn an earlier failure into a second one for no gain.
3558        let mut last = self
3559            .last_mutation
3560            .lock()
3561            .unwrap_or_else(std::sync::PoisonError::into_inner);
3562        let now = Instant::now();
3563        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3564        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3565        let at = last.map_or(now, |previous| {
3566            previous
3567                .checked_add(self.pacing.min_mutation_interval)
3568                .map_or(now, |earliest| earliest.max(now))
3569        });
3570        *last = Some(at);
3571        at.saturating_duration_since(now)
3572    }
3573
3574    /// Record that a content-creating mutation has finished, so the next one is spaced
3575    /// from here rather than from the moment this one was released.
3576    ///
3577    /// This source can only choose when a request *departs*; the limiter counts when it
3578    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3579    /// departure from the last therefore hands the limiter a gap of the interval less that
3580    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3581    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3582    /// slower machine while passing on a quick one.
3583    ///
3584    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3585    /// previous request had already arrived before its response came back, so its arrival
3586    /// is no later than this moment, and the next mutation is released at least the
3587    /// interval after this moment and arrives no earlier than it is released: the gap the
3588    /// limiter measures is therefore at least the interval, whatever transit costs and on
3589    /// whatever platform. The price is that a mutation's own round trip no longer counts
3590    /// towards its spacing, which makes this source slightly slower than the configured
3591    /// rate rather than slightly faster — the safe side of a limit that punishes being
3592    /// wrong by refusing reads for the next fifty minutes.
3593    ///
3594    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3595    /// and one that never left costs only a wait nobody needed.
3596    fn finish_mutation(&self) {
3597        if self.pacing.min_mutation_interval.is_zero() {
3598            return;
3599        }
3600        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3601        let mut last = self
3602            .last_mutation
3603            .lock()
3604            .unwrap_or_else(std::sync::PoisonError::into_inner);
3605        let now = Instant::now();
3606        // `max` rather than an assignment: a concurrent caller may already have reserved a
3607        // slot further out, and completing this request must never pull that slot back in.
3608        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3609    }
3610
3611    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3612    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3613    ///
3614    /// This is the one place a request leaves this crate, which is why the accounting is
3615    /// here rather than at each of the callers: a read path added later is counted without
3616    /// anybody remembering to count it, and
3617    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3618    /// when one is not.
3619    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3620        let Attempted {
3621            result,
3622            limits,
3623            reported_cost,
3624        } = self.attempt(query, variables).await;
3625        // No `otherwise` name: every document this source sends is one of its own, and the
3626        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3627        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3628        let outcome = match &result {
3629            Ok(_) => accounting::Outcome::Answered,
3630            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3631            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3632        };
3633        self.ledger.record(sending.finished(outcome, limits));
3634        result
3635    }
3636
3637    /// The attempt itself, with what its response said about the rate limit alongside.
3638    ///
3639    /// The two are returned together rather than recorded here because every one of the
3640    /// early exits below is a different outcome, and a record written at each of them is a
3641    /// record one of them can be added without.
3642    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3643        let mut limits = accounting::RateLimit::default();
3644        let mut reported_cost = None;
3645        let result = self
3646            .attempted(query, variables, &mut limits, &mut reported_cost)
3647            .await;
3648        Attempted {
3649            result,
3650            limits,
3651            reported_cost,
3652        }
3653    }
3654
3655    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3656    async fn attempted(
3657        &self,
3658        query: &str,
3659        variables: &Value,
3660        limits: &mut accounting::RateLimit,
3661        reported_cost: &mut Option<u64>,
3662    ) -> Result<Value, Attempt> {
3663        let response = self
3664            .client
3665            .post(self.endpoint.clone())
3666            .bearer_auth(self.token.expose_secret())
3667            .json(&json!({"query": query, "variables": variables}))
3668            .send()
3669            .await
3670            .map_err(|e| {
3671                Attempt::Failed(SourceError::Unavailable {
3672                    message: format!("GitHub GraphQL request failed: {e}"),
3673                })
3674            })?;
3675        let status = response.status();
3676        let header = |name: &str| whole_seconds(response.headers().get(name));
3677        *limits = accounting::RateLimit::read(|name| {
3678            response
3679                .headers()
3680                .get(name)
3681                .and_then(|value| value.to_str().ok())
3682                .map(str::to_owned)
3683        });
3684        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3685        // that are not text at all — is "not known to be exhausted". This never makes a
3686        // response a refusal on its own: it says which limiter a refusal is attributed to
3687        // and where its hint comes from, so a value this cannot read costs a hint rather
3688        // than an answer.
3689        let exhausted = response
3690            .headers()
3691            .get("x-ratelimit-remaining")
3692            .and_then(|value| value.to_str().ok())
3693            == Some("0");
3694        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3695        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3696        // which is the same question answered as an absolute time. Nothing else here is a
3697        // hint, and a schedule is what answers a refusal that carries none.
3698        let hint = header("retry-after").or_else(|| {
3699            exhausted
3700                .then(|| header("x-ratelimit-reset"))
3701                .flatten()
3702                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3703        });
3704        // Read before it is parsed, because the evidence which tells a secondary rate
3705        // limit from a rejected credential is in the body of a response whose status says
3706        // only "forbidden" — and a non-success response was never parsed at all.
3707        let body = response.text().await.map_err(|e| {
3708            Attempt::Failed(SourceError::Unavailable {
3709                message: format!("GitHub GraphQL response could not be read: {e}"),
3710            })
3711        })?;
3712        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3713            return Err(Attempt::Limited(Limited { limiter, hint }));
3714        }
3715        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3716            return Err(Attempt::Failed(SourceError::Auth {
3717                message: format!(
3718                    "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"
3719                ),
3720            }));
3721        }
3722        if !status.is_success() {
3723            return Err(Attempt::Failed(SourceError::Unavailable {
3724                message: format!("GitHub GraphQL returned HTTP {status}"),
3725            }));
3726        }
3727        // GitHub reports what a call cost only when the document asked it to, and no
3728        // document this source sends does — so this is `None` here and carries the figure
3729        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3730        // pick up is a `dryRun` probe's cost, which is some other document's.
3731        *reported_cost = serde_json::from_str::<Value>(&body)
3732            .ok()
3733            .as_ref()
3734            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3735            .and_then(Value::as_u64);
3736        self.answer(&body).map_err(Attempt::Failed)
3737    }
3738
3739    /// What one successful HTTP response says, once its GraphQL errors are read.
3740    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3741        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3742            message: format!("GitHub returned invalid JSON: {e}"),
3743        })?;
3744        let errors = body
3745            .get("errors")
3746            .map(|value| {
3747                value.as_array().ok_or_else(|| SourceError::Malformed {
3748                    message: "GitHub response errors is not an array".into(),
3749                })
3750            })
3751            .transpose()?;
3752        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3753            let messages = errors
3754                .iter()
3755                .filter_map(|e| e.get("message").and_then(Value::as_str))
3756                .collect::<Vec<_>>()
3757                .join("; ");
3758            let message = if messages.is_empty() {
3759                "GitHub returned GraphQL errors".into()
3760            } else {
3761                messages
3762            };
3763            let normalized = message.to_ascii_lowercase();
3764            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3765                return Err(SourceError::Auth {
3766                    message: format!(
3767                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3768                        self.credential_name
3769                    ),
3770                });
3771            }
3772            return Err(SourceError::Refused { message });
3773        }
3774        body.get("data")
3775            .filter(|data| data.is_object())
3776            .cloned()
3777            .ok_or_else(|| SourceError::Malformed {
3778                message: "GitHub response has no data object".into(),
3779            })
3780    }
3781
3782    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3783    // GraphQL cannot independently page them inside the outer item page. This source page is
3784    // deliberately bounded at that published maximum; the live drift journey exercises it.
3785    async fn board_page(
3786        &self,
3787        items_after: Option<&str>,
3788        items_first: u32,
3789    ) -> Result<Value, SourceError> {
3790        let data = self
3791            .graphql(
3792                graphql::BOARD,
3793                json!({"owner":self.owner,"number":self.project_number,
3794                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3795                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3796            )
3797            .await?;
3798        data.pointer("/owner/projectV2")
3799            .filter(|v| !v.is_null())
3800            .cloned()
3801            .ok_or_else(|| SourceError::Refused {
3802                message: format!(
3803                    "GitHub project {}/{} was not found or is not visible to the token",
3804                    self.owner, self.project_number
3805                ),
3806            })
3807    }
3808
3809    /// The search that finds the issues of this board, narrowed by `also` when it is
3810    /// given.
3811    ///
3812    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3813    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3814    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3815    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3816    /// from a task by the `parent` field each issue carries rather than by the search.
3817    fn board_search(&self, also: Option<&str>) -> String {
3818        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3819        match also {
3820            Some(also) => format!("{scope} {also}"),
3821            None => scope,
3822        }
3823    }
3824
3825    /// One issue this source reached directly, as the board item a read of the board would
3826    /// have produced — or `None` when this board does not hold it.
3827    ///
3828    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3829    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3830    /// item's own id, that item's field values, and the issue as its content. One resolver
3831    /// for both routes is what makes an issue read through a search, through its own node
3832    /// id, or through its project's sub-issues report the same title, the same status, the
3833    /// same labels and the same qualified id.
3834    ///
3835    /// An issue with no entry for *this* board is not this source's to report, which is
3836    /// what keeps an id naming some other repository's issue from being answered as an item
3837    /// of this board. That answer is given about an **exhausted** connection and never
3838    /// about an unread page: the entry is looked for on the page in hand, and only if that
3839    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3840    /// rest of it.
3841    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3842        if optional_str(issue, "__typename")? != Some("Issue") {
3843            return Ok(None);
3844        }
3845        let memberships = issue
3846            .get("projectItems")
3847            .ok_or_else(|| SourceError::Malformed {
3848                message: "GitHub issue is missing projectItems".into(),
3849            })?;
3850        let nodes = memberships
3851            .get("nodes")
3852            .and_then(Value::as_array)
3853            .ok_or_else(|| SourceError::Malformed {
3854                message: "GitHub issue projectItems.nodes is not an array".into(),
3855            })?;
3856        let held = match self.board_entry(nodes) {
3857            Some(held) => held.clone(),
3858            None => {
3859                let info = memberships
3860                    .get("pageInfo")
3861                    .ok_or_else(|| SourceError::Malformed {
3862                        message: "GitHub issue projectItems has no pageInfo".into(),
3863                    })?;
3864                // The page held no entry for this board. Whether that means the issue is
3865                // not on it is a question about the rest of the connection, and only a
3866                // connection with no rest answers it here.
3867                if !required_bool(info, "hasNextPage")? {
3868                    return Ok(None);
3869                }
3870                let cursor = required_str(info, "endCursor")?;
3871                validate_cursor_progress(None, cursor)?;
3872                let issue_id = required_str(issue, "id")?;
3873                match self.board_membership(issue_id, cursor).await? {
3874                    Some(held) => held,
3875                    None => return Ok(None),
3876                }
3877            }
3878        };
3879        let item = json!({
3880            "id": required_str(&held, "id")?,
3881            "project": held.get("project"),
3882            "fieldValues": held.get("fieldValues"),
3883            "content": issue,
3884        });
3885        self.resolve(&item)
3886    }
3887
3888    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3889    ///
3890    /// One spelling of *which membership is this board's*, so the page a read carries and
3891    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3892    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3893        nodes.iter().find(|node| {
3894            node.pointer("/project/number").and_then(Value::as_u64)
3895                == Some(u64::from(self.project_number))
3896        })
3897    }
3898
3899    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3900    ///
3901    /// The recovery read: a page of memberships that holds no entry for this board says
3902    /// nothing about the memberships past it, so the connection is walked to exhaustion
3903    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3904    /// positive answer — the whole connection was read and no entry named this board —
3905    /// rather than a failure, and the walk is held to
3906    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3907    /// with a cursor that does not advance is refused instead of spun on.
3908    async fn board_membership(
3909        &self,
3910        issue: &str,
3911        after: &str,
3912    ) -> Result<Option<Value>, SourceError> {
3913        let mut after = after.to_owned();
3914        loop {
3915            let data = self
3916                .graphql(
3917                    graphql::ISSUE_BOARD_ITEMS,
3918                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3919                           "nestedFirst":NESTED_PAGE_SIZE}),
3920                )
3921                .await?;
3922            let Some(connection) = data
3923                .pointer("/node/projectItems")
3924                .filter(|value| !value.is_null())
3925            else {
3926                // The id resolved to nothing, or to something with no memberships to walk —
3927                // which is the same answer as a connection holding no entry for this board.
3928                return Ok(None);
3929            };
3930            let nodes = connection
3931                .get("nodes")
3932                .and_then(Value::as_array)
3933                .ok_or_else(|| SourceError::Malformed {
3934                    message: "GitHub issue projectItems.nodes is not an array".into(),
3935                })?;
3936            if let Some(held) = self.board_entry(nodes) {
3937                return Ok(Some(held.clone()));
3938            }
3939            let info = connection
3940                .get("pageInfo")
3941                .ok_or_else(|| SourceError::Malformed {
3942                    message: "GitHub issue projectItems has no pageInfo".into(),
3943                })?;
3944            let next = required_bool(info, "hasNextPage")?
3945                .then(|| required_str(info, "endCursor"))
3946                .transpose()?;
3947            match next {
3948                Some(next) => {
3949                    validate_cursor_progress(Some(&after), next)?;
3950                    after = next.to_owned();
3951                }
3952                None => return Ok(None),
3953            }
3954        }
3955    }
3956
3957    /// One page of a board-scoped issue search, and where the next page resumes.
3958    async fn search_page(
3959        &self,
3960        search: &str,
3961        first: u32,
3962        after: Option<&str>,
3963    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3964        let data = self
3965            .graphql(
3966                graphql::SEARCH_ISSUES,
3967                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3968                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3969                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3970            )
3971            .await?;
3972        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3973            message: "GitHub search response has no search connection".into(),
3974        })?;
3975        let mut found = Vec::new();
3976        for node in connection
3977            .get("nodes")
3978            .and_then(Value::as_array)
3979            .ok_or_else(|| SourceError::Malformed {
3980                message: "GitHub search nodes is not an array".into(),
3981            })?
3982        {
3983            if let Some(resolved) = self.resolve_issue(node).await? {
3984                found.push(resolved);
3985            }
3986        }
3987        let info = connection
3988            .get("pageInfo")
3989            .ok_or_else(|| SourceError::Malformed {
3990                message: "GitHub search connection has no pageInfo".into(),
3991            })?;
3992        let next = required_bool(info, "hasNextPage")?
3993            .then(|| required_str(info, "endCursor"))
3994            .transpose()?
3995            .map(str::to_owned);
3996        if let Some(next) = &next {
3997            validate_cursor_progress(after, next)?;
3998        }
3999        Ok((found, next))
4000    }
4001
4002    /// Every issue this board holds, completed with what this run wrote.
4003    ///
4004    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4005    /// is an index and is eventually consistent, so an issue this run created seconds ago
4006    /// can be absent from it, and a project listed straight after being written would
4007    /// otherwise be missing from its own board. What is added back is only what this
4008    /// process itself wrote, out of [`Self::created`], which lives and dies with the
4009    /// process.
4010    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4011        let found = self.searched_issues().await?;
4012        self.completed_with_written(found, |_| true)
4013    }
4014
4015    /// Every issue this board's own search reports, walked to exhaustion, read once per
4016    /// source.
4017    ///
4018    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4019    /// needs it too and the two would otherwise walk the same search twice in one command.
4020    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4021    /// is.
4022    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4023        let cached = self.search_cache()?.clone();
4024        if let Some(held) = cached {
4025            return Ok(held);
4026        }
4027        let mut after: Option<String> = None;
4028        let mut found = Vec::new();
4029        let search = self.board_search(None);
4030        loop {
4031            let (page, next) = self
4032                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4033                .await?;
4034            found.extend(page);
4035            match next {
4036                Some(next) => after = Some(next),
4037                None => break,
4038            }
4039        }
4040        *self.search_cache()? = Some(found.clone());
4041        Ok(found)
4042    }
4043
4044    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4045    fn search_cache(
4046        &self,
4047    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4048        self.search_cache
4049            .lock()
4050            .map_err(|_| SourceError::Unavailable {
4051                message: "this source's view of the board's issues was left inconsistent by an \
4052                      earlier failure; next: run the command again"
4053                    .into(),
4054            })
4055    }
4056
4057    /// `found`, with everything this run wrote that `keep` accepts and the read did not
4058    /// report.
4059    ///
4060    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4061    /// at all: the search index is behind, and a node read of an item filed moments ago can
4062    /// be too.
4063    fn completed_with_written(
4064        &self,
4065        mut found: Vec<Resolved>,
4066        keep: impl Fn(&Resolved) -> bool,
4067    ) -> Result<Vec<Resolved>, SourceError> {
4068        for own in self.created()?.iter().filter(|own| keep(own)) {
4069            if !found.iter().any(|item| item.id == own.id) {
4070                found.push(own.clone());
4071            }
4072        }
4073        Ok(found)
4074    }
4075
4076    /// What resolving one node id reached.
4077    ///
4078    /// Three answers rather than an `Option`, because a board *draft* is none of the other
4079    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4080    /// is completed by a read of the draft itself rather than reported as nothing.
4081    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4082        let asked = self
4083            .graphql(
4084                graphql::ISSUE,
4085                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4086                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4087            )
4088            .await;
4089        let data = match asked {
4090            Ok(data) => data,
4091            // A string that is not a node id at all is not a failure to report: it is an id
4092            // this board does not hold, which is what every read of one already answers.
4093            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4094            Err(error) => return Err(error),
4095        };
4096        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4097            return Ok(Reached::Nothing);
4098        };
4099        if optional_str(node, "__typename")? == Some("DraftIssue") {
4100            return Ok(Reached::Draft);
4101        }
4102        Ok(match self.resolve_issue(node).await? {
4103            Some(item) => Reached::Held(Box::new(item)),
4104            None => Reached::Nothing,
4105        })
4106    }
4107
4108    /// One item of this board by its own id, whatever kind it is.
4109    ///
4110    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4111    /// run wrote is read first, because a node read of an item created moments ago can
4112    /// still be behind the board field values written onto it — see [`Self::created`].
4113    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4114        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4115            return Ok(Some(own.clone()));
4116        }
4117        match self.reach(id).await? {
4118            Reached::Held(item) => Ok(Some(*item)),
4119            Reached::Nothing => Ok(None),
4120            Reached::Draft => self.draft_by_id(id).await,
4121        }
4122    }
4123
4124    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4125    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4126    /// than one request per id.
4127    ///
4128    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4129    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4130    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4131    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4132    /// is completed by a read of the draft, exactly as there.
4133    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4134        let mut found: Vec<Option<Option<Resolved>>> = {
4135            let created = self.created()?;
4136            ids.iter()
4137                .map(|id| {
4138                    created
4139                        .iter()
4140                        .find(|own| own.id == *id)
4141                        .map(|own| Some(own.clone()))
4142                })
4143                .collect()
4144        };
4145        let unread: Vec<NativeId> = ids
4146            .iter()
4147            .zip(&found)
4148            .filter(|(_, found)| found.is_none())
4149            .map(|(id, _)| id.clone())
4150            .collect();
4151        let mut read = Vec::with_capacity(unread.len());
4152        if let [one] = unread.as_slice() {
4153            read.push(self.item_by_id(one).await?);
4154        } else {
4155            for batch in unread.chunks(DETAIL_BATCH) {
4156                let data = match self
4157                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4158                    .await
4159                {
4160                    Ok(data) => data,
4161                    Err(error) if unresolvable_node(&error) => {
4162                        for id in batch {
4163                            read.push(self.item_by_id(id).await?);
4164                        }
4165                        continue;
4166                    }
4167                    Err(error) => return Err(error),
4168                };
4169                for (slot, id) in batch.iter().enumerate() {
4170                    let node =
4171                        data.get(format!("i{slot}"))
4172                            .ok_or_else(|| SourceError::Malformed {
4173                                message: format!(
4174                                    "GitHub answered a batch read with no item for {}",
4175                                    id.0
4176                                ),
4177                            })?;
4178                    read.push(if node.is_null() {
4179                        None
4180                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4181                        self.draft_by_id(id).await?
4182                    } else {
4183                        if optional_str(node, "__typename")? == Some("Issue")
4184                            && required_str(node, "id")? != id.0
4185                        {
4186                            return Err(SourceError::Malformed {
4187                                message: format!(
4188                                    "GitHub answered the read of {} with issue {}",
4189                                    id.0,
4190                                    required_str(node, "id")?
4191                                ),
4192                            });
4193                        }
4194                        self.resolve_issue(node).await?
4195                    });
4196                }
4197            }
4198        }
4199        let mut read = read.into_iter();
4200        Ok(found
4201            .iter_mut()
4202            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4203            .collect())
4204    }
4205
4206    fn resolved_cache(
4207        &self,
4208    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4209        self.resolved_cache
4210            .lock()
4211            .map_err(|_| SourceError::Unavailable {
4212                message: "resolved item records were left inconsistent; run the command again"
4213                    .into(),
4214            })
4215    }
4216
4217    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4218    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4219    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4220        let cached = self.resolved_cache()?.get(id).cloned();
4221        match cached {
4222            Some(item) => Ok(Some(item)),
4223            None => self.item_by_id(id).await,
4224        }
4225    }
4226
4227    /// One board draft by its own id, with the board item it sits in — or `None` when no
4228    /// item of this board is that draft's.
4229    ///
4230    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4231    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4232    /// links a draft to one board item, so the page this read carries is the whole of that
4233    /// connection, and a page that reports more than it holds is refused rather than read
4234    /// as an answer about memberships nobody read.
4235    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4236        let data = self
4237            .graphql(
4238                graphql::DRAFT,
4239                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4240                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4241            )
4242            .await?;
4243        // Gone between the two reads is an answer — the draft is no longer there. Anything
4244        // else than the draft [`Self::reach`] was just told this id is, is not one.
4245        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4246            return Ok(None);
4247        };
4248        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4249            return Err(SourceError::Malformed {
4250                message: format!(
4251                    "GitHub answered {} as a draft and then as something else",
4252                    id.0
4253                ),
4254            });
4255        }
4256        if required_str(draft, "id")? != id.0 {
4257            return Err(SourceError::Malformed {
4258                message: format!("GitHub answered a different draft for {}", id.0),
4259            });
4260        }
4261        let memberships = draft
4262            .get("projectV2Items")
4263            .ok_or_else(|| SourceError::Malformed {
4264                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4265            })?;
4266        let nodes = memberships
4267            .get("nodes")
4268            .and_then(Value::as_array)
4269            .ok_or_else(|| SourceError::Malformed {
4270                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4271            })?;
4272        let info = memberships
4273            .get("pageInfo")
4274            .ok_or_else(|| SourceError::Malformed {
4275                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4276            })?;
4277        // Read whether or not this board's entry is on the page: a page claiming more than
4278        // the one item GitHub links a draft to is a malformed answer either way.
4279        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4280            return Err(SourceError::Malformed {
4281                message: format!(
4282                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4283                     to",
4284                    id.0
4285                ),
4286            });
4287        }
4288        if let Some(node) = nodes.first()
4289            && node
4290                .pointer("/project/number")
4291                .and_then(Value::as_u64)
4292                .is_none()
4293        {
4294            return Err(SourceError::Malformed {
4295                message: format!(
4296                    "GitHub draft {} board item has no numeric project number",
4297                    id.0
4298                ),
4299            });
4300        }
4301        let Some(held) = self.board_entry(nodes) else {
4302            return Ok(None);
4303        };
4304        if required_str(
4305            held.get("project").ok_or_else(|| SourceError::Malformed {
4306                message: format!("GitHub draft {} board item has no project", id.0),
4307            })?,
4308            "id",
4309        )? != self.board_fields().await?.id.as_str()
4310        {
4311            return Ok(None);
4312        }
4313        let item = json!({
4314            "id": required_str(held, "id")?,
4315            "project": held.get("project"),
4316            "fieldValues": held.get("fieldValues"),
4317            "content": draft,
4318        });
4319        self.resolve(&item)
4320    }
4321
4322    /// The board's own id and field definitions, for a write whose item does not carry
4323    /// them — never its items.
4324    ///
4325    /// A board this command has already listed supplies them, since it read them beside its
4326    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4327    /// is consulted about which items the board holds: see the module documentation for
4328    /// why a question about one known item is answered by reading that item.
4329    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4330        if let Some(board) = self.board_cache()?.as_ref() {
4331            return Ok(BoardFields {
4332                id: BoardId::parse(&board.id)?,
4333                fields: board.fields.clone(),
4334            });
4335        }
4336        if let Some(held) = self.fields_cache()?.clone() {
4337            return Ok(held);
4338        }
4339        let data = self
4340            .graphql(
4341                graphql::BOARD_FIELDS,
4342                json!({"owner":self.owner,"number":self.project_number,
4343                       "nestedFirst":NESTED_PAGE_SIZE}),
4344            )
4345            .await?;
4346        self.fields_read(&data)
4347    }
4348
4349    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4350    /// the rest of this command.
4351    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4352        let board = data
4353            .pointer("/boardFields/projectV2")
4354            .filter(|value| !value.is_null())
4355            .ok_or_else(|| SourceError::Refused {
4356                message: format!(
4357                    "GitHub project {}/{} was not found or is not visible to the token",
4358                    self.owner, self.project_number
4359                ),
4360            })?;
4361        let read = BoardFields {
4362            id: BoardId::parse(required_str(board, "id")?)?,
4363            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4364        };
4365        *self.fields_cache()? = Some(read.clone());
4366        Ok(read)
4367    }
4368
4369    /// Read what creating an issue in `repository` needs and this command has not read yet —
4370    /// the board's fields and the repository's node id — in one request when it needs both.
4371    ///
4372    /// When either is already known this sends nothing, and the other is read by its own
4373    /// document where it is asked for, so no create reads anything twice.
4374    async fn creation_context(
4375        &self,
4376        repository: &RepositoryTarget,
4377        incoming: &Incoming<'_>,
4378    ) -> Result<(), SourceError> {
4379        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4380        if fields_known || self.repository_cache()?.contains_key(repository) {
4381            return Ok(());
4382        }
4383        let data = self
4384            .graphql(
4385                graphql::CREATION_CONTEXT,
4386                json!({"owner":self.owner,"number":self.project_number,
4387                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4388                       "repositoryName":repository.name}),
4389            )
4390            .await?;
4391        self.fields_read(&data)?;
4392        self.repository_read(&data, repository, incoming)?;
4393        Ok(())
4394    }
4395
4396    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4397    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4398        self.fields_cache
4399            .lock()
4400            .map_err(|_| SourceError::Unavailable {
4401                message: "this source's view of the board's fields was left inconsistent by an \
4402                      earlier failure; next: run the command again"
4403                    .into(),
4404            })
4405    }
4406
4407    /// What a write to `item` needs of the board, read off that item when it says enough and
4408    /// off [`Self::board_fields`] when it does not.
4409    ///
4410    /// A node read of an item names its board and carries the definition of every field it
4411    /// holds a value of — so an item naming its board, holding a value of the origin field,
4412    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4413    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4414    /// of may still be on the board, and a view reading it as absent would refuse a write the
4415    /// board can take or skip a field write the board needs, so such an item — and a create,
4416    /// which has no item yet — takes the board's fields from their own read instead.
4417    async fn fields_for(
4418        &self,
4419        item: Option<&Resolved>,
4420        writes_status: bool,
4421        selects_priority: bool,
4422    ) -> Result<BoardFields, SourceError> {
4423        if let Some(board) = item.and_then(Resolved::carried_board) {
4424            return Ok(board);
4425        }
4426        if let Some(item) = item
4427            && let Some(board_id) = item.named_board()
4428            && item.defines(ORIGIN_FIELD)
4429            && (!writes_status || item.defines("Status"))
4430            && (!selects_priority || item.defines(PRIORITY_FIELD))
4431        {
4432            return Ok(BoardFields {
4433                id: board_id,
4434                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4435            });
4436        }
4437        self.board_fields().await
4438    }
4439
4440    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4441    /// when that id names nothing here with a sub-issue relationship to walk.
4442    ///
4443    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4444    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4445    /// empty vector is a project that holds nothing.
4446    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4447        let mut after: Option<String> = None;
4448        let mut children = Vec::new();
4449        loop {
4450            let asked = self
4451                .graphql(
4452                    graphql::SUB_ISSUES,
4453                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4454                           "nestedFirst":NESTED_PAGE_SIZE,
4455                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4456                )
4457                .await;
4458            let data = match asked {
4459                Ok(data) => data,
4460                // A string that is not a node id at all is not a failure to report: it is
4461                // the ordinary answer to a selector naming a project by its name.
4462                Err(error) if unresolvable_node(&error) => return Ok(None),
4463                Err(error) => return Err(error),
4464            };
4465            let Some(connection) = data
4466                .pointer("/node/subIssues")
4467                .filter(|value| !value.is_null())
4468            else {
4469                // No such node, or one with no sub-issue relationship — a board draft is
4470                // the one this board can really hold.
4471                return Ok(None);
4472            };
4473            for node in connection
4474                .get("nodes")
4475                .and_then(Value::as_array)
4476                .ok_or_else(|| SourceError::Malformed {
4477                    message: "GitHub subIssues.nodes is not an array".into(),
4478                })?
4479            {
4480                if let Some(resolved) = self.resolve_issue(node).await? {
4481                    children.push(resolved);
4482                }
4483            }
4484            let info = connection
4485                .get("pageInfo")
4486                .ok_or_else(|| SourceError::Malformed {
4487                    message: "GitHub subIssues connection has no pageInfo".into(),
4488                })?;
4489            let next = required_bool(info, "hasNextPage")?
4490                .then(|| required_str(info, "endCursor"))
4491                .transpose()?;
4492            match next {
4493                Some(next) => {
4494                    validate_cursor_progress(after.as_deref(), next)?;
4495                    after = Some(next.to_owned());
4496                }
4497                None => return Ok(Some(children)),
4498            }
4499        }
4500    }
4501
4502    /// Which issue of this board a project *name* is, or `None` when none is.
4503    ///
4504    /// One bounded query which filters on that name at the server, rather than a walk of
4505    /// every issue the board holds. The name is compared again here: the qualifier narrows
4506    /// what GitHub sends, and this source decides what it names.
4507    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4508        let search = self.board_search(Some(&title_qualifier(name)));
4509        let mut after = None;
4510        loop {
4511            let (candidates, next) = self
4512                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4513                .await?;
4514            if let Some(item) = candidates.into_iter().find(|item| {
4515                item.kind == BoardKind::Work(ItemKind::Project)
4516                    && item.title.eq_ignore_ascii_case(name)
4517            }) {
4518                return Ok(Some(item.id));
4519            }
4520            match next {
4521                Some(next) => after = Some(next),
4522                None => return Ok(None),
4523            }
4524        }
4525    }
4526
4527    /// Everything filed under one project of this board: the sub-issues of the issue that
4528    /// project is.
4529    ///
4530    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4531    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4532    /// gains projects, or as another project gains tasks.
4533    ///
4534    /// A qualified id names the issue and is asked for its sub-issues directly: one
4535    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4536    /// read as a project *name*, which costs the one bounded search
4537    /// [`Self::project_by_name`] makes.
4538    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4539        let (project, children) = match self.sub_issues(selector).await? {
4540            Some(children) => (selector.clone(), children),
4541            None => match self.project_by_name(&selector.0).await? {
4542                Some(project) => {
4543                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4544                    (project, children)
4545                }
4546                None => return Ok(Vec::new()),
4547            },
4548        };
4549        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4550    }
4551
4552    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4553    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4554    ///
4555    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4556    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4557    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4558    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4559    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4560    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4561    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4562    /// rather than silently narrowing a caller's answer.
4563    ///
4564    /// The instant is written to the second, rounded down, which can only widen what the
4565    /// search returns; confirmation against each candidate's own comments is what makes the
4566    /// answer exact. The search is an index that lags a write by a second or two — the module
4567    /// documentation records it — so a caller that asks again from its last instant should
4568    /// overlap the two by more than that.
4569    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4570        let found = self.searched(&updated_qualifier(since)).await?;
4571        self.completed_with_written(found, |_| true)
4572    }
4573
4574    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4575    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4576    ///
4577    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4578    /// own record is the fresher of the two.
4579    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4580        let search = self.board_search(Some(also));
4581        let mut after: Option<String> = None;
4582        let mut found = Vec::new();
4583        loop {
4584            let (page, next) = self
4585                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4586                .await?;
4587            found.extend(page);
4588            match next {
4589                Some(next) => after = Some(next),
4590                None => return Ok(found),
4591            }
4592        }
4593    }
4594
4595    /// A bounded task answer; the versioned cursor carries the connection position, how
4596    /// many rows of the page starting there were already handed out, and the own-write ids
4597    /// already observed, including across a new source instance.
4598    ///
4599    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4600    /// sliced from the pages it needs; why is the module documentation's paging contract.
4601    async fn search_tasks(
4602        &self,
4603        query: &TaskQuery,
4604        page: &PageRequest,
4605        also: &str,
4606    ) -> Result<Page<Task>, SourceError> {
4607        let mut position = match &page.cursor {
4608            None => SearchPosition::default(),
4609            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4610                .ok()
4611                .filter(|position| {
4612                    position.version == SEARCH_CURSOR_VERSION
4613                        && position.connection.valid_resume(position.offset)
4614                })
4615                .ok_or_else(|| SourceError::Config {
4616                    message: "page cursor is invalid".into(),
4617                })?,
4618        };
4619        let search = self.board_search(Some(also));
4620        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4621        let own = self.with_own_writes(Vec::new())?;
4622        for item in &own {
4623            if !position.own.contains(&item.id) {
4624                position.own.push(item.id.clone());
4625            }
4626        }
4627        let mut tasks = Vec::new();
4628        while !position.connection.exhausted() && tasks.len() < limit {
4629            let first = SEARCH_PAGE_SIZE;
4630            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4631            let key =
4632                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4633                    .expect("search page key is serializable");
4634            let cached = if query.commented_since.is_none() {
4635                self.narrowed_cache()?.get(&key).cloned()
4636            } else {
4637                None
4638            };
4639            let (found, next) = match cached {
4640                Some(found) => {
4641                    let next = self
4642                        .search_next
4643                        .lock()
4644                        .map_err(|_| SourceError::Unavailable {
4645                            message:
4646                                "search pagination was left inconsistent; run the command again"
4647                                    .into(),
4648                        })?
4649                        .get(&key)
4650                        .cloned()
4651                        .flatten();
4652                    (found, next)
4653                }
4654                None => {
4655                    let (found, next) = self
4656                        .search_page(&search, first, position.connection.after())
4657                        .await?;
4658                    if query.commented_since.is_none() {
4659                        self.search_next
4660                            .lock()
4661                            .map_err(|_| SourceError::Unavailable {
4662                                message:
4663                                    "search pagination was left inconsistent; run the command again"
4664                                        .into(),
4665                            })?
4666                            .insert(key.clone(), next.clone());
4667                        self.narrowed_cache()?.insert(key, found.clone());
4668                    }
4669                    (found, next)
4670                }
4671            };
4672            let rows = found.len();
4673            for mut item in found.into_iter().skip(position.offset) {
4674                if tasks.len() == limit {
4675                    break;
4676                }
4677                position.offset += 1;
4678                if position.own.contains(&item.id) {
4679                    if position.seen.contains(&item.id) {
4680                        continue;
4681                    }
4682                    position.seen.push(item.id.clone());
4683                    let updated_at = item.updated_at;
4684                    let Some(written) = self.search_written(&own, &item.id).await? else {
4685                        continue;
4686                    };
4687                    item = written;
4688                    item.updated_at = item.updated_at.max(updated_at);
4689                    self.resolved_cache()?.insert(item.id.clone(), item.clone());
4690                }
4691                if item.kind == BoardKind::Work(ItemKind::Task) {
4692                    let task = item.task()?;
4693                    if task_matches(&task, query, &query.project)
4694                        && self.commented_since(&item, query.commented_since).await?
4695                    {
4696                        tasks.push(task);
4697                    }
4698                }
4699            }
4700            if position.offset < rows {
4701                continue;
4702            }
4703            position.offset = 0;
4704            position.connection = match next {
4705                Some(after) => SearchConnection::Continuing {
4706                    after: Cursor(after),
4707                },
4708                None => SearchConnection::Exhausted {},
4709            };
4710        }
4711        if position.connection.exhausted() {
4712            for id in position.own.clone() {
4713                if position.seen.contains(&id) {
4714                    continue;
4715                }
4716                if tasks.len() == limit {
4717                    break;
4718                }
4719                position.seen.push(id.clone());
4720                let Some(item) = self.search_written(&own, &id).await? else {
4721                    continue;
4722                };
4723                if item.kind == BoardKind::Work(ItemKind::Task) {
4724                    let task = item.task()?;
4725                    if task_matches(&task, query, &query.project)
4726                        && self.commented_since(&item, query.commented_since).await?
4727                    {
4728                        tasks.push(task);
4729                    }
4730                }
4731            }
4732        }
4733        let more = !position.connection.exhausted()
4734            || position.own.iter().any(|id| !position.seen.contains(id));
4735        Ok(Page {
4736            items: tasks,
4737            next: more.then(|| {
4738                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4739            }),
4740        })
4741    }
4742
4743    /// A resumed process has the ids but no write records; resolve only a record the
4744    /// current page needs, by its uncached node read rather than the lagging search index.
4745    async fn search_written(
4746        &self,
4747        own: &[Resolved],
4748        id: &NativeId,
4749    ) -> Result<Option<Resolved>, SourceError> {
4750        match own.iter().find(|item| item.id == *id) {
4751            Some(item) => Ok(Some(item.clone())),
4752            None => self.item_by_id(id).await,
4753        }
4754    }
4755
4756    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4757    /// without enumerating the board — or `None` for a query carrying none of the three, which
4758    /// keeps the reads it always had.
4759    ///
4760    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4761    /// because it names at most a handful of items. Text and metadata are answered by one
4762    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4763    /// further by `updated:>=` when the query also asks for comment activity, since both
4764    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4765    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4766    ///
4767    /// Completed with what this process wrote, its own record winning over the index's copy
4768    /// of the same item: see [`Self::with_own_writes`].
4769    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4770        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4771            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4772            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4773                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4774                None => qualifiers,
4775            }),
4776            (None, None) => return Ok(None),
4777        };
4778        // A question about comment activity is asked afresh every time, as it always was: it
4779        // is the one a caller polls from one source while waiting for the index, and an
4780        // answer held from the first poll would be the answer to every later one.
4781        let key = query.commented_since.is_none().then(|| asked.key());
4782        let cached = match &key {
4783            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4784            None => None,
4785        };
4786        let found = match cached {
4787            Some(found) => found,
4788            None => {
4789                let found = match &asked {
4790                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4791                    Narrowing::Search(also) => self.searched(also).await?,
4792                };
4793                if let Some(key) = key {
4794                    self.narrowed_cache()?.insert(key, found.clone());
4795                }
4796                found
4797            }
4798        };
4799        self.with_own_writes(found).map(Some)
4800    }
4801
4802    /// The candidates for a project or unscoped document query carrying a searchable text,
4803    /// read without enumerating the board — or `None` for a query with no text or a blank one,
4804    /// which keeps the read it always had.
4805    ///
4806    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4807    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4808    /// costs is the issues that match and never the board. Its answer is held for the command
4809    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4810    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4811    /// substring rule, exactly as an item of the wider read was, and is completed with what this
4812    /// process wrote: see [`Self::with_own_writes`].
4813    async fn text_searched(
4814        &self,
4815        text: Option<&TextQuery>,
4816    ) -> Result<Option<Vec<Resolved>>, SourceError> {
4817        let Some(also) = text_qualifiers(text) else {
4818            return Ok(None);
4819        };
4820        let key = Narrowing::Search(also.clone()).key();
4821        let cached = self.narrowed_cache()?.get(&key).cloned();
4822        let found = match cached {
4823            Some(found) => found,
4824            None => {
4825                let found = self.searched(&also).await?;
4826                self.narrowed_cache()?.insert(key, found.clone());
4827                found
4828            }
4829        };
4830        self.with_own_writes(found).map(Some)
4831    }
4832
4833    /// Every item of this board that may carry `origin` — a superset of those that do — found
4834    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4835    ///
4836    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4837    /// which reads the field every carrier holds, whichever release wrote it — and the
4838    /// board-scoped issue search for the same id as a phrase in the body, where this source
4839    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4840    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4841    /// query's, exactly.
4842    ///
4843    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4844    /// already ended is sent its last cursor again, which answers an empty page, so the one
4845    /// document serves every page of either. What the two leave is stated in the module
4846    /// documentation: a carrier another process added within the last second or two, before
4847    /// either index has it.
4848    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4849        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4850        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4851        let mut items_after: Option<String> = None;
4852        let mut search_after: Option<String> = None;
4853        let mut found: Vec<Resolved> = Vec::new();
4854        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4855            if !found.iter().any(|held| held.id == resolved.id) {
4856                found.push(resolved);
4857            }
4858        };
4859        loop {
4860            let data = self
4861                .graphql(
4862                    graphql::ORIGIN_LOOKUP,
4863                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4864                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4865                           "itemsAfter":items_after,"searchAfter":search_after,
4866                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4867                           "duplicates":true}),
4868                )
4869                .await?;
4870            let items = data
4871                .pointer("/originItems/projectV2/items")
4872                .filter(|value| !value.is_null())
4873                .ok_or_else(|| SourceError::Refused {
4874                    message: format!(
4875                        "GitHub project {}/{} was not found or is not visible to the token",
4876                        self.owner, self.project_number
4877                    ),
4878                })?;
4879            for item in optional_nodes(Some(items), "project items")?
4880                .into_iter()
4881                .flatten()
4882            {
4883                // The board's own items list its drafts too, and a draft is not an issue: no
4884                // narrowed read answers with one, whatever its origin field holds.
4885                if let Some(resolved) = self.resolve(item)?
4886                    && resolved.content_kind == ContentKind::Issue
4887                {
4888                    keep(resolved, &mut found);
4889                }
4890            }
4891            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4892                message: "GitHub search response has no search connection".into(),
4893            })?;
4894            for node in optional_nodes(Some(searched), "search")?
4895                .into_iter()
4896                .flatten()
4897            {
4898                if let Some(resolved) = self.resolve_issue(node).await? {
4899                    keep(resolved, &mut found);
4900                }
4901            }
4902            let items_next = resumed(items, items_after.as_deref())?;
4903            let search_next = resumed(searched, search_after.as_deref())?;
4904            if !items_next.has_more() && !search_next.has_more() {
4905                return Ok(found);
4906            }
4907            items_after = items_next.cursor();
4908            search_after = search_next.cursor();
4909        }
4910    }
4911
4912    /// `found`, with every item this process created or wrote in its place, and every one of
4913    /// them the read did not report added.
4914    ///
4915    /// This process's own record wins over the read's copy of the same item, because a read
4916    /// of an item written moments ago can still be behind what was written onto it — the
4917    /// origin field included, which is the one a narrowed read is confirmed against — and a
4918    /// read that still names an item under a predicate this process's write moved it out of
4919    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4920    /// last saw the item change, which is what a comment-activity read rules a candidate out
4921    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4922    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4923    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4924        // A board draft is not an issue, so no narrowed read returns one, and this process
4925        // having written one does not make it an answer either.
4926        let own: Vec<Resolved> = self
4927            .created()?
4928            .iter()
4929            .chain(self.updated()?.iter())
4930            .filter(|own| own.content_kind == ContentKind::Issue)
4931            .cloned()
4932            .collect();
4933        for mut own in own {
4934            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4935            match found.iter_mut().find(|read| read.id == own.id) {
4936                Some(read) => {
4937                    own.updated_at = own.updated_at.max(read.updated_at);
4938                    *read = own;
4939                }
4940                None => found.push(own),
4941            }
4942        }
4943        Ok(found)
4944    }
4945
4946    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4947    /// there is no instant to hold it to.
4948    ///
4949    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4950    /// or after the instant moved it there: an issue not updated since holds no such comment,
4951    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4952    /// only as far as the first that matches. A board draft is not an issue and has no
4953    /// comments, so it never matches.
4954    async fn commented_since(
4955        &self,
4956        item: &Resolved,
4957        since: Option<DateTime<Utc>>,
4958    ) -> Result<bool, SourceError> {
4959        let Some(since) = since else {
4960            return Ok(true);
4961        };
4962        if item.content_kind == ContentKind::DraftIssue
4963            || item.updated_at.is_some_and(|updated| updated < since)
4964        {
4965            return Ok(false);
4966        }
4967        let query = TaskQuery {
4968            commented_since: Some(since),
4969            ..TaskQuery::default()
4970        };
4971        let mut after: Option<String> = None;
4972        loop {
4973            let data = self
4974                .graphql(
4975                    graphql::ISSUE_COMMENTS,
4976                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4977                )
4978                .await?;
4979            let Some(connection) = data
4980                .get("node")
4981                .filter(|value| !value.is_null())
4982                .and_then(|node| node.get("comments"))
4983                .filter(|value| !value.is_null())
4984            else {
4985                // Removed since the search reported it: no longer an issue with comments.
4986                return Ok(false);
4987            };
4988            let comments = optional_nodes(Some(connection), "issue comments")?
4989                .into_iter()
4990                .flatten()
4991                .map(comment_from)
4992                .collect::<Result<Vec<_>, _>>()?;
4993            if query.comments_match(&comments) {
4994                return Ok(true);
4995            }
4996            match next_cursor(connection)? {
4997                Some(next) => {
4998                    validate_cursor_progress(after.as_deref(), &next.0)?;
4999                    after = Some(next.0);
5000                }
5001                None => return Ok(false),
5002            }
5003        }
5004    }
5005
5006    /// Every item on the board: the union of both enumerations GitHub offers of one.
5007    ///
5008    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5009    /// board **draft** and reads the board's own fields beside its items, and only the search
5010    /// reports an item that connection is behind on. The module documentation is where the lag and the
5011    /// measurements behind it are written down.
5012    ///
5013    /// A search result is admitted on the same terms as any other issue this source reaches
5014    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5015    /// names *this* board — so an issue the index still believes is here after it was taken
5016    /// off is refused rather than reported.
5017    ///
5018    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5019    /// which is what the cache could otherwise have broken.
5020    async fn board(&self) -> Result<Board, SourceError> {
5021        let cached = self.board_cache()?.clone();
5022        let mut board = match cached {
5023            Some(board) => board,
5024            None => {
5025                let read = self.read_board().await?;
5026                *self.board_cache()? = Some(read.clone());
5027                read
5028            }
5029        };
5030        for held in self.searched_issues().await? {
5031            if !board.items.iter().any(|item| item.id == held.id) {
5032                board.items.push(held);
5033            }
5034        }
5035        for own in self.created()?.iter() {
5036            if !board.items.iter().any(|item| item.id == own.id) {
5037                board.items.push(own.clone());
5038            }
5039        }
5040        Ok(board)
5041    }
5042
5043    /// This process's own view of the board, or the refusal a poisoned lock is.
5044    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5045        self.board_cache
5046            .lock()
5047            .map_err(|_| SourceError::Unavailable {
5048                message: "this source's view of the board was left inconsistent by an earlier \
5049                      failure; next: run the command again"
5050                    .into(),
5051            })
5052    }
5053
5054    /// Bring this process's own view of the board up to an item it has just written.
5055    ///
5056    /// A created item goes to `created`, which is what completes a board read GitHub's own
5057    /// eventual consistency has left behind. An item that was already there is replaced
5058    /// where it sits, so a second write of it in the same command reads its real parent
5059    /// rather than the one it had before the first write.
5060    ///
5061    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5062    /// that wins: an item this same run created is held in `created` and not in the cached
5063    /// board, and `board` completes the cached board *from* `created`, so replacing only
5064    /// the cached copy of such an item replaces nothing and the read still reports the
5065    /// title it was created with. The search is the third, and it is the one an item the
5066    /// board's own projection is behind on sits in *alone* — which is exactly the item this
5067    /// source is least able to re-read, so leaving it out would put the stale title back on
5068    /// the only items the completion in [`Self::board`] exists for.
5069    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5070        self.resolved_cache()?.insert(item.id.clone(), item.clone());
5071        if created {
5072            self.created()?.push(item);
5073            return Ok(());
5074        }
5075        {
5076            let mut own = self.created()?;
5077            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5078                *held = item;
5079                return Ok(());
5080            }
5081        }
5082        {
5083            let mut own = self.updated()?;
5084            match own.iter_mut().find(|held| held.id == item.id) {
5085                Some(held) => *held = item.clone(),
5086                None => own.push(item.clone()),
5087            }
5088        }
5089        if let Some(board) = self.board_cache()?.as_mut()
5090            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5091        {
5092            *held = item.clone();
5093        }
5094        if let Some(found) = self.search_cache()?.as_mut()
5095            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5096        {
5097            *held = item.clone();
5098        }
5099        for found in self.narrowed_cache()?.values_mut() {
5100            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5101                *held = item.clone();
5102            }
5103        }
5104        Ok(())
5105    }
5106
5107    /// Forget one item this process has just deleted, from every half of its own view.
5108    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5109        self.resolved_cache()?.remove(id);
5110        self.created()?.retain(|own| own.id != *id);
5111        self.updated()?.retain(|own| own.id != *id);
5112        if let Some(board) = self.board_cache()?.as_mut() {
5113            board.items.retain(|item| item.id != *id);
5114        }
5115        if let Some(found) = self.search_cache()?.as_mut() {
5116            found.retain(|item| item.id != *id);
5117        }
5118        for found in self.narrowed_cache()?.values_mut() {
5119            found.retain(|item| item.id != *id);
5120        }
5121        Ok(())
5122    }
5123
5124    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5125    fn narrowed_cache(
5126        &self,
5127    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5128        self.narrowed_cache
5129            .lock()
5130            .map_err(|_| SourceError::Unavailable {
5131                message: "this source's view of a narrowed read was left inconsistent by an \
5132                      earlier failure; next: run the command again"
5133                    .into(),
5134            })
5135    }
5136
5137    /// Every page of the board, read from GitHub.
5138    async fn read_board(&self) -> Result<Board, SourceError> {
5139        let mut after: Option<String> = None;
5140        let mut items = Vec::new();
5141        let mut board;
5142        loop {
5143            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5144            for item in page
5145                .pointer("/items/nodes")
5146                .and_then(Value::as_array)
5147                .ok_or_else(|| SourceError::Malformed {
5148                    message: "GitHub project items.nodes is not an array".into(),
5149                })?
5150            {
5151                if let Some(resolved) = self.resolve(item)? {
5152                    items.push(resolved);
5153                }
5154            }
5155            let info = page
5156                .pointer("/items/pageInfo")
5157                .ok_or_else(|| SourceError::Malformed {
5158                    message: "GitHub project items have no pageInfo".into(),
5159                })?;
5160            let has_next = required_bool(info, "hasNextPage")?;
5161            let next = has_next
5162                .then(|| required_str(info, "endCursor"))
5163                .transpose()?;
5164            board = page.clone();
5165            match next {
5166                Some(next) => {
5167                    validate_cursor_progress(after.as_deref(), next)?;
5168                    after = Some(next.to_owned());
5169                }
5170                None => break,
5171            }
5172        }
5173        Ok(Board {
5174            id: required_str(&board, "id")?.to_owned(),
5175            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5176            items,
5177        })
5178    }
5179
5180    /// The existing items this source has written, for completing a narrowed read that is
5181    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5182    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5183        self.updated.lock().map_err(|_| SourceError::Unavailable {
5184            message: "this source's record of what it wrote in this run was left inconsistent \
5185                      by an earlier failure; next: run the command again"
5186                .into(),
5187        })
5188    }
5189
5190    /// The items this source has created, for completing a board read that is behind.
5191    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5192        self.created.lock().map_err(|_| SourceError::Unavailable {
5193            message: "this source's record of what it created in this run was left \
5194                      inconsistent by an earlier failure; next: run the command again"
5195                .into(),
5196        })
5197    }
5198
5199    /// One board item as this source reports it, or `None` for content it ignores.
5200    ///
5201    /// A pull request is neither a project nor a task — it is somebody's change, not a
5202    /// unit of plan — and an item whose content the token cannot see has nothing to
5203    /// report at all.
5204    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5205        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5206            message: "GitHub project item is missing content".into(),
5207        })?;
5208        if content.is_null() {
5209            return Ok(None);
5210        }
5211        let content_kind = match required_str(content, "__typename")? {
5212            "Issue" => ContentKind::Issue,
5213            "DraftIssue" => ContentKind::DraftIssue,
5214            _ => return Ok(None),
5215        };
5216        let field_values = item
5217            .get("fieldValues")
5218            .ok_or_else(|| SourceError::Malformed {
5219                message: "GitHub project item is missing fieldValues".into(),
5220            })?;
5221        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5222        let nodes = field_values
5223            .get("nodes")
5224            .and_then(Value::as_array)
5225            .ok_or_else(|| SourceError::Malformed {
5226                message: "GitHub project item fieldValues.nodes is not an array".into(),
5227            })?;
5228        if let Some(labels) = content.get("labels") {
5229            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5230        }
5231        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5232        let (body, slot) = metadata_body(raw_body.clone())?;
5233        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5234            .map(|id| NativeId(id.to_owned()));
5235        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5236        // to read one from; it is a task, and never a project.
5237        let sub_issues = match content_kind {
5238            ContentKind::Issue => sub_issue_total(content)?,
5239            ContentKind::DraftIssue => 0,
5240        };
5241        let content_id = required_str(content, "id")?;
5242        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5243            message: format!("GitHub issue {content_id}: {message}"),
5244        })?;
5245        let raw_title = required_str(content, "title")?;
5246        // The design prefix is read *first*, before either of the two rules that separate
5247        // a project from a task. A document is not work whatever sub-issues it has and
5248        // whatever marker it carries, and reading the prefix later would make a design
5249        // issue with none of either an empty project.
5250        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5251            BoardKind::Document
5252        } else if parent.is_some() {
5253            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5254            // under a project is that project's task even when it has sub-issues of its
5255            // own.
5256            BoardKind::Work(ItemKind::Task)
5257        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5258            BoardKind::Work(ItemKind::Project)
5259        } else {
5260            BoardKind::Work(ItemKind::Task)
5261        };
5262        // The title a person wrote, which for a document is the one without the prefix —
5263        // the same way `content` above is the body without this source's metadata slot.
5264        let title = match kind {
5265            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5266            BoardKind::Work(_) => raw_title.to_owned(),
5267        };
5268        let own_repository = content
5269            .pointer("/repository/nameWithOwner")
5270            .and_then(Value::as_str)
5271            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5272            .transpose()
5273            .map_err(|message| SourceError::Malformed { message })?;
5274        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5275            Repository::from_metadata(&slot)
5276                .map_err(|message| SourceError::Malformed { message })?
5277        } else {
5278            own_repository.clone().into_iter().collect()
5279        };
5280        let id = NativeId(content_id.to_owned());
5281        // Read only for a task, because only a task has either list: a project or a
5282        // document holding one of these keys holds nothing this source reports, and the
5283        // keys are left out of its caller-visible metadata all the same.
5284        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5285            let listed = |key: &str| {
5286                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5287                    .map_err(|message| SourceError::Malformed { message })
5288            };
5289            (
5290                listed(TaskRef::DELIVERS_KEY)?,
5291                listed(TaskRef::DELIVERED_BY_KEY)?,
5292            )
5293        } else {
5294            (Vec::new(), Vec::new())
5295        };
5296        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5297        let priority = self.held_priority(nodes)?;
5298        // Present when the item was reached through its own issue, whose board entry
5299        // names the board; a read of the board's own items has the board already. An
5300        // empty id names nothing a field write could address, so it is read as absent and
5301        // the write goes back to reading the board.
5302        let board_id = item
5303            .pointer("/project/id")
5304            .and_then(Value::as_str)
5305            .filter(|id| !id.is_empty());
5306        let resolved = Resolved {
5307            item_id: required_str(item, "id")?.to_owned(),
5308            id,
5309            content_kind,
5310            kind,
5311            title,
5312            body: body.filter(|value| !value.is_empty()),
5313            raw_body,
5314            status: self
5315                .statuses
5316                .status(kind.status_kind(), option, closed, reason),
5317            option: option.map(str::to_owned),
5318            priority,
5319            closed,
5320            delivers,
5321            delivered_by,
5322            labels: labels(content)?,
5323            parent,
5324            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5325            number: match content_kind {
5326                ContentKind::Issue => Some(issue_number(content)?),
5327                // A draft is filed in no repository, so nothing ever numbered it:
5328                // `DraftIssue` declares no `number` at all, exactly as it declares no
5329                // `subIssuesSummary` the branch above reads.
5330                ContentKind::DraftIssue => None,
5331            },
5332            url: optional_str(content, "url")?.map(str::to_owned),
5333            created_at: optional_time(content, "createdAt")?,
5334            updated_at: optional_time(content, "updatedAt")?,
5335            own_repository,
5336            repositories,
5337            slot,
5338            board_id: board_id.map(str::to_owned),
5339            fields: field_definitions(nodes),
5340            board_fields: Self::carried_board_fields(content, board_id)?,
5341            blocked_by: carried_blocked_by(content)?,
5342        };
5343        self.resolved_cache()?
5344            .insert(resolved.id.clone(), resolved.clone());
5345        Ok(Some(resolved))
5346    }
5347
5348    /// The field definitions of the board `board_id` names — the project this issue's own
5349    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5350    /// `None` when the read carried none, carried no entry for that board, or the board item
5351    /// named no board, which a write then answers by reading the board's fields itself.
5352    ///
5353    /// Matched by the board's node id and never by its number alone: a project number is
5354    /// unique only within its owner, so another owner's board numbered alike can sit on the
5355    /// same page, and its field and option ids address nothing on this one.
5356    fn carried_board_fields(
5357        content: &Value,
5358        board_id: Option<&str>,
5359    ) -> Result<Option<Value>, SourceError> {
5360        let (Some(nodes), Some(board_id)) = (
5361            content.pointer("/boards/nodes").and_then(Value::as_array),
5362            board_id,
5363        ) else {
5364            return Ok(None);
5365        };
5366        let Some(board) = nodes.iter().find_map(|node| {
5367            let project = node.get("project")?;
5368            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5369        }) else {
5370            return Ok(None);
5371        };
5372        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5373            return Ok(None);
5374        };
5375        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5376        Ok(Some(fields.clone()))
5377    }
5378
5379    /// What one board item's `Priority` field says, through this instance's mapping.
5380    ///
5381    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5382    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5383    /// option the mapping does not name is kept as itself — never read as a level or as
5384    /// `none` — for a read of the task to report by name.
5385    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5386        let Some(mapping) = &self.priorities else {
5387            return Ok(HeldPriority::Read(Priority::None));
5388        };
5389        // A value of the field that names no option — a text field someone called `Priority` —
5390        // is malformed rather than `none`: reading it as no priority would let the next copy
5391        // clear one a person set.
5392        let Some(option) = field_values
5393            .iter()
5394            .find(|value| {
5395                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5396            })
5397            .map(|value| required_str(value, "name"))
5398            .transpose()?
5399        else {
5400            return Ok(HeldPriority::Read(Priority::None));
5401        };
5402        Ok(mapping.priority_of(option).map_or_else(
5403            || HeldPriority::Unmapped(option.to_owned()),
5404            HeldPriority::Read,
5405        ))
5406    }
5407
5408    /// What one board item's status is read from: its `Status` option, whether its issue
5409    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5410    /// three into the status it reports.
5411    fn status_parts<'a>(
5412        field_values: &'a [Value],
5413        content: &'a Value,
5414    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5415        let option = field_values
5416            .iter()
5417            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5418            .map(|value| required_str(value, "name"))
5419            .transpose()?;
5420        let closed = optional_str(content, "state")? == Some("CLOSED");
5421        Ok((option, closed, optional_str(content, "stateReason")?))
5422    }
5423
5424    /// The board Status option this write selects, or the refusal that says why not.
5425    ///
5426    /// The mapped option is required for both open and terminal targets. A terminal write
5427    /// validates it before changing either representation, so it can never fall back to
5428    /// closing an issue whose board cannot display the matching status.
5429    ///
5430    /// Answers the field's id, the option's id, and the option's name as the board spells
5431    /// it — which is the name a read of the item reports once it sits there.
5432    fn column_for(
5433        &self,
5434        fields: &Value,
5435        kind: ItemKind,
5436        category: StatusCategory,
5437        target: &StatusTarget,
5438    ) -> Result<Option<(String, String, String)>, SourceError> {
5439        let Some(wanted) = target.option() else {
5440            return Ok(None);
5441        };
5442        let missing = |detail: &str| SourceError::Refused {
5443            message: format!(
5444                "{} status {} of source {} needs the board Status option {wanted:?}, and \
5445                 {detail}; next: add that option to the board, which `onetaskgraph sources \
5446                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5447                 it has",
5448                kind.marker(),
5449                category_name(category),
5450                self.name,
5451                self.name,
5452                category_name(category),
5453                kind.marker()
5454            ),
5455        };
5456        let Some(field) = Board::field(fields, "Status")? else {
5457            return Err(missing("this board has no Status field"));
5458        };
5459        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5460            return Err(missing(
5461                "this board's Status field is not a single-select field",
5462            ));
5463        }
5464        let option = field
5465            .get("options")
5466            .and_then(Value::as_array)
5467            .and_then(|options| {
5468                options.iter().find(|option| {
5469                    option
5470                        .get("name")
5471                        .and_then(Value::as_str)
5472                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5473                })
5474            });
5475        match option {
5476            None => Err(missing("this board does not have it")),
5477            Some(option) => Ok(Some((
5478                required_str(field, "id")?.to_owned(),
5479                required_str(option, "id")?.to_owned(),
5480                required_str(option, "name")?.to_owned(),
5481            ))),
5482        }
5483    }
5484
5485    /// The refusal a status that closes an issue is answered with over a board draft.
5486    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5487        SourceError::Refused {
5488            message: format!(
5489                "status {} of source {} closes the item's issue, and GitHub draft items have \
5490                 no open or closed state",
5491                category_name(category),
5492                self.name
5493            ),
5494        }
5495    }
5496
5497    /// What a status write to one item needs of the board: the board's id and the
5498    /// definition of its `Status` field, read off the item when the item says both.
5499    ///
5500    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5501    /// and its `Status` value carries that field's definition, options and all. An item that
5502    /// does not say — no board id, or no `Status` value to read the field off — takes them
5503    /// from [`Self::board_fields`], which reads no item.
5504    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5505        if let Some(board) = item.carried_board() {
5506            return Ok(board);
5507        }
5508        if item.defines("Status")
5509            && let Some(board_id) = item.named_board()
5510        {
5511            return Ok(BoardFields {
5512                id: board_id,
5513                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5514            });
5515        }
5516        self.board_fields().await
5517    }
5518
5519    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5520    async fn set_status(
5521        &self,
5522        id: &NativeId,
5523        category: StatusCategory,
5524    ) -> Result<Option<Status>, SourceError> {
5525        // Refused before anything is read, in the words a write of the same status is.
5526        let target = self.resolved_target(ItemKind::Task, category)?;
5527        let Some(mut item) = self
5528            .bound_item(id)
5529            .await?
5530            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5531        else {
5532            return Ok(None);
5533        };
5534        let board = self.status_board(&item).await?;
5535        let (field, option, name) = self
5536            .column_for(&board.fields, ItemKind::Task, category, &target)?
5537            .ok_or_else(|| SourceError::Malformed {
5538                message: format!(
5539                    "status {} of source {} names no board Status option",
5540                    category_name(category),
5541                    self.name
5542                ),
5543            })?;
5544        if item.status.category == category && item.option.as_deref() == Some(&name) {
5545            return Ok(Some(item.status));
5546        }
5547        match &target {
5548            StatusTarget::Terminal(_, reason) => {
5549                if item.content_kind == ContentKind::DraftIssue {
5550                    return Err(self.closes_a_draft(category));
5551                }
5552                self.set_item_field(
5553                    board.id.as_str(),
5554                    &item.item_id,
5555                    &field,
5556                    json!({"singleSelectOptionId": option}),
5557                )
5558                .await?;
5559                self.update_content(
5560                    ContentKind::Issue,
5561                    &item.id,
5562                    json!({"stateInput": state_input(Some(&target))}),
5563                )
5564                .await?;
5565                item.closed = true;
5566                item.status =
5567                    self.statuses
5568                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5569                item.option = Some(name);
5570            }
5571            StatusTarget::Column(_) => {
5572                // An option is what an open item's status is, so a closed issue is reopened
5573                // first — sitting closed in the column, it would read back as closed. A draft has
5574                // no state to reopen.
5575                if item.content_kind == ContentKind::Issue && item.closed {
5576                    self.update_content(
5577                        ContentKind::Issue,
5578                        &item.id,
5579                        json!({"stateInput": state_input(Some(&target))}),
5580                    )
5581                    .await?;
5582                    item.closed = false;
5583                }
5584                self.set_item_field(
5585                    board.id.as_str(),
5586                    &item.item_id,
5587                    &field,
5588                    json!({"singleSelectOptionId": option}),
5589                )
5590                .await?;
5591                item.status = self
5592                    .statuses
5593                    .status(ItemKind::Task, Some(&name), false, None);
5594                item.option = Some(name);
5595            }
5596            StatusTarget::Disabled(_) => {
5597                unreachable!("resolved_target refused a disabled status")
5598            }
5599        }
5600        let status = item.status.clone();
5601        self.remember_written(item, false)?;
5602        Ok(Some(status))
5603    }
5604
5605    /// Replace one task's `delivered_by` and nothing else; see
5606    /// [`TaskSource::set_delivered_by`].
5607    ///
5608    /// One update of the body, which differs from the body GitHub holds only inside the
5609    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5610    async fn replace_delivered_by(
5611        &self,
5612        id: &NativeId,
5613        delivered_by: &[TaskRef],
5614    ) -> Result<Option<()>, SourceError> {
5615        let entries = TaskRef::listed(
5616            TaskRef::DELIVERED_BY_KEY,
5617            id,
5618            Some(&self.name),
5619            delivered_by.to_vec(),
5620        )
5621        .map_err(|message| SourceError::Refused { message })?;
5622        let Some(mut item) = self
5623            .bound_item(id)
5624            .await?
5625            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5626        else {
5627            return Ok(None);
5628        };
5629        let mut slot = item.slot.clone();
5630        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5631        self.write_slot(&mut item, &slot).await?;
5632        item.delivered_by = entries;
5633        self.remember_written(item, false)?;
5634        Ok(Some(()))
5635    }
5636
5637    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5638    /// see [`TaskSource::set_task_metadata`].
5639    ///
5640    /// `None` when this board holds no item by that id, or holds one of another kind. The
5641    /// answer is the item as this source now reads it, so what a caller is told the key
5642    /// holds is what the slot holds.
5643    ///
5644    /// A key already holding the value is answered without a write, compared as JSON rather
5645    /// than as the body's bytes: a slot a person spelled with other whitespace would
5646    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5647    async fn set_slot_key(
5648        &self,
5649        id: &NativeId,
5650        kind: BoardKind,
5651        key: &MetadataKey,
5652        value: &Value,
5653    ) -> Result<Option<Resolved>, SourceError> {
5654        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5655            return Ok(None);
5656        };
5657        if item.slot.get(key.as_str()) == Some(value) {
5658            return Ok(Some(item));
5659        }
5660        let mut slot = item.slot.clone();
5661        slot.insert(key.as_str().to_owned(), value.clone());
5662        self.write_slot(&mut item, &slot).await?;
5663        self.remember_written(item.clone(), false)?;
5664        Ok(Some(item))
5665    }
5666
5667    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5668    /// `item` up to what that write left.
5669    ///
5670    /// The body sent differs from the body GitHub holds only inside the slot — see
5671    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5672    /// the mutation the item's content takes, so a board draft's body is written with
5673    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5674    async fn write_slot(
5675        &self,
5676        item: &mut Resolved,
5677        slot: &BTreeMap<String, Value>,
5678    ) -> Result<(), SourceError> {
5679        let held = item.raw_body.clone().unwrap_or_default();
5680        let body = with_slot(&held, slot)?;
5681        if body != held {
5682            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5683                .await?;
5684        }
5685        let (visible, slot) = metadata_body(Some(body.clone()))?;
5686        item.body = visible.filter(|value| !value.is_empty());
5687        item.raw_body = Some(body);
5688        item.slot = slot;
5689        Ok(())
5690    }
5691
5692    /// This instance's target for a category written to an item of `kind`, refusing one
5693    /// that kind has no option for — before anything is read or written.
5694    ///
5695    /// Nothing here mutates the board's option set to make room for a status. GitHub
5696    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5697    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5698    /// field and every item's status.
5699    fn resolved_target(
5700        &self,
5701        kind: ItemKind,
5702        category: StatusCategory,
5703    ) -> Result<StatusTarget, SourceError> {
5704        let target = self.statuses.target(kind, category).clone();
5705        let StatusTarget::Disabled(why) = target else {
5706            return Ok(target);
5707        };
5708        let refusal = why.refusal(&self.name, category, kind);
5709        // Why there is no shipped default, which is the question a person meeting this
5710        // refusal on a source that never mentioned the category asks.
5711        let shipped_none = match category {
5712            StatusCategory::Draft => Some(
5713                "draft has no shipped default because GitHub draft issues cannot have \
5714                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5715            ),
5716            StatusCategory::Unknown => Some(
5717                "unknown has no shipped default because this board keeps no open-ended status \
5718                 word: every word classified unknown is written to the one board Status option \
5719                 status_mapping.unknown names",
5720            ),
5721            _ => None,
5722        };
5723        Err(match (refusal, shipped_none, why) {
5724            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5725                SourceError::Refused {
5726                    message: format!("{message}; {note}"),
5727                }
5728            }
5729            (refusal, _, _) => refusal,
5730        })
5731    }
5732
5733    /// What writing `priority` does to one item's `Priority` field on this board, or the
5734    /// refusal naming what the board lacks.
5735    ///
5736    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5737    /// none already, or of an item not created yet. Every other priority selects the option
5738    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5739    /// without that option, is refused rather than given one: reads and writes never create
5740    /// a field or an option.
5741    fn priority_write(
5742        &self,
5743        fields: &Value,
5744        existing: Option<&Resolved>,
5745        priority: Priority,
5746    ) -> Result<Option<PriorityWrite>, SourceError> {
5747        let Some(mapping) = &self.priorities else {
5748            return Err(self.holds_no_priority());
5749        };
5750        let Some(wanted) = mapping.option(priority) else {
5751            if !existing.is_some_and(Resolved::holds_priority) {
5752                return Ok(None);
5753            }
5754            let field =
5755                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5756                    message: format!(
5757                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5758                    ),
5759                })?;
5760            return Ok(Some(PriorityWrite::Clear {
5761                field: required_str(field, "id")?.to_owned(),
5762            }));
5763        };
5764        let missing = |detail: &str| SourceError::Refused {
5765            message: format!(
5766                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5767                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5768                 it, or point priority_mapping.{priority} of this source at an option the board \
5769                 has",
5770                self.name, self.name
5771            ),
5772        };
5773        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5774            return Err(missing(&format!(
5775                "this board has no {PRIORITY_FIELD} field"
5776            )));
5777        };
5778        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5779            return Err(missing(&format!(
5780                "this board's {PRIORITY_FIELD} field is not a single-select field"
5781            )));
5782        }
5783        // An options list that is absent or not a list is an answer this source cannot read,
5784        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5785        let option = field
5786            .get("options")
5787            .and_then(Value::as_array)
5788            .ok_or_else(|| SourceError::Malformed {
5789                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5790            })?
5791            .iter()
5792            .find(|option| {
5793                option
5794                    .get("name")
5795                    .and_then(Value::as_str)
5796                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5797            })
5798            .ok_or_else(|| missing("this board does not have it"))?;
5799        Ok(Some(PriorityWrite::Select {
5800            field: required_str(field, "id")?.to_owned(),
5801            option: required_str(option, "id")?.to_owned(),
5802        }))
5803    }
5804
5805    /// Apply one priority write to one board item.
5806    async fn write_priority(
5807        &self,
5808        board_id: &str,
5809        item_id: &str,
5810        write: &PriorityWrite,
5811    ) -> Result<(), SourceError> {
5812        match write {
5813            PriorityWrite::Select { field, option } => {
5814                self.set_item_field(
5815                    board_id,
5816                    item_id,
5817                    field,
5818                    json!({"singleSelectOptionId": option}),
5819                )
5820                .await
5821            }
5822            PriorityWrite::Clear { field } => {
5823                let data = self
5824                    .graphql(
5825                        graphql::CLEAR_FIELD,
5826                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5827                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5828                    )
5829                    .await?;
5830                let returned = data
5831                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5832                    .ok_or_else(|| SourceError::Malformed {
5833                        message: "GitHub field clear returned no project item".into(),
5834                    })?;
5835                if required_str(returned, "id")? != item_id {
5836                    return Err(SourceError::Malformed {
5837                        message: "GitHub field clear returned the wrong project item".into(),
5838                    });
5839                }
5840                Ok(())
5841            }
5842        }
5843    }
5844
5845    /// The refusal a priority is answered with by an instance configured with no
5846    /// `priority_mapping`, which holds none.
5847    fn holds_no_priority(&self) -> SourceError {
5848        SourceError::Refused {
5849            message: format!(
5850                "source {} holds no task priority: its configuration sets no priority_mapping; \
5851                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5852                 fields {} --apply` to set its board up",
5853                self.name, self.name
5854            ),
5855        }
5856    }
5857
5858    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5859    ///
5860    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5861    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5862    async fn set_priority(
5863        &self,
5864        id: &NativeId,
5865        priority: Priority,
5866    ) -> Result<Option<Priority>, SourceError> {
5867        if self.priorities.is_none() {
5868            return Err(self.holds_no_priority());
5869        }
5870        let Some(mut item) = self
5871            .bound_item(id)
5872            .await?
5873            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5874        else {
5875            return Ok(None);
5876        };
5877        if priority == Priority::None && !item.holds_priority() {
5878            return Ok(Some(priority));
5879        }
5880        // The item's own read carries the field's definition whenever it holds a value of
5881        // it, which a clear always does; a select onto an item holding none reads the board.
5882        let board = match (item.carried_board(), item.named_board()) {
5883            (Some(board), _) => board,
5884            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5885                id,
5886                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5887            },
5888            _ => self.board_fields().await?,
5889        };
5890        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5891            return Ok(Some(priority));
5892        };
5893        let (document, root, input) = match write {
5894            PriorityWrite::Select { field, option } => (
5895                graphql::UPDATE_FIELD,
5896                "updateProjectV2ItemFieldValue",
5897                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5898            ),
5899            PriorityWrite::Clear { field } => (
5900                graphql::CLEAR_FIELD,
5901                "clearProjectV2ItemFieldValue",
5902                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5903            ),
5904        };
5905        let data = self
5906            .graphql(
5907                document,
5908                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5909            )
5910            .await?;
5911        let returned = data
5912            .get(root)
5913            .and_then(|value| value.get("projectV2Item"))
5914            .ok_or_else(|| SourceError::Malformed {
5915                message: "GitHub priority write returned no project item".into(),
5916            })?;
5917        if required_str(returned, "id")? != item.item_id {
5918            return Err(SourceError::Malformed {
5919                message: "GitHub priority write returned the wrong project item".into(),
5920            });
5921        }
5922        let value = returned
5923            .get("fieldValueByName")
5924            .ok_or_else(|| SourceError::Malformed {
5925                message: "GitHub priority write returned no priority read-back".into(),
5926            })?;
5927        if !value.is_null()
5928            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5929        {
5930            return Err(SourceError::Malformed {
5931                message: "GitHub priority read-back is not a Priority field value".into(),
5932            });
5933        }
5934        let values = if value.is_null() {
5935            Vec::new()
5936        } else {
5937            vec![value.clone()]
5938        };
5939        item.priority = self.held_priority(&values)?;
5940        let answer = item.task()?.priority;
5941        self.remember_written(item, false)?;
5942        Ok(Some(answer))
5943    }
5944
5945    /// Replace one task's visible body and nothing else; see
5946    /// [`TaskSource::set_task_content`].
5947    ///
5948    /// One update of the body, which differs from the body GitHub holds only outside the
5949    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5950    /// this source keeps there reads back as it was. A body that would not change is not
5951    /// sent at all.
5952    async fn replace_content(
5953        &self,
5954        id: &NativeId,
5955        content: &str,
5956    ) -> Result<Option<()>, SourceError> {
5957        let Some(mut item) = self
5958            .bound_item(id)
5959            .await?
5960            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5961        else {
5962            return Ok(None);
5963        };
5964        let held = item.raw_body.clone().unwrap_or_default();
5965        let body = with_content(&held, content)?;
5966        // Checked before anything is sent: content ending in what this source reads as its own
5967        // metadata slot would read back as metadata rather than as the content it was.
5968        let (visible, slot) = metadata_body(Some(body.clone()))?;
5969        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5970            return Err(SourceError::Refused {
5971                message: format!(
5972                    "this content ends in what source {} reads as its own metadata slot \
5973                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5974                     as content; next: remove that trailing block from the content",
5975                    self.name
5976                ),
5977            });
5978        }
5979        if body != held {
5980            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5981                .await?;
5982        }
5983        item.body = visible.filter(|value| !value.is_empty());
5984        item.raw_body = Some(body);
5985        item.slot = slot;
5986        self.remember_written(item, false)?;
5987        Ok(Some(()))
5988    }
5989
5990    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5991    ///
5992    /// One read of the item — which carries the board's field definitions and the issue's
5993    /// `blockedBy`, so neither is read again — and then only what differs from it: the
5994    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5995    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5996    /// the title, the body — visible content and metadata slot together — and a state change.
5997    /// So an update naming any of title, body, metadata, status and priority is one read and
5998    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5999    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6000    /// as a whole write does; an open one selects its option and then reopens. The origin
6001    /// field is never written: an update is of an item that already exists, whose origin is
6002    /// what it is.
6003    ///
6004    /// The task answered is the item as those writes left it, built from the read and what was
6005    /// sent rather than read again — the same record a later read in this run answers from.
6006    async fn targeted_update(
6007        &self,
6008        id: &NativeId,
6009        update: &TaskUpdate,
6010    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6011        // Everything this source can refuse without reading the item is refused first, in the
6012        // words a whole write of the same fields is refused with.
6013        update.consistent()?;
6014        if update
6015            .title
6016            .as_deref()
6017            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6018        {
6019            return Err(SourceError::Refused {
6020                message: format!(
6021                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6022                     spells a document, so it would read back as one rather than as a task; \
6023                     retitle it",
6024                    self.name
6025                ),
6026            });
6027        }
6028        if let Some(delivers) = &update.delivers {
6029            TaskRef::listed(
6030                TaskRef::DELIVERS_KEY,
6031                id,
6032                Some(&self.name),
6033                delivers.clone(),
6034            )
6035            .map_err(|message| SourceError::Refused { message })?;
6036        }
6037        if self.priorities.is_none()
6038            && update
6039                .priority
6040                .is_some_and(|priority| priority != Priority::None)
6041        {
6042            return Err(self.holds_no_priority());
6043        }
6044        let target = update
6045            .status
6046            .as_ref()
6047            .map(|status| self.resolved_target(ItemKind::Task, status.category))
6048            .transpose()?;
6049        let Some(mut item) = self
6050            .bound_item(id)
6051            .await?
6052            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6053        else {
6054            return Ok(None);
6055        };
6056        let before = item.task()?;
6057
6058        let mut status_move = None;
6059        if let (Some(status), Some(target)) = (&update.status, target) {
6060            let board = self.status_board(&item).await?;
6061            let (field, option, name) = self
6062                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6063                .ok_or_else(|| SourceError::Malformed {
6064                    message: format!(
6065                        "status {} of source {} names no board Status option",
6066                        category_name(status.category),
6067                        self.name
6068                    ),
6069                })?;
6070            let terminal = matches!(target, StatusTarget::Terminal(_, _));
6071            if terminal && item.content_kind == ContentKind::DraftIssue {
6072                return Err(self.closes_a_draft(status.category));
6073            }
6074            let landed = match &target {
6075                StatusTarget::Terminal(_, reason) => {
6076                    self.statuses
6077                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6078                }
6079                _ => self
6080                    .statuses
6081                    .status(ItemKind::Task, Some(&name), false, None),
6082            };
6083            let option_moves = item
6084                .option
6085                .as_deref()
6086                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6087            let state_moves = item.content_kind == ContentKind::Issue
6088                && (item.closed != terminal || (terminal && item.status != landed));
6089            if let Some(moves) = Moves::of(option_moves, state_moves) {
6090                status_move = Some(StatusMove {
6091                    board: board.id,
6092                    field,
6093                    option,
6094                    name,
6095                    target,
6096                    landed,
6097                    moves,
6098                });
6099            }
6100        }
6101
6102        let mut priority_move = None;
6103        if let Some(priority) = update.priority
6104            && self.priorities.is_some()
6105            && item.priority != HeldPriority::Read(priority)
6106        {
6107            let board = match (item.carried_board(), item.named_board()) {
6108                (Some(board), _) => board,
6109                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6110                    id: board,
6111                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6112                },
6113                _ => self.board_fields().await?,
6114            };
6115            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6116                priority_move = Some((board.id, write, priority));
6117            }
6118        }
6119
6120        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6121        // recorded in the slot, and the slot travels in the one body update below.
6122        let edges = match &update.depends_on {
6123            Some(edges) => Some(
6124                self.partition_edges(
6125                    BoardKind::Work(ItemKind::Task),
6126                    item.content_kind,
6127                    item.blocked_by.as_deref(),
6128                    edges,
6129                )
6130                .await?,
6131            ),
6132            None => None,
6133        };
6134
6135        let mut slot = item.slot.clone();
6136        for (key, value) in &update.metadata_set {
6137            slot.insert(key.as_str().to_owned(), value.clone());
6138        }
6139        for key in &update.metadata_remove {
6140            slot.remove(key.as_str());
6141        }
6142        if let Some(delivers) = &update.delivers {
6143            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6144        }
6145        if let Some((_, recorded)) = &edges {
6146            record_edges(&mut slot, recorded);
6147        }
6148        let held = item.raw_body.clone().unwrap_or_default();
6149        let content = match &update.content {
6150            Some(content) => with_content(&held, content)?,
6151            None => held.clone(),
6152        };
6153        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6154        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6155        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6156        let body = if slot == item.slot {
6157            content
6158        } else {
6159            with_slot(&content, &slot)?
6160        };
6161        // Checked before anything is sent, as a content write checks it: content ending in
6162        // what this source reads as its own slot would read back as metadata.
6163        let (visible, read) = metadata_body(Some(body.clone()))?;
6164        let wanted = update.content.as_deref().or(item.body.as_deref());
6165        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6166            return Err(SourceError::Refused {
6167                message: format!(
6168                    "this content ends in what source {} reads as its own metadata slot \
6169                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6170                     as content; next: remove that trailing block from the content",
6171                    self.name
6172                ),
6173            });
6174        }
6175        let recorded_moves =
6176            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6177
6178        // One `updateIssue` carries all three, because every mutation spends the secondary
6179        // limiter and the title, body and state are one mutation's inputs.
6180        let mut fields = serde_json::Map::new();
6181        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6182            fields.insert("title".to_owned(), json!(title));
6183        }
6184        if body != held {
6185            fields.insert("body".to_owned(), json!(body));
6186        }
6187        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6188            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6189        }
6190        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6191        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6192        // without undoing an earlier field when a later one fails — so a body written before a
6193        // board field the board then refused would be left changed. Written after every other
6194        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6195        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6196        // first, together in one request — a terminal option selected before the issue
6197        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6198        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6199        let mut clear: Option<(&BoardId, &str)> = None;
6200        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6201            board_writes.push((
6202                &moving.board,
6203                (
6204                    moving.field.clone(),
6205                    json!({"singleSelectOptionId": moving.option}),
6206                ),
6207            ));
6208        }
6209        match &priority_move {
6210            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6211                board,
6212                (field.clone(), json!({"singleSelectOptionId": option})),
6213            )),
6214            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6215            None => {}
6216        }
6217        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6218        boards.extend(clear.map(|(board, _)| board));
6219        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6220        for board in boards {
6221            let writes = board_writes
6222                .iter()
6223                .filter(|(on, _)| on.as_str() == board.as_str())
6224                .map(|(_, write)| write.clone())
6225                .collect::<Vec<_>>();
6226            let cleared = clear
6227                .filter(|(on, _)| on.as_str() == board.as_str())
6228                .map(|(_, field)| field);
6229            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6230                .await?;
6231        }
6232        let mut blocked_by_moved = false;
6233        if let Some((native, _)) = &edges
6234            && item.content_kind == ContentKind::Issue
6235        {
6236            blocked_by_moved = self
6237                .reconcile_blocked_by(
6238                    &item.id,
6239                    native,
6240                    Issue::Existing(item.blocked_by.as_deref()),
6241                )
6242                .await?;
6243        }
6244        if !fields.is_empty() {
6245            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6246                .await?;
6247        }
6248
6249        if let Some(title) = &update.title {
6250            item.title.clone_from(title);
6251        }
6252        item.body = visible.filter(|value| !value.is_empty());
6253        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6254        item.slot = slot;
6255        if let Some(delivers) = &update.delivers {
6256            item.delivers.clone_from(delivers);
6257        }
6258        if let Some(moving) = status_move {
6259            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6260                && item.content_kind == ContentKind::Issue;
6261            item.status = moving.landed;
6262            item.option = Some(moving.name);
6263        }
6264        if let Some((_, _, priority)) = priority_move {
6265            item.priority = HeldPriority::Read(priority);
6266        }
6267        let task = item.task()?;
6268        let mut written = update.changed(&before, &task);
6269        if blocked_by_moved || recorded_moves {
6270            written.insert(UpdatedField::DependsOn);
6271        }
6272        self.remember_written(item, false)?;
6273        Ok(Some(TaskUpdateOutcome {
6274            task,
6275            written,
6276            delivers_before: before.delivers,
6277        }))
6278    }
6279
6280    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6281    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6282    ///
6283    /// One update of the body: the content outside the slot, and inside it that one entry,
6284    /// every other entry kept as it was. This source keeps no template answers — an issue has
6285    /// no room beside itself that is not its body, and answers written there would duplicate
6286    /// what the content already says and count against GitHub's body limit — so `answers`
6287    /// reaches nothing here. A body that would not change is not sent at all.
6288    async fn replace_rendering(
6289        &self,
6290        id: &NativeId,
6291        kind: BoardKind,
6292        content: &str,
6293        provenance: &Value,
6294    ) -> Result<Option<()>, SourceError> {
6295        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6296            return Ok(None);
6297        };
6298        let held = item.raw_body.clone().unwrap_or_default();
6299        let mut slot = item.slot.clone();
6300        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6301        let body = with_slot(&with_content(&held, content)?, &slot)?;
6302        // Checked before anything is sent, as a content write checks it.
6303        let (visible, read) = metadata_body(Some(body.clone()))?;
6304        if visible.as_deref().unwrap_or_default() != content || read != slot {
6305            return Err(SourceError::Refused {
6306                message: format!(
6307                    "this content ends in what source {} reads as its own metadata slot \
6308                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6309                     as content; next: remove that trailing block from the template",
6310                    self.name
6311                ),
6312            });
6313        }
6314        if body != held {
6315            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6316                .await?;
6317        }
6318        item.body = visible.filter(|value| !value.is_empty());
6319        item.raw_body = Some(body);
6320        item.slot = read;
6321        self.remember_written(item, false)?;
6322        Ok(Some(()))
6323    }
6324
6325    async fn set_item_field(
6326        &self,
6327        board_id: &str,
6328        item_id: &str,
6329        field_id: &str,
6330        value: Value,
6331    ) -> Result<(), SourceError> {
6332        let data = self
6333            .graphql(
6334                graphql::UPDATE_FIELD,
6335                json!({"input":{
6336                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6337                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6338            )
6339            .await?;
6340        let returned = data
6341            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6342            .ok_or_else(|| SourceError::Malformed {
6343                message: "GitHub field update returned no project item".into(),
6344            })?;
6345        if required_str(returned, "id")? != item_id {
6346            return Err(SourceError::Malformed {
6347                message: "GitHub field update returned the wrong project item".into(),
6348            });
6349        }
6350        Ok(())
6351    }
6352
6353    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6354    /// one request. Every returned item id is checked, including optional aliases.
6355    async fn set_item_fields(
6356        &self,
6357        board: &str,
6358        item: &str,
6359        fields: &[(String, Value)],
6360        clear: Option<&str>,
6361    ) -> Result<(), SourceError> {
6362        if fields.len() <= 1 && clear.is_none() {
6363            if let Some((field, value)) = fields.first() {
6364                self.set_item_field(board, item, field, value.clone())
6365                    .await?;
6366            }
6367            return Ok(());
6368        }
6369        if fields.is_empty() {
6370            if let Some(field) = clear {
6371                self.write_priority(
6372                    board,
6373                    item,
6374                    &PriorityWrite::Clear {
6375                        field: field.to_owned(),
6376                    },
6377                )
6378                .await?;
6379            }
6380            return Ok(());
6381        }
6382        let input = |index: usize| {
6383            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6384            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6385        };
6386        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6387            "input":input(0),"second":input(1),"third":input(2),
6388            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6389            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6390        })).await?;
6391        for alias in [
6392            Some("updateProjectV2ItemFieldValue"),
6393            (fields.len() > 1).then_some("second"),
6394            (fields.len() > 2).then_some("third"),
6395            clear.map(|_| "cleared"),
6396        ]
6397        .into_iter()
6398        .flatten()
6399        {
6400            let returned = data
6401                .get(alias)
6402                .and_then(|value| value.get("projectV2Item"))
6403                .ok_or_else(|| SourceError::Malformed {
6404                    message: format!("GitHub field update {alias} returned no project item"),
6405                })?;
6406            if required_str(returned, "id")? != item {
6407                return Err(SourceError::Malformed {
6408                    message: format!("GitHub field update {alias} returned the wrong project item"),
6409                });
6410            }
6411        }
6412        Ok(())
6413    }
6414
6415    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6416        let mut after: Option<String> = None;
6417        let mut ids = Vec::new();
6418        loop {
6419            let data = self
6420                .graphql(
6421                    graphql::ISSUE_DEPENDENCIES,
6422                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6423                )
6424                .await?;
6425            let connection =
6426                data.pointer("/node/blockedBy")
6427                    .ok_or_else(|| SourceError::Malformed {
6428                        message: "GitHub dependency response has no blockedBy connection".into(),
6429                    })?;
6430            ids.extend(
6431                connection
6432                    .get("nodes")
6433                    .and_then(Value::as_array)
6434                    .ok_or_else(|| SourceError::Malformed {
6435                        message: "GitHub dependency response nodes is not an array".into(),
6436                    })?
6437                    .iter()
6438                    .map(|value| required_str(value, "id").map(str::to_owned))
6439                    .collect::<Result<Vec<_>, _>>()?,
6440            );
6441            let next = next_cursor(connection)?;
6442            if let Some(next) = &next {
6443                validate_cursor_progress(after.as_deref(), &next.0)?;
6444            }
6445            after = next.map(|cursor| cursor.0);
6446            if after.is_none() {
6447                return Ok(ids);
6448            }
6449        }
6450    }
6451
6452    async fn dependencies(
6453        &self,
6454        id: &NativeId,
6455        near_kind: ItemKind,
6456        direction: Direction,
6457        page: &PageRequest,
6458    ) -> Result<Page<DependencyEdge>, SourceError> {
6459        validate_page(page)?;
6460        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6461        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6462        let recorded = recorded_offset(cursor, direction)?;
6463        // What this issue is blocked by, when a read of it by its own id in this command
6464        // already carried the whole connection — a copy reads the item it writes before it
6465        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6466        // after it. Answered from that read, in the shape the dependency read answers in;
6467        // anything else is asked of GitHub.
6468        let carried = match direction {
6469            Direction::DependsOn => self
6470                .resolved_cache()?
6471                .get(id)
6472                .filter(|item| item.content_kind == ContentKind::Issue)
6473                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6474            Direction::DependedOnBy => None,
6475        }
6476        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6477        // Asked for even in the recorded phase, whose page reads nothing from the
6478        // connection: `__typename` is what says whether this item has a native
6479        // relationship at all, and that is what decides which far ends the reserved key is
6480        // allowed to hold.
6481        let data = match carried {
6482            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6483                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6484            None => {
6485                self.graphql(
6486                    graphql::ISSUE_DEPENDENCIES,
6487                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6488                           "after":if recorded.is_some() {None} else {cursor}}),
6489                )
6490                .await?
6491            }
6492        };
6493        let node =
6494            data.get("node")
6495                .filter(|v| !v.is_null())
6496                .ok_or_else(|| SourceError::Refused {
6497                    message: format!(
6498                        "GitHub item {} was not found or does not support dependencies",
6499                        id.0
6500                    ),
6501                })?;
6502        let connection_name = match direction {
6503            Direction::DependsOn => "blockedBy",
6504            Direction::DependedOnBy => "blocking",
6505        };
6506        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6507        // named natively and the reserved key may hold any far end. An issue's connections
6508        // hold issues, and this source reads them at the near item's own level.
6509        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6510        if let Some(offset) = recorded {
6511            return Ok(recorded_page(
6512                self.recorded_edges(id, near_kind, direction, natively_names, node)
6513                    .await?,
6514                offset,
6515                limit,
6516            ));
6517        }
6518        if natively_names.is_none() {
6519            return Ok(recorded_page(
6520                self.recorded_edges(id, near_kind, direction, natively_names, node)
6521                    .await?,
6522                0,
6523                limit,
6524            ));
6525        }
6526        let connection = node
6527            .get(connection_name)
6528            .ok_or_else(|| SourceError::Malformed {
6529                message: "GitHub dependency response is missing its connection".into(),
6530            })?;
6531        let nodes = connection
6532            .get("nodes")
6533            .and_then(Value::as_array)
6534            .ok_or_else(|| SourceError::Malformed {
6535                message: "GitHub dependency response nodes is not an array".into(),
6536            })?;
6537        // `from` depends on `to`, always. GitHub spells the same relationship from either
6538        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6539        // it — so the near item is `from` in one direction and `to` in the other.
6540        let items = nodes
6541            .iter()
6542            .map(|value| {
6543                let related = NativeId(required_str(value, "id")?.into());
6544                let related_kind = related_kind(value)?;
6545                let (from, to) = match direction {
6546                    Direction::DependsOn => (
6547                        DependencyEndpoint::from_native(id.clone(), near_kind),
6548                        DependencyEndpoint::from_native(related, related_kind),
6549                    ),
6550                    Direction::DependedOnBy => (
6551                        DependencyEndpoint::from_native(related, related_kind),
6552                        DependencyEndpoint::from_native(id.clone(), near_kind),
6553                    ),
6554                };
6555                Ok(DependencyEdge {
6556                    from,
6557                    to,
6558                    kind: DependencyKind::Blocks,
6559                })
6560            })
6561            .collect::<Result<Vec<_>, SourceError>>()?;
6562        let mut next = next_cursor(connection)?;
6563        if let Some(next) = &next {
6564            validate_cursor_progress(cursor, &next.0)?;
6565        }
6566        if next.is_none()
6567            && !self
6568                .recorded_edges(id, near_kind, direction, natively_names, node)
6569                .await?
6570                .is_empty()
6571        {
6572            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6573        }
6574        Ok(Page { items, next })
6575    }
6576
6577    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6578    /// a far end in another source has to live: no GitHub issue relationship can name one.
6579    ///
6580    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6581    /// source never writes one down.
6582    ///
6583    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6584    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6585    /// request beyond the read already made, and reading the board for them would be a
6586    /// walk of every item for one field of one. A draft has no body in that answer, because
6587    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6588    /// listing of the board, which can be behind on the very item asked about.
6589    async fn recorded_edges(
6590        &self,
6591        id: &NativeId,
6592        near_kind: ItemKind,
6593        direction: Direction,
6594        natively_names: Option<ItemKind>,
6595        node: &Value,
6596    ) -> Result<Vec<DependencyEdge>, SourceError> {
6597        if direction != Direction::DependsOn {
6598            return Ok(Vec::new());
6599        }
6600        let slot = match node.get("body") {
6601            Some(body) if natively_names.is_some() => {
6602                metadata_body(body.as_str().map(str::to_owned))?.1
6603            }
6604            _ => {
6605                let Some(item) = self.bound_item(id).await? else {
6606                    return Ok(Vec::new());
6607                };
6608                item.slot
6609            }
6610        };
6611        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6612            .map_err(|message| SourceError::Malformed { message })
6613    }
6614
6615    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6616        self.repository
6617            .as_ref()
6618            .ok_or_else(|| SourceError::Refused {
6619                message: format!(
6620                    "source {} has no repository configured, and a GitHub Projects board has no \
6621                 repository of its own to create an issue in; set repository: owner/name on \
6622                 this source",
6623                    self.name
6624                ),
6625            })
6626    }
6627
6628    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6629    /// states.
6630    ///
6631    /// The fallback is demanded first, whichever arm answers: a write without a configured
6632    /// repository is refused naming the field exactly as it was before the rule existed,
6633    /// so a source that could not write before cannot write now, rather than writing for
6634    /// the one item whose own field happens to decide it.
6635    ///
6636    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6637    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6638    /// entry owned by someone other than the owner of the parent issue's repository —
6639    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6640    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6641    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6642    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6643    /// and is visible to the token is checked where its node id is resolved, still before
6644    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6645    /// looked up in a listing of the board, which can be minutes behind an issue its own
6646    /// `projectItems` already places on it — and that read answers first from this process's
6647    /// own record, so a project created moments ago in this command answers though GitHub
6648    /// has not caught up.
6649    async fn creation_target(
6650        &self,
6651        incoming: &Incoming<'_>,
6652    ) -> Result<RepositoryTarget, SourceError> {
6653        let fallback = self.configured_repository()?;
6654        let what = |incoming: &Incoming<'_>| {
6655            format!(
6656                "{} {:?}",
6657                incoming.written.kind().describes(),
6658                incoming.title
6659            )
6660        };
6661        let parent = match incoming.parent {
6662            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6663                SourceError::Refused {
6664                    message: format!(
6665                        "GitHub project issue {} was not found on the board of source {}, so {} \
6666                         cannot be filed under it",
6667                        parent.0,
6668                        self.name,
6669                        what(incoming)
6670                    ),
6671                }
6672            })?),
6673            None => None,
6674        };
6675        let parents_repository = parent
6676            .as_ref()
6677            .map(|parent| {
6678                // A draft is on the board and so is found, but it has no repository to
6679                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6680                // would refuse the task only once `createIssue` had made it.
6681                if parent.content_kind == ContentKind::DraftIssue {
6682                    return Err(SourceError::Refused {
6683                        message: format!(
6684                            "GitHub project item {} on the board of source {} is a draft, \
6685                             which cannot have sub-issues, so {} cannot be filed under it",
6686                            parent.id.0,
6687                            self.name,
6688                            what(incoming)
6689                        ),
6690                    });
6691                }
6692                // An issue's repository is where a sub-issue is placed and whose owner it
6693                // is compared against, so a parent whose repository this source cannot
6694                // spell as `owner/name` — GitHub's login grammar is wider than this
6695                // source's floor — is one nothing can be filed under.
6696                parent
6697                    .own_repository
6698                    .as_ref()
6699                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6700                    .ok_or_else(|| SourceError::Malformed {
6701                        message: format!(
6702                            "GitHub project issue {} on the board of source {} is in {}, which \
6703                             is not a {}/owner/name repository this source can place {} in",
6704                            parent.id.0,
6705                            self.name,
6706                            parent
6707                                .own_repository
6708                                .as_ref()
6709                                .map_or("no repository", Repository::as_str),
6710                            RepositoryTarget::HOST,
6711                            what(incoming)
6712                        ),
6713                    })
6714            })
6715            .transpose()?;
6716        match incoming.repositories {
6717            [named] => {
6718                let target =
6719                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6720                        message: format!(
6721                            "{} names repository {}, which is not a {}/owner/name repository \
6722                             source {} can create an issue in; name one that is, or name none",
6723                            what(incoming),
6724                            named.as_str(),
6725                            RepositoryTarget::HOST,
6726                            self.name
6727                        ),
6728                    })?;
6729                if let Some(parents) = &parents_repository
6730                    && parents.owner != target.owner
6731                {
6732                    return Err(SourceError::Refused {
6733                        message: format!(
6734                            "{} names repository {}, owned by {}, but its project's issue is in \
6735                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6736                             of the same owner as its parent issue; name a repository of {}, or \
6737                             name none",
6738                            what(incoming),
6739                            target.slug(),
6740                            target.owner,
6741                            parents.slug(),
6742                            parents.owner,
6743                            parents.owner
6744                        ),
6745                    });
6746                }
6747                Ok(target)
6748            }
6749            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6750        }
6751    }
6752
6753    /// The node id of the repository `incoming` is being created in, or the refusal naming
6754    /// the item and the repository the token cannot see.
6755    ///
6756    /// Resolved once per command per repository; see [`Self::repository_cache`].
6757    async fn repository_id(
6758        &self,
6759        repository: &RepositoryTarget,
6760        incoming: &Incoming<'_>,
6761    ) -> Result<String, SourceError> {
6762        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6763            return Ok(id);
6764        }
6765        let data = self
6766            .graphql(
6767                graphql::REPOSITORY,
6768                json!({"owner":repository.owner,"name":repository.name}),
6769            )
6770            .await?;
6771        self.repository_read(&data, repository, incoming)
6772    }
6773
6774    /// The repository's node id out of an answer carrying the `repository` root, held for
6775    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6776    fn repository_read(
6777        &self,
6778        data: &Value,
6779        repository: &RepositoryTarget,
6780        incoming: &Incoming<'_>,
6781    ) -> Result<String, SourceError> {
6782        let node = data
6783            .get("repository")
6784            .filter(|value| !value.is_null())
6785            .ok_or_else(|| SourceError::Refused {
6786                message: format!(
6787                    "GitHub repository {} was not found or is not visible to the token, so {} \
6788                     {:?} cannot be created in it",
6789                    repository.slug(),
6790                    incoming.written.kind().describes(),
6791                    incoming.title
6792                ),
6793            })?;
6794        let id = required_str(node, "id")?.to_owned();
6795        self.repository_cache()?
6796            .insert(repository.clone(), id.clone());
6797        Ok(id)
6798    }
6799
6800    fn repository_cache(
6801        &self,
6802    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6803        self.repository_cache
6804            .lock()
6805            .map_err(|_| SourceError::Unavailable {
6806                message: "this source's record of the destination repository was left \
6807                          inconsistent by an earlier failure; next: run the command again"
6808                    .into(),
6809            })
6810    }
6811
6812    /// Create or update one board item, whichever kind it is.
6813    async fn write_item(
6814        &self,
6815        incoming: &Incoming<'_>,
6816        target: Option<&NativeId>,
6817        depends_on: &[DependencyEdge],
6818    ) -> Result<NativeId, SourceError> {
6819        // Refused before anything is read or written: a task or a project titled the way
6820        // this board spells a document would land as an issue this same source reads back
6821        // as a document, so the field this destination cannot carry is named rather than
6822        // written and silently reclassified.
6823        if let Written::Work(kind, _) = incoming.written
6824            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6825        {
6826            return Err(SourceError::Refused {
6827                message: format!(
6828                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6829                     spells a document, so it would read back as one rather than as a {}; \
6830                     retitle it, or copy it as a document",
6831                    kind.marker(),
6832                    self.name,
6833                    kind.marker()
6834                ),
6835            });
6836        }
6837        // The destination is read by its own id, and whether this board holds it is decided
6838        // by that read — its own `projectItems` — rather than by whether a listing of the
6839        // board happens to include it yet. See the module documentation.
6840        let existing = match target {
6841            Some(target) => {
6842                Some(
6843                    self.bound_item(target)
6844                        .await?
6845                        .ok_or_else(|| SourceError::Refused {
6846                            message: format!("GitHub destination item {} was not found", target.0),
6847                        })?,
6848                )
6849            }
6850            None => None,
6851        };
6852        let existing = existing.as_ref();
6853        // An existing issue is never moved; a new one is created where the rule says — and
6854        // knowing where is what lets the board's fields and that repository's id be read
6855        // together, before anything below needs either.
6856        let creation_target = match existing {
6857            Some(_) => None,
6858            None => {
6859                let target = self.creation_target(incoming).await?;
6860                self.creation_context(&target, incoming).await?;
6861                Some(target)
6862            }
6863        };
6864        let board = self
6865            .fields_for(
6866                existing,
6867                incoming.written.status().is_some(),
6868                incoming
6869                    .priority
6870                    .is_some_and(|priority| priority != Priority::None),
6871            )
6872            .await?;
6873        let status_target = incoming
6874            .written
6875            .work_status()
6876            .map(|(kind, status)| self.resolved_target(kind, status.category))
6877            .transpose()?;
6878        let column = match (incoming.written.work_status(), status_target.as_ref()) {
6879            (Some((kind, status)), Some(target)) => {
6880                self.column_for(&board.fields, kind, status.category, target)?
6881            }
6882            _ => None,
6883        };
6884        // Resolved before anything is created, for the reason the column above is: a
6885        // priority this board has no option for is refused while nothing has been written.
6886        let priority_write = match incoming.priority {
6887            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6888            None => None,
6889        };
6890        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6891        if content_kind == ContentKind::DraftIssue {
6892            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6893                (status_target.as_ref(), incoming.written.status())
6894            {
6895                return Err(self.closes_a_draft(status.category));
6896            }
6897            if incoming.parent.is_some() {
6898                return Err(SourceError::Refused {
6899                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6900                });
6901            }
6902        }
6903        match existing {
6904            Some(item) if content_kind == ContentKind::Issue => {
6905                if item.labels != incoming.labels {
6906                    return Err(SourceError::Refused {
6907                        message: "GitHub issue labels differ from the labels being written".into(),
6908                    });
6909                }
6910            }
6911            _ => {
6912                if !incoming.labels.is_empty() {
6913                    return Err(SourceError::Refused {
6914                        message: "GitHub items created by this destination carry no labels".into(),
6915                    });
6916                }
6917            }
6918        }
6919
6920        // The repository the issue really lives in is what the slot below is written against,
6921        // so a single entry that is where the issue is created travels as no key at all, and
6922        // the read side derives it back from the issue.
6923        let own_repository = match (existing, &creation_target) {
6924            (Some(item), _) => item.own_repository.clone(),
6925            (None, Some(target)) => Some(
6926                Repository::try_from(target.origin())
6927                    .map_err(|message| SourceError::Config { message })?,
6928            ),
6929            (None, None) => None,
6930        };
6931        let (native, fallback) = self
6932            .partition_edges(
6933                incoming.written.kind(),
6934                content_kind,
6935                existing.and_then(|item| item.blocked_by.as_deref()),
6936                depends_on,
6937            )
6938            .await?;
6939        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6940        let body = compose_body(incoming.content, &slot)?;
6941        // Read before anything is created, for the reason the field below is: a value
6942        // this destination cannot store has to refuse, and refusing after `createIssue`
6943        // would leave an issue behind that nothing asked for. The engine writes a
6944        // qualified id here; a caller handing this key anything else is told so rather
6945        // than having it silently stored as no origin at all.
6946        // 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.
6947        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6948            None => "",
6949            Some(Value::String(origin)) => origin.as_str(),
6950            Some(other) => {
6951                return Err(SourceError::Refused {
6952                    message: format!(
6953                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6954                         is {other}"
6955                    ),
6956                });
6957            }
6958        };
6959        // Resolved before anything is created: a board that cannot carry the copy origin
6960        // has to refuse the write, and refusing it after `createIssue` would leave an
6961        // issue behind that nothing asked for.
6962        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6963            Some(field) => {
6964                if required_str(field, "__typename")? != "ProjectV2Field" {
6965                    return Err(SourceError::Refused {
6966                        message: format!(
6967                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6968                        ),
6969                    });
6970                }
6971                Some(required_str(field, "id")?.to_owned())
6972            }
6973            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6974                return Err(SourceError::Refused {
6975                    message: format!(
6976                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6977                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6978                         the board"
6979                    ),
6980                });
6981            }
6982            None => None,
6983        };
6984
6985        let Landed {
6986            content_id,
6987            item_id,
6988            url,
6989            number,
6990        } = match existing {
6991            // Its content is written last, below, once everything else has landed.
6992            Some(item) => Landed {
6993                content_id: item.id.clone(),
6994                item_id: item.item_id.clone(),
6995                url: item.url.clone(),
6996                number: item.number,
6997            },
6998            None => {
6999                let target = creation_target
7000                    .as_ref()
7001                    .ok_or_else(|| SourceError::Malformed {
7002                        message: "a new item was decided without a repository to create it in"
7003                            .into(),
7004                    })?;
7005                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7006                    .await?
7007            }
7008        };
7009
7010        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7011        let column = column
7012            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7013            .map(|(field, option, _)| (field, option));
7014        // Creating an item here is several calls — `createIssue`, which files it on the
7015        // board, then its board fields, the parent and the dependencies — and GitHub can fail
7016        // at any of them. Everything this source can refuse *before* the first of those is
7017        // already checked above, so what is left is GitHub itself failing part way. When it
7018        // does over an item this call created, the issue is taken back: a write that
7019        // refused must not leave an item behind that nobody asked for, and one that does
7020        // makes the retry create a second.
7021        // Whether the board-field write carrying a moved origin was answered as landing whole.
7022        // When it was refused, GitHub does not say which of its fields ran before the one that
7023        // failed, so the origin may or may not have moved.
7024        let mut origin_landed = false;
7025        let landed = self
7026            .finish_write(
7027                board.id.as_str(),
7028                incoming,
7029                &content_id,
7030                &item_id,
7031                content_kind,
7032                existing,
7033                origin_field.as_deref(),
7034                origin,
7035                column,
7036                status_target.as_ref(),
7037                priority_write.as_ref(),
7038                &native,
7039                &mut origin_landed,
7040            )
7041            .await;
7042        // An existing item's title, body and state go last, in one `updateIssue`, once its board
7043        // fields and its relationships have landed: a refusal of any of those then leaves its
7044        // body — and the metadata slot inside it — exactly as it stood.
7045        let landed = match (landed, existing) {
7046            (Ok(()), Some(item)) => {
7047                self.update_existing(item, incoming, &body, status_target.as_ref())
7048                    .await
7049            }
7050            (landed, _) => landed,
7051        };
7052        if let Err(error) = landed {
7053            match existing {
7054                // Best effort, and the write's own failure is what the caller is told: a
7055                // refusal naming the tidy-up would hide why the write failed at all.
7056                None => {
7057                    let _ = self.delete_issue(&content_id).await;
7058                }
7059                // The origin field is the one piece of an existing item's metadata written
7060                // before its body, so a write refused after it puts it back as it was. When
7061                // that is refused too, the write's own failure is still what the caller is
7062                // told — with what it left behind added, because the item's metadata is then
7063                // not as it stood and a caller retrying has to know which key moved.
7064                Some(item) => {
7065                    let before = item.origin.as_deref().unwrap_or("");
7066                    if let Some(field) = origin_field.as_deref()
7067                        && before != origin
7068                        && let Err(restore) = self
7069                            .set_item_field(
7070                                board.id.as_str(),
7071                                &item.item_id,
7072                                field,
7073                                json!({"text": before}),
7074                            )
7075                            .await
7076                    {
7077                        let left = if origin_landed {
7078                            format!(
7079                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7080                                 not be put back to {before:?} ({restore}), so item {} still \
7081                                 holds {origin:?} there",
7082                                item.id.0
7083                            )
7084                        } else {
7085                            format!(
7086                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7087                                 {origin:?}, GitHub does not say whether that part of it ran, \
7088                                 and putting it back to {before:?} was refused ({restore}), so \
7089                                 item {} holds {origin:?} or {before:?} there",
7090                                item.id.0
7091                            )
7092                        };
7093                        return Err(noting(
7094                            error,
7095                            &format!(
7096                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7097                                 run the write again"
7098                            ),
7099                        ));
7100                    }
7101                }
7102            }
7103            return Err(error);
7104        }
7105
7106        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7107            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7108                self.statuses
7109                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7110            }
7111            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7112                self.statuses
7113                    .status(kind, written_option.as_deref(), false, None)
7114            }
7115            (Some((_, status)), _) => status.clone(),
7116            (None, _) => Status {
7117                category: StatusCategory::Unknown,
7118                name: "Open".to_owned(),
7119            },
7120        };
7121
7122        // So the rest of this command reads what it just did rather than what the board
7123        // said before it. See `remember_written` for which half takes it.
7124        let remembered = Resolved {
7125            item_id,
7126            id: content_id.clone(),
7127            content_kind,
7128            kind: incoming.written.kind(),
7129            title: incoming.title.to_owned(),
7130            // The visible half of the body this write composed, split back off it the
7131            // way a read splits it — so what this record reports is what a read of the
7132            // same issue reports, rather than the person's text with the metadata slot
7133            // still on the end of it.
7134            body: metadata_body(body.clone())?.0,
7135            raw_body: body.clone(),
7136            // A document has no status of its own; what it reads back as is whatever
7137            // the issue's own state says, which is what a re-read reports.
7138            status: written_status,
7139            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7140            priority: match incoming.priority {
7141                Some(priority) => HeldPriority::Read(priority),
7142                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7143                    item.priority.clone()
7144                }),
7145            },
7146            // What `state_input` asked for: closed for a terminal target, open for any other
7147            // status, and the issue's own state left as it was by a document write.
7148            closed: content_kind == ContentKind::Issue
7149                && match status_target.as_ref() {
7150                    Some(StatusTarget::Terminal(_, _)) => true,
7151                    Some(_) => false,
7152                    None => existing.is_some_and(|item| item.closed),
7153                },
7154            delivers: incoming.delivers.to_vec(),
7155            delivered_by: incoming.delivered_by.to_vec(),
7156            labels: incoming.labels.to_vec(),
7157            parent: incoming.parent.cloned(),
7158            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7159            number,
7160            // In the update path this is the item's own url, read off `existing` where the
7161            // record above was bound, so one expression serves both halves.
7162            url,
7163            created_at: existing.and_then(|item| item.created_at),
7164            updated_at: existing.and_then(|item| item.updated_at),
7165            own_repository,
7166            repositories: incoming.repositories.to_vec(),
7167            slot,
7168            board_id: Some(board.id.as_str().to_owned()),
7169            fields: board
7170                .fields
7171                .get("nodes")
7172                .and_then(Value::as_array)
7173                .cloned()
7174                .unwrap_or_default(),
7175            board_fields: Some(board.fields.clone()),
7176            // What this write left the relationship holding is known by id alone, and a
7177            // later read of its edges needs each far end's kind, so it reads them again.
7178            blocked_by: None,
7179        };
7180        self.remember_written(remembered, existing.is_none())?;
7181        Ok(content_id)
7182    }
7183
7184    /// Everything a write does after the item exists: its board fields, its parent, and
7185    /// its dependencies.
7186    ///
7187    /// Split out of `write_item` so there is one place a failure past the point of no
7188    /// return is caught, rather than a tidy-up repeated at each `?` above.
7189    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7190    // so there is one place a failure past the point of no return is caught, and its
7191    // arguments are exactly the values that tail already had in scope. Bundling them into a
7192    // struct would describe no concept — it would be "the arguments of this function" — and
7193    // would put the whole of `write_item`'s locals behind one more indirection.
7194    #[allow(clippy::too_many_arguments)]
7195    async fn finish_write(
7196        &self,
7197        board_id: &str,
7198        incoming: &Incoming<'_>,
7199        content_id: &NativeId,
7200        item_id: &str,
7201        content_kind: ContentKind,
7202        existing: Option<&Resolved>,
7203        origin_field: Option<&str>,
7204        origin: &str,
7205        column: Option<(String, String)>,
7206        status_target: Option<&StatusTarget>,
7207        priority: Option<&PriorityWrite>,
7208        native: &[String],
7209        origin_landed: &mut bool,
7210    ) -> Result<(), SourceError> {
7211        let mut fields = Vec::new();
7212        if let Some(field_id) = origin_field
7213            && existing.map_or(!origin.is_empty(), |item| {
7214                item.origin.as_deref().unwrap_or("") != origin
7215            })
7216        {
7217            fields.push((field_id.to_owned(), json!({"text":origin})));
7218        }
7219        if let Some((field_id, option_id)) = column {
7220            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7221        }
7222        let clear = match priority {
7223            Some(PriorityWrite::Select { field, option }) => {
7224                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7225                None
7226            }
7227            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7228            None => None,
7229        };
7230        self.set_item_fields(board_id, item_id, &fields, clear)
7231            .await?;
7232        *origin_landed = true;
7233
7234        // An existing issue closes in the `updateIssue` its write ends with; one created just
7235        // now closes here, once its option is selected.
7236        if existing.is_none()
7237            && content_kind == ContentKind::Issue
7238            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7239        {
7240            self.update_content(
7241                ContentKind::Issue,
7242                content_id,
7243                json!({"stateInput":state_input(status_target)}),
7244            )
7245            .await?;
7246        }
7247
7248        if content_kind == ContentKind::Issue {
7249            self.reparent(
7250                existing.and_then(|item| item.parent.clone()),
7251                content_id,
7252                incoming.parent,
7253            )
7254            .await?;
7255            // A document takes part in no dependency graph, so writing one neither reads
7256            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7257            // against the empty list a document write carries would *delete* whatever
7258            // relationships a person had made on that issue, which is a write nobody
7259            // asked for.
7260            if incoming.written.kind() != BoardKind::Document {
7261                let issue = match existing {
7262                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7263                    None => Issue::Created,
7264                };
7265                self.reconcile_blocked_by(content_id, native, issue).await?;
7266            }
7267        }
7268        Ok(())
7269    }
7270
7271    /// Delete one issue, which takes its board item with it.
7272    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7273        let data = self
7274            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7275            .await?;
7276        data.pointer("/deleteIssue/repository")
7277            .filter(|value| !value.is_null())
7278            .ok_or_else(|| SourceError::Malformed {
7279                message: "GitHub issue deletion returned no repository".into(),
7280            })?;
7281        self.forget(id)?;
7282        Ok(())
7283    }
7284
7285    /// Remove one item this copy created, so a copy that could not finish leaves the board
7286    /// as it found it.
7287    ///
7288    /// Deleting the issue takes its board item with it, so there is no second mutation to
7289    /// keep in step. An id the board does not hold is not an error: the item is already
7290    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7291    /// by its own id — a listing of the board can still be missing an item it holds, and
7292    /// reading that as *already gone* would leave behind the very item this was asked to
7293    /// take back.
7294    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7295        let Some(item) = self.bound_item(id).await? else {
7296            return Ok(());
7297        };
7298        if item.content_kind == ContentKind::DraftIssue {
7299            return Err(SourceError::Refused {
7300                message: format!(
7301                    "GitHub item {} is a draft, and this source removes an item by deleting \
7302                     its issue; next: remove it from the board by hand",
7303                    id.0
7304                ),
7305            });
7306        }
7307        let data = self
7308            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7309            .await?;
7310        data.pointer("/deleteIssue/repository")
7311            .filter(|value| !value.is_null())
7312            .ok_or_else(|| SourceError::Malformed {
7313                message: "GitHub issue deletion returned no repository".into(),
7314            })?;
7315        self.forget(id)?;
7316        Ok(())
7317    }
7318
7319    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7320    /// task.
7321    ///
7322    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7323    /// read of the task cannot disagree about which ids name one: a project or a document of
7324    /// this board is not a task here either.
7325    ///
7326    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7327    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7328    /// which would read as a task nobody has commented on yet.
7329    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7330        let cached = self.resolved_cache()?.get(task).cloned();
7331        let Some(item) = (match cached {
7332            Some(item) => Some(item),
7333            None => self.item_by_id(task).await?,
7334        })
7335        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7336            return Ok(None);
7337        };
7338        if item.content_kind == ContentKind::DraftIssue {
7339            return Err(self.draft_has_no_comments(task));
7340        }
7341        Ok(Some(item.id))
7342    }
7343
7344    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7345    /// issues, and a draft is not one.
7346    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7347        SourceError::Refused {
7348            message: format!(
7349                "task {} of source {} is a draft item on the board, and GitHub keeps \
7350                 comments on issues alone, so a draft has none to read or write; next: \
7351                 convert the draft to an issue on the board, then comment on the issue it \
7352                 becomes",
7353                task.0, self.name
7354            ),
7355        }
7356    }
7357
7358    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7359    /// request — or `None` when this board holds no task by that id.
7360    ///
7361    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7362    /// is answered with the draft and the refusal, at the price of the draft's own read.
7363    async fn issue_detail(
7364        &self,
7365        id: &NativeId,
7366        page: &PageRequest,
7367    ) -> Result<Option<TaskDetailRead>, SourceError> {
7368        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7369        let asked = self
7370            .graphql(
7371                graphql::ISSUE_DETAIL,
7372                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7373                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7374                       "duplicates":true}),
7375            )
7376            .await;
7377        let data = match asked {
7378            Ok(data) => data,
7379            Err(error) if unresolvable_node(&error) => return Ok(None),
7380            Err(error) => return Err(error),
7381        };
7382        // `node` is null for an id that names nothing, and absent only from an answer this
7383        // source cannot read — never the same thing.
7384        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7385            message: format!("GitHub answered the read of {} with no node", id.0),
7386        })?;
7387        self.detail_of(id, node, true, after).await
7388    }
7389
7390    /// Several tasks, each with the first page of its comments when `comments` is set, read
7391    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7392    /// order.
7393    ///
7394    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7395    /// one item at a time, so that id is answered as missing and the others as themselves; any
7396    /// other refusal is every id of that batch's answer.
7397    async fn issue_details(
7398        &self,
7399        ids: &[NativeId],
7400        comments: Option<&PageRequest>,
7401    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7402        let mut read = Vec::with_capacity(ids.len());
7403        for batch in ids.chunks(DETAIL_BATCH) {
7404            match self
7405                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7406                .await
7407            {
7408                Ok(data) => {
7409                    for (slot, id) in batch.iter().enumerate() {
7410                        // Every alias asked for is answered, null for an id naming nothing;
7411                        // one missing is an answer this source cannot read.
7412                        let read_one = match data.get(format!("i{slot}")) {
7413                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7414                            None => Err(SourceError::Malformed {
7415                                message: format!(
7416                                    "GitHub answered a batch read with no item for {}",
7417                                    id.0
7418                                ),
7419                            }),
7420                        };
7421                        read.push(read_one);
7422                    }
7423                }
7424                Err(error) if unresolvable_node(&error) => {
7425                    for id in batch {
7426                        read.push(match comments {
7427                            Some(page) => self.issue_detail(id, page).await,
7428                            None => self.task_read(id).await,
7429                        });
7430                    }
7431                }
7432                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7433            }
7434        }
7435        read
7436    }
7437
7438    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7439    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7440        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7441            task,
7442            comments: None,
7443        }))
7444    }
7445
7446    /// What one node a detail read reached says: the task this board holds by `id`, with the
7447    /// page of comments the node carries when `commented` — or `None` for a node that is no
7448    /// task of this board.
7449    ///
7450    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7451    /// and an item this process created answers from this process's own record, which a node
7452    /// read taken moments after the write can still be behind.
7453    async fn detail_of(
7454        &self,
7455        id: &NativeId,
7456        node: &Value,
7457        commented: bool,
7458        after: Option<&str>,
7459    ) -> Result<Option<TaskDetailRead>, SourceError> {
7460        if node.is_null() {
7461            return Ok(None);
7462        }
7463        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7464        // An issue answered under one id is that id's, or the answer is not one this source
7465        // can report: reporting another issue's task and comments under the qualified id asked
7466        // for would be the one wrong answer here. A draft's own read checks the same.
7467        if !draft
7468            && optional_str(node, "__typename")? == Some("Issue")
7469            && required_str(node, "id")? != id.0
7470        {
7471            return Err(SourceError::Malformed {
7472                message: format!(
7473                    "GitHub answered the read of {} with issue {}",
7474                    id.0,
7475                    required_str(node, "id")?
7476                ),
7477            });
7478        }
7479        let item = if draft {
7480            self.draft_by_id(id).await?
7481        } else {
7482            self.resolve_issue(node).await?
7483        };
7484        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7485            return Ok(None);
7486        };
7487        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7488        let task = own.unwrap_or(item).task()?;
7489        let comments = match (commented, draft) {
7490            (false, _) => None,
7491            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7492            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7493        };
7494        Ok(Some(TaskDetailRead { task, comments }))
7495    }
7496
7497    /// Whether the comment `comment` is one of `issue`'s own.
7498    ///
7499    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7500    /// comment's id and nothing else: a comment id given against the wrong task would
7501    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7502    /// names something that is not an issue comment, is a comment this task does not have —
7503    /// which is what GitHub refusing to resolve it means too.
7504    async fn comment_is_on(
7505        &self,
7506        issue: &NativeId,
7507        comment: &NativeId,
7508    ) -> Result<bool, SourceError> {
7509        let asked = self
7510            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7511            .await;
7512        let data = match asked {
7513            Ok(data) => data,
7514            Err(error) if unresolvable_node(&error) => return Ok(false),
7515            Err(error) => return Err(error),
7516        };
7517        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7518            return Ok(false);
7519        };
7520        if optional_str(node, "__typename")? != Some("IssueComment") {
7521            return Ok(false);
7522        }
7523        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7524            message: format!("GitHub issue comment {} names no issue", comment.0),
7525        })?;
7526        Ok(required_str(on, "id")? == issue.0)
7527    }
7528
7529    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7530    async fn partition_edges(
7531        &self,
7532        near_kind: BoardKind,
7533        near_content: ContentKind,
7534        carried: Option<&[Value]>,
7535        depends_on: &[DependencyEdge],
7536    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7537        let mut native = Vec::new();
7538        let mut fallback = Vec::new();
7539        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7540            .iter()
7541            .map(|edge| {
7542                let same_source = edge
7543                    .to
7544                    .source()
7545                    .is_none_or(|source| source == self.name.as_str());
7546                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7547                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7548                // colons of its own, so the far end is everything after that one separator.
7549                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7550                let far_id = if edge.to.is_qualified() {
7551                    edge.to
7552                        .id()
7553                        .split_once(':')
7554                        .map_or(edge.to.id(), |(_, native)| native)
7555                } else {
7556                    edge.to.id()
7557                };
7558                // One that already blocks the near issue was answered by that issue's own
7559                // read, which carried each of its blockers' kinds — an issue every one — so it
7560                // is not read again.
7561                let blocking = carried.and_then(|nodes| {
7562                    nodes
7563                        .iter()
7564                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7565                });
7566                (edge, far_id, same_source, blocking)
7567            })
7568            .collect();
7569        // Every other same-source far end is read by its own id, exactly as the item it is a
7570        // far end of is: whether this board holds it is that read's answer, never a listing's.
7571        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7572        let mut unread: Vec<NativeId> = Vec::new();
7573        for (_, far_id, same_source, blocking) in &far_ends {
7574            let id = NativeId((*far_id).to_owned());
7575            if *same_source && blocking.is_none() && !unread.contains(&id) {
7576                unread.push(id);
7577            }
7578        }
7579        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7580            .iter()
7581            .cloned()
7582            .zip(self.items_by_ids(&unread).await?)
7583            .collect();
7584        for (edge, far_id, same_source, blocking) in far_ends {
7585            let far = match (same_source, blocking) {
7586                (false, _) => None,
7587                (true, Some(node)) => Some(FarEnd {
7588                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7589                        BoardKind::Document
7590                    } else {
7591                        BoardKind::Work(related_kind(node)?)
7592                    },
7593                    content_kind: ContentKind::Issue,
7594                }),
7595                (true, None) => {
7596                    let read = read
7597                        .get(&NativeId(far_id.to_owned()))
7598                        .cloned()
7599                        .flatten()
7600                        .ok_or_else(|| SourceError::Refused {
7601                            message: format!("GitHub dependency item {far_id} was not found"),
7602                        })?;
7603                    Some(FarEnd {
7604                        kind: read.kind,
7605                        content_kind: read.content_kind,
7606                    })
7607                }
7608            };
7609            let far = far.as_ref();
7610            // The caller says which kind the far end is, and this board holds the far end
7611            // itself, so a disagreement is settled here rather than stored: recorded, the
7612            // wrong kind would read back as a cross-level edge that never existed; written
7613            // natively, it would name a relationship of a different level than the caller
7614            // asked for.
7615            //
7616            // A far end this board holds as a *document* fails the same comparison and is
7617            // refused by the same sentence: `ItemKind` has no document variant because
7618            // nothing may point at one, so no caller can name it correctly and the refusal
7619            // is the only honest answer.
7620            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7621                return Err(SourceError::Refused {
7622                    message: format!(
7623                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7624                         names it as a {}; record the kind it is",
7625                        disagreeing.kind.describes(),
7626                        edge.to.kind.marker()
7627                    ),
7628                });
7629            }
7630            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7631            // however the far end is spelled — and one classified native here would be
7632            // written nowhere at all, because a draft's native reconciliation never runs.
7633            let native_here = near_content == ContentKind::Issue
7634                && far.is_some_and(|far| {
7635                    far.content_kind == ContentKind::Issue
7636                        && BoardKind::Work(edge.to.kind) == near_kind
7637                });
7638            if native_here {
7639                native.push(far_id.to_owned());
7640            } else {
7641                fallback.push(edge.clone());
7642            }
7643        }
7644        Ok((native, fallback))
7645    }
7646
7647    async fn update_existing(
7648        &self,
7649        item: &Resolved,
7650        incoming: &Incoming<'_>,
7651        body: &Option<String>,
7652        status_target: Option<&StatusTarget>,
7653    ) -> Result<(), SourceError> {
7654        let title = incoming.written_title();
7655        // A terminal status closes the issue here, in the same mutation as its body: its board
7656        // option was selected before this, so a close never lands on an item whose board cannot
7657        // show it.
7658        let fields = match item.content_kind {
7659            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7660            ContentKind::Issue => json!({"title":title,"body":body,
7661                                         "stateInput":state_input(status_target)}),
7662        };
7663        self.update_content(item.content_kind, &item.id, fields)
7664            .await
7665    }
7666
7667    /// Update one board item's content with exactly `fields` beside its id, through the
7668    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7669    /// a draft.
7670    ///
7671    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7672    /// is what lets a narrow write carry the one thing it changes and nothing else.
7673    async fn update_content(
7674        &self,
7675        kind: ContentKind,
7676        id: &NativeId,
7677        fields: Value,
7678    ) -> Result<(), SourceError> {
7679        let (operation, id_key, pointer) = match kind {
7680            ContentKind::DraftIssue => (
7681                graphql::UPDATE_DRAFT,
7682                "draftIssueId",
7683                "/updateProjectV2DraftIssue/draftIssue",
7684            ),
7685            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7686        };
7687        let mut input = fields;
7688        input[id_key] = json!(id.0);
7689        let data = self.graphql(operation, json!({"input":input})).await?;
7690        let returned = data
7691            .pointer(pointer)
7692            .ok_or_else(|| SourceError::Malformed {
7693                message: "GitHub item update returned no item".into(),
7694            })?;
7695        if required_str(returned, "id")? != id.0 {
7696            return Err(SourceError::Malformed {
7697                message: "GitHub item update returned the wrong item".into(),
7698            });
7699        }
7700        Ok(())
7701    }
7702
7703    /// Creates one issue, files it on the board, and reports what a read of it would say:
7704    /// its content id, its board item id, and the web address GitHub gave it.
7705    ///
7706    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7707    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7708    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7709    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7710    /// "Content already exists in this project". A terminal status is not written here:
7711    /// `finish_write` selects its option first and closes the issue after, so a close never
7712    /// lands on an item whose board cannot show it.
7713    ///
7714    /// The address and the number come back here because this is the only place either is
7715    /// known before GitHub's own board read catches up — an item this run created answers
7716    /// the reads that follow it out of the record below, and one remembered without them
7717    /// would report no location and no key for the rest of the run.
7718    async fn create_and_file_issue(
7719        &self,
7720        board_id: &str,
7721        repository: &RepositoryTarget,
7722        incoming: &Incoming<'_>,
7723        body: &Option<String>,
7724    ) -> Result<Landed, SourceError> {
7725        let repository_id = self.repository_id(repository, incoming).await?;
7726        let data = self
7727            .graphql(
7728                graphql::CREATE_ISSUE,
7729                json!({"input":{
7730                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7731                }}),
7732            )
7733            .await?;
7734        let created = data
7735            .pointer("/createIssue/issue")
7736            .filter(|value| !value.is_null())
7737            .ok_or_else(|| SourceError::Malformed {
7738                message: "GitHub issue creation returned no issue".into(),
7739            })?;
7740        let content_id = NativeId(required_str(created, "id")?.to_owned());
7741        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7742        // a response without it is not worth failing a landed write over — the item simply
7743        // reports no location until the board read catches up, which is what it did before.
7744        let url = optional_str(created, "url")?.map(str::to_owned);
7745        // The issue exists from here on, so an unreadable number and a refused board
7746        // filing below each try, best effort, to take it back: an issue in the repository
7747        // that is on no board is an item nobody asked for and nothing here would find again.
7748        //
7749        // Its number is optional on the same terms its address is — a landed write is not
7750        // worth failing over a member that came back missing, and such an item reports no
7751        // handle until a board read catches up. A number that is *present* and is not an
7752        // unsigned integer is still a response this source cannot read.
7753        let number = match created_issue_number(created) {
7754            Ok(number) => number,
7755            Err(error) => {
7756                let _ = self.delete_issue(&content_id).await;
7757                return Err(error);
7758            }
7759        };
7760        let added = match self
7761            .graphql(
7762                graphql::ADD_TO_BOARD,
7763                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7764            )
7765            .await
7766        {
7767            Ok(added) => added,
7768            Err(error) => {
7769                let _ = self.delete_issue(&content_id).await;
7770                return Err(error);
7771            }
7772        };
7773        let item = added
7774            .pointer("/addProjectV2ItemById/item")
7775            .filter(|value| !value.is_null())
7776            .ok_or_else(|| SourceError::Malformed {
7777                message: "GitHub board addition returned no project item".into(),
7778            })?;
7779        Ok(Landed {
7780            content_id,
7781            item_id: required_str(item, "id")?.to_owned(),
7782            url,
7783            number,
7784        })
7785    }
7786
7787    /// Move one issue under the project it now belongs to, or out of the one it left.
7788    async fn reparent(
7789        &self,
7790        held: Option<NativeId>,
7791        child: &NativeId,
7792        wanted: Option<&NativeId>,
7793    ) -> Result<(), SourceError> {
7794        if held.as_ref() == wanted {
7795            return Ok(());
7796        }
7797        if let Some(held) = &held {
7798            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7799                .await?;
7800        }
7801        if let Some(wanted) = wanted {
7802            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7803                .await?;
7804        }
7805        Ok(())
7806    }
7807
7808    async fn sub_issue(
7809        &self,
7810        operation: &str,
7811        parent: &NativeId,
7812        child: &NativeId,
7813        root: &str,
7814    ) -> Result<(), SourceError> {
7815        let data = self
7816            .graphql(
7817                operation,
7818                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7819            )
7820            .await?;
7821        let issue =
7822            data.pointer(&format!("/{root}/issue"))
7823                .ok_or_else(|| SourceError::Malformed {
7824                    message: "GitHub sub-issue update returned no issue".into(),
7825                })?;
7826        let sub =
7827            data.pointer(&format!("/{root}/subIssue"))
7828                .ok_or_else(|| SourceError::Malformed {
7829                    message: "GitHub sub-issue update returned no sub-issue".into(),
7830                })?;
7831        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7832            return Err(SourceError::Malformed {
7833                message: "GitHub sub-issue update returned the wrong issues".into(),
7834            });
7835        }
7836        Ok(())
7837    }
7838
7839    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7840    /// whether there was one.
7841    ///
7842    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7843    /// relationships are not read: there is nothing a read of them could find.
7844    async fn reconcile_blocked_by(
7845        &self,
7846        content_id: &NativeId,
7847        native: &[String],
7848        issue: Issue<'_>,
7849    ) -> Result<bool, SourceError> {
7850        let current = match issue {
7851            Issue::Created => Vec::new(),
7852            Issue::Existing(Some(held)) => held
7853                .iter()
7854                .map(|far| required_str(far, "id").map(str::to_owned))
7855                .collect::<Result<Vec<_>, _>>()?,
7856            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7857        };
7858        let mut changed = false;
7859        for (operation, far_id) in current
7860            .iter()
7861            .filter(|id| !native.contains(id))
7862            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7863            .chain(
7864                native
7865                    .iter()
7866                    .filter(|id| !current.contains(id))
7867                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7868            )
7869        {
7870            let data = self
7871                .graphql(
7872                    operation,
7873                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7874                )
7875                .await?;
7876            let root = if operation == graphql::ADD_BLOCKED_BY {
7877                "addBlockedBy"
7878            } else {
7879                "removeBlockedBy"
7880            };
7881            let issue =
7882                data.pointer(&format!("/{root}/issue"))
7883                    .ok_or_else(|| SourceError::Malformed {
7884                        message: "GitHub dependency update returned no issue".into(),
7885                    })?;
7886            let blocker = data
7887                .pointer(&format!("/{root}/blockingIssue"))
7888                .ok_or_else(|| SourceError::Malformed {
7889                    message: "GitHub dependency update returned no blocking issue".into(),
7890                })?;
7891            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7892            {
7893                return Err(SourceError::Malformed {
7894                    message: "GitHub dependency update returned the wrong issues".into(),
7895                });
7896            }
7897            changed = true;
7898        }
7899        Ok(changed)
7900    }
7901}
7902
7903/// What a write needs to know of one far end it names: which kind of item it is, and whether
7904/// it is an issue a native relationship can name.
7905struct FarEnd {
7906    kind: BoardKind,
7907    content_kind: ContentKind,
7908}
7909
7910/// Whether the issue one write reconciles was created by that write or was already there.
7911#[derive(Clone, Copy, PartialEq, Eq)]
7912enum Issue<'a> {
7913    /// Created by this write, so it holds no relationships yet.
7914    Created,
7915    /// On the board before this write, holding whatever relationships it holds — the far
7916    /// ends of its whole `blockedBy`, when the read that reached it carried them.
7917    Existing(Option<&'a [Value]>),
7918}
7919
7920/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7921enum Reached {
7922    /// An issue this board holds, resolved into everything this source reports about it.
7923    Held(Box<Resolved>),
7924    /// Nothing this board holds: no such node, or a node on some other board.
7925    Nothing,
7926    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7927    /// again by [`GitHubProjectsSource::draft_by_id`].
7928    Draft,
7929}
7930
7931/// What GitHub says when a string is not a node id it can resolve.
7932///
7933/// Matched because it is the ordinary answer to a project selector naming a project by its
7934/// *name*, and reporting that as a failure would make naming one impossible. It is read
7935/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7936/// does not define the syntax of a GitHub node id and would be wrong about it.
7937const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7938
7939/// `error` with `note` added to the end of what it says, its kind and every other member
7940/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7941/// what that failure left behind.
7942fn noting(error: SourceError, note: &str) -> SourceError {
7943    match error {
7944        SourceError::Config { message } => SourceError::Config {
7945            message: message + note,
7946        },
7947        SourceError::Auth { message } => SourceError::Auth {
7948            message: message + note,
7949        },
7950        SourceError::Refused { message } => SourceError::Refused {
7951            message: message + note,
7952        },
7953        SourceError::RateLimited {
7954            retry_after_seconds,
7955            message,
7956        } => SourceError::RateLimited {
7957            retry_after_seconds,
7958            message: Some(message.unwrap_or_default() + note),
7959        },
7960        SourceError::Unavailable { message } => SourceError::Unavailable {
7961            message: message + note,
7962        },
7963        SourceError::Malformed { message } => SourceError::Malformed {
7964            message: message + note,
7965        },
7966    }
7967}
7968
7969/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7970/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7971/// for them.
7972///
7973/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7974/// is read again at no added price.
7975fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7976    let mut variables = serde_json::Map::new();
7977    for slot in 0..DETAIL_BATCH {
7978        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7979        variables.insert(format!("id{slot}"), json!(id));
7980    }
7981    variables.insert(
7982        "first".to_owned(),
7983        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7984    );
7985    variables.insert("comments".to_owned(), json!(comments.is_some()));
7986    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7987    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7988    variables.insert("duplicates".to_owned(), json!(true));
7989    Value::Object(variables)
7990}
7991
7992/// Whether this refusal is GitHub saying the id names no node at all.
7993fn unresolvable_node(error: &SourceError) -> bool {
7994    matches!(error, SourceError::Refused { message }
7995        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7996}
7997
7998/// One project name, as a search qualifier which filters on it at the server.
7999///
8000/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8001/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8002/// the way it documents. A title matched here is still compared for equality afterwards:
8003/// the qualifier narrows what the server sends, and this source decides what it names.
8004fn title_qualifier(name: &str) -> String {
8005    format!("in:title {}", quoted(name))
8006}
8007
8008/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8009/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8010/// it documents — so a value holding a qualifier's spelling is searched for rather than
8011/// obeyed.
8012fn quoted(value: &str) -> String {
8013    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8014    format!("\"{escaped}\"")
8015}
8016
8017/// The search qualifier for the issues updated at or after `since`.
8018///
8019/// Written to the second, rounded down, which can only widen what the search returns.
8020fn updated_qualifier(since: DateTime<Utc>) -> String {
8021    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8022}
8023
8024/// The search terms that narrow a board-scoped issue search to a task query's text and
8025/// metadata predicates, or `None` when it carries neither.
8026///
8027/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8028/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8029/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8030/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8031/// a metadata value searches both fields for both — wider than asked, never narrower, and
8032/// every candidate is confirmed in process afterwards.
8033///
8034/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8035/// matches whole tokens where a substring rule would match inside a word, so an item holding
8036/// the text only inside a longer word is not returned. A text of nothing but whitespace
8037/// matches every item, so it narrows nothing and is not sent.
8038fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8039    let text = query
8040        .text
8041        .as_ref()
8042        .filter(|text| !text.terms.trim().is_empty());
8043    if text.is_none() && query.metadata.is_empty() {
8044        return None;
8045    }
8046    let (title, body) = match text.map(|text| text.fields) {
8047        None => (false, true),
8048        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8049        Some(TextFields::Content) => (false, true),
8050        Some(TextFields::TitleOrContent) => (true, true),
8051    };
8052    let fields = match (title, body) {
8053        (true, true) => "in:title,body",
8054        (true, false) => "in:title",
8055        _ => "in:body",
8056    };
8057    let phrases = text
8058        .map(|text| text.terms.clone())
8059        .into_iter()
8060        .chain(
8061            query
8062                .metadata
8063                .iter()
8064                .map(|wanted| as_stored(wanted.value())),
8065        )
8066        .map(|phrase| quoted(&phrase))
8067        .collect::<Vec<_>>();
8068    Some(format!("{fields} {}", phrases.join(" ")))
8069}
8070
8071/// The search terms that narrow a board-scoped issue search to a project or document query's
8072/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8073/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8074fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8075    narrowing_qualifiers(&TaskQuery {
8076        text: text.cloned(),
8077        ..TaskQuery::default()
8078    })
8079}
8080
8081/// Refuses a project or document query's text GitHub's issue search cannot find, before
8082/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8083/// query's.
8084fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8085    refuse_unsearchable(&TaskQuery {
8086        text: text.cloned(),
8087        ..TaskQuery::default()
8088    })
8089}
8090
8091/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8092/// before anything is asked of GitHub.
8093///
8094/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8095/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8096/// left out, the search is every issue of the board. So this source says it cannot answer
8097/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8098/// nothing GitHub could search for, and keeps the board read it always had.
8099fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8100    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8101                       letter or digit with a bounded query";
8102    if let Some(text) = &query.text
8103        && !text.terms.trim().is_empty()
8104        && !has_words(&text.terms)
8105    {
8106        return Err(SourceError::Refused {
8107            message: format!(
8108                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8109                text.terms
8110            ),
8111        });
8112    }
8113    if let Some(wanted) = query
8114        .metadata
8115        .iter()
8116        .find(|wanted| !has_words(wanted.value()))
8117    {
8118        return Err(SourceError::Refused {
8119            message: format!(
8120                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8121                wanted.value(),
8122                std::iter::once(wanted.key())
8123                    .chain(wanted.path().iter().map(String::as_str))
8124                    .collect::<Vec<_>>()
8125                    .join("/"),
8126            ),
8127        });
8128    }
8129    Ok(())
8130}
8131
8132/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8133fn has_words(phrase: &str) -> bool {
8134    phrase.chars().any(char::is_alphanumeric)
8135}
8136
8137/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8138///
8139/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8140/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8141/// which GitHub's word match would read as different words.
8142fn as_stored(value: &str) -> String {
8143    let encoded = Value::String(value.to_owned()).to_string();
8144    encoded[1..encoded.len() - 1].to_owned()
8145}
8146
8147/// The one narrower question a task query carrying a text, metadata or origin predicate is
8148/// sent as.
8149enum Narrowing {
8150    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8151    Origin(String),
8152    /// The board-scoped issue search narrowed by these qualifiers.
8153    Search(String),
8154}
8155
8156impl Narrowing {
8157    /// What this question is remembered under for the length of one command.
8158    fn key(&self) -> String {
8159        match self {
8160            Self::Origin(origin) => format!("origin {origin}"),
8161            Self::Search(also) => format!("search {also}"),
8162        }
8163    }
8164}
8165
8166/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8167enum Resumed {
8168    /// It reported another page, which starts after this cursor.
8169    More(String),
8170    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8171    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8172    /// can go on walking the other connection.
8173    Ended(Option<String>),
8174}
8175
8176impl Resumed {
8177    /// Whether the connection has another page.
8178    const fn has_more(&self) -> bool {
8179        matches!(self, Self::More(_))
8180    }
8181
8182    /// The cursor to send this connection next.
8183    fn cursor(self) -> Option<String> {
8184        match self {
8185            Self::More(next) => Some(next),
8186            Self::Ended(last) => last,
8187        }
8188    }
8189}
8190
8191/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8192/// with no cursor to it, or from a cursor that does not advance.
8193fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8194    let info = connection
8195        .get("pageInfo")
8196        .ok_or_else(|| SourceError::Malformed {
8197            message: "GitHub connection has no pageInfo".into(),
8198        })?;
8199    let end = optional_str(info, "endCursor")?;
8200    if required_bool(info, "hasNextPage")? {
8201        let next = end.ok_or_else(|| SourceError::Malformed {
8202            message: "GitHub connection reports another page and no endCursor".into(),
8203        })?;
8204        validate_cursor_progress(after, next)?;
8205        return Ok(Resumed::More(next.to_owned()));
8206    }
8207    Ok(Resumed::Ended(
8208        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8209    ))
8210}
8211
8212/// The board, and every item on it this source reports.
8213#[derive(Clone)]
8214struct Board {
8215    id: String,
8216    fields: Value,
8217    items: Vec<Resolved>,
8218}
8219
8220/// What a write needs of the board and nothing more: its node id and its field
8221/// definitions, in the shape a read of the board's own `fields` gives them.
8222///
8223/// Deliberately no items. A write decides which item it writes, which parent it files
8224/// under and which far ends it names by reading each of them by its own id; this is the
8225/// half of the board those reads cannot carry, and holding no item is what keeps it from
8226/// ever being asked whether an item is there.
8227#[derive(Clone)]
8228struct BoardFields {
8229    id: BoardId,
8230    fields: Value,
8231}
8232
8233/// A board's node id: what a field write and `addProjectV2ItemById` address.
8234///
8235/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8236/// refused where it is read, and one an item names blank is read as not named at all.
8237#[derive(Clone)]
8238struct BoardId(String);
8239
8240/// Where one write left its item, for the record the rest of the command reads it out of.
8241///
8242/// A named record rather than a tuple because the update arm and the create arm each fill
8243/// all four, and two `Option`s of different meaning side by side in a tuple are two
8244/// positions a reader has to count.
8245struct Landed {
8246    /// The issue's own node id, which is the [`NativeId`] this source reports.
8247    content_id: NativeId,
8248    /// The board item's id, which is what a field write addresses.
8249    // 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.
8250    item_id: String,
8251    /// The web address GitHub gave the issue, when it gave one.
8252    // 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.
8253    url: Option<String>,
8254    /// The issue's number on its repository, when GitHub reported one.
8255    number: Option<u64>,
8256}
8257
8258impl BoardId {
8259    fn parse(id: &str) -> Result<Self, SourceError> {
8260        if id.trim().is_empty() {
8261            return Err(SourceError::Malformed {
8262                message: "GitHub named a board with a blank node id".into(),
8263            });
8264        }
8265        Ok(Self(id.to_owned()))
8266    }
8267
8268    fn as_str(&self) -> &str {
8269        &self.0
8270    }
8271}
8272
8273impl Board {
8274    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8275        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8276        let nodes = fields
8277            .get("nodes")
8278            .and_then(Value::as_array)
8279            .ok_or_else(|| SourceError::Malformed {
8280                message: "GitHub project fields.nodes is not an array".into(),
8281            })?;
8282        Ok(nodes
8283            .iter()
8284            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8285    }
8286}
8287
8288/// One board item, resolved into everything this source reports about it.
8289#[derive(Clone)]
8290struct Resolved {
8291    item_id: String,
8292    id: NativeId,
8293    content_kind: ContentKind,
8294    kind: BoardKind,
8295    title: String,
8296    body: Option<String>,
8297    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8298    /// that changes the slot alone has to keep byte for byte outside it.
8299    raw_body: Option<String>,
8300    status: Status,
8301    /// The name of the board `Status` option this item sits in, as the board spells it.
8302    option: Option<String>,
8303    /// What its `Priority` field says, read through this instance's mapping.
8304    priority: HeldPriority,
8305    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8306    closed: bool,
8307    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8308    delivers: Vec<TaskRef>,
8309    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8310    /// task.
8311    delivered_by: Vec<TaskRef>,
8312    labels: Vec<Label>,
8313    parent: Option<NativeId>,
8314    // 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.
8315    origin: Option<String>,
8316    /// The issue's own number on its repository, as GitHub reports it.
8317    ///
8318    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8319    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8320    /// an issue this run created whose creating mutation answered without one, which is a
8321    /// response GitHub's own schema says cannot happen and which a landed write is not
8322    /// worth failing over. An `Issue` read off the board always has one.
8323    number: Option<u64>,
8324    url: Option<String>,
8325    created_at: Option<DateTime<Utc>>,
8326    updated_at: Option<DateTime<Utc>>,
8327    own_repository: Option<Repository>,
8328    repositories: Vec<Repository>,
8329    slot: BTreeMap<String, Value>,
8330    /// The node id of the board this item sits on, when the read that reached it said.
8331    board_id: Option<String>,
8332    /// The definition of every board field this item holds a value of, in the shape a read
8333    /// of the board's own `fields` gives one.
8334    ///
8335    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8336    /// which says nothing about whether the board has it.
8337    fields: Vec<Value>,
8338    /// Every field the board this item sits on defines, as its own read of the board's
8339    /// `fields` gives them — when the read that reached the item carried them, which a read
8340    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8341    /// of the board.
8342    board_fields: Option<Value>,
8343    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8344    /// selects one — when the read that reached it carried the connection to its end, which a
8345    /// read of it by its own id does for any issue blocked by no more than a page. What a
8346    /// write reconciles that relationship against, and what a read of its forward edges in
8347    /// the same command answers with.
8348    blocked_by: Option<Vec<Value>>,
8349}
8350
8351impl Resolved {
8352    /// The board this item's own read names it on, when that read named one this source can
8353    /// address.
8354    fn named_board(&self) -> Option<BoardId> {
8355        self.board_id
8356            .as_deref()
8357            .and_then(|id| BoardId::parse(id).ok())
8358    }
8359
8360    /// The board's id and every field it defines, when the read that reached this item
8361    /// carried both — which a read of it by its own id does.
8362    fn carried_board(&self) -> Option<BoardFields> {
8363        Some(BoardFields {
8364            id: self.named_board()?,
8365            fields: self.board_fields.clone()?,
8366        })
8367    }
8368
8369    /// Whether this item holds a value of the board field called `name`, and so carries
8370    /// that field's definition. `false` says nothing about whether the board has the field.
8371    fn defines(&self, name: &str) -> bool {
8372        self.fields
8373            .iter()
8374            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8375    }
8376
8377    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8378    /// in a field of its own, and none of the five keys that are only an encoding.
8379    ///
8380    /// The two delivery keys are left out for every kind, not only for a task: they are
8381    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8382    /// document carrying one holds nothing a caller's own metadata could mean by it.
8383    fn metadata(&self) -> BTreeMap<String, Value> {
8384        let mut metadata = self.slot.clone();
8385        metadata.remove(Repository::METADATA_KEY);
8386        metadata.remove(DependencyEdge::RECORDED_KEY);
8387        metadata.remove(ItemKind::METADATA_KEY);
8388        metadata.remove(TaskRef::DELIVERS_KEY);
8389        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8390        // The board field is the origin, and the body's copy of it is only a mirror for the
8391        // issue search to find: an item whose field holds none has none, whatever its body
8392        // says, so no reader ever sees two answers.
8393        metadata.remove(ORIGIN_KEY);
8394        if let Some(origin) = &self.origin {
8395            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8396        }
8397        metadata
8398    }
8399
8400    /// Where this item is, as a link a reader can open.
8401    ///
8402    /// A board is a hosted place and every issue on it has a web address, so that address
8403    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8404    /// of place it is, so a reader knows to open it rather than to read a file out. It
8405    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8406    /// reported before, and this says what that address *is*.
8407    ///
8408    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8409    /// rather than a third variant, which is the contract's "the source did not say". An
8410    /// issue this run created is not one of those: its address comes back from the
8411    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8412    /// rather than from whenever the board read catches up.
8413    fn location(&self) -> Option<Location> {
8414        self.url.clone().map(Location::Url)
8415    }
8416
8417    /// The short handle this board's backend shows people for a task: the issue's number
8418    /// alone, as a decimal string.
8419    ///
8420    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8421    /// value for this backend. A draft has no number and so no handle, which is the
8422    /// contract's *absent* rather than a handle of some other shape — and the native
8423    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8424    /// derives from.
8425    fn key(&self) -> Option<String> {
8426        self.number.map(|number| number.to_string())
8427    }
8428
8429    /// Whether its `Priority` field holds a value at all, mapped or not.
8430    fn holds_priority(&self) -> bool {
8431        self.priority != HeldPriority::Read(Priority::None)
8432    }
8433
8434    /// The task this item is.
8435    ///
8436    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8437    /// reading that as a level would be a guess, and reading it as `none` would let the next
8438    /// copy clear a priority a person set.
8439    fn task(&self) -> Result<Task, SourceError> {
8440        let priority = match &self.priority {
8441            HeldPriority::Read(priority) => *priority,
8442            HeldPriority::Unmapped(option) => {
8443                return Err(SourceError::Malformed {
8444                    message: format!(
8445                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8446                         this source's priority_mapping does not name, so its priority cannot be \
8447                         read; next: name {option:?} under priority_mapping, or move the item to \
8448                         a mapped option",
8449                        self.id,
8450                        self.number
8451                            .map(|number| format!(" (#{number})"))
8452                            .unwrap_or_default()
8453                    ),
8454                });
8455            }
8456        };
8457        Ok(Task {
8458            id: self.id.clone(),
8459            key: self.key(),
8460            title: self.title.clone(),
8461            content: self.body.clone(),
8462            status: self.status.clone(),
8463            priority,
8464            labels: self.labels.clone(),
8465            project: self.parent.clone(),
8466            url: self.url.clone(),
8467            location: self.location(),
8468            created_at: self.created_at,
8469            updated_at: self.updated_at,
8470            metadata: self.metadata(),
8471            repositories: self.repositories.clone(),
8472            delivers: self.delivers.clone(),
8473            delivered_by: self.delivered_by.clone(),
8474        })
8475    }
8476
8477    fn project(&self) -> Project {
8478        Project {
8479            id: self.id.clone(),
8480            title: self.title.clone(),
8481            content: self.body.clone(),
8482            status: self.status.clone(),
8483            labels: self.labels.clone(),
8484            url: self.url.clone(),
8485            location: self.location(),
8486            created_at: self.created_at,
8487            updated_at: self.updated_at,
8488            metadata: self.metadata(),
8489            repositories: self.repositories.clone(),
8490        }
8491    }
8492
8493    /// The same issue as a document: the project it is filed under, and no status and no
8494    /// dependencies, because a document is not work.
8495    fn document(&self) -> Document {
8496        Document {
8497            id: self.id.clone(),
8498            title: self.title.clone(),
8499            content: self.body.clone(),
8500            project: self.parent.clone(),
8501            labels: self.labels.clone(),
8502            url: self.url.clone(),
8503            location: self.location(),
8504            created_at: self.created_at,
8505            updated_at: self.updated_at,
8506            metadata: self.metadata(),
8507            repositories: self.repositories.clone(),
8508        }
8509    }
8510}
8511
8512/// Where one targeted update moves an item's status, and which of its two halves move.
8513struct StatusMove {
8514    /// The board the item's `Status` field is on.
8515    board: BoardId,
8516    /// The `Status` field's id.
8517    field: String,
8518    /// The option's id.
8519    option: String,
8520    /// The option's name, as the board spells it.
8521    name: String,
8522    /// What the status asks of the issue's state.
8523    target: StatusTarget,
8524    /// The status the item reads as once it is there.
8525    landed: Status,
8526    /// Which of the status's two halves differ from what the item holds.
8527    moves: Moves,
8528}
8529
8530/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8531/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8532/// and is not a value of this type.
8533#[derive(Clone, Copy, PartialEq, Eq)]
8534enum Moves {
8535    /// The option alone.
8536    Option,
8537    /// The issue's state alone: open, closed, or closed with another reason.
8538    State,
8539    /// Both.
8540    Both,
8541}
8542
8543impl Moves {
8544    /// What differs, or `None` when nothing does.
8545    const fn of(option: bool, state: bool) -> Option<Self> {
8546        match (option, state) {
8547            (true, true) => Some(Self::Both),
8548            (true, false) => Some(Self::Option),
8549            (false, true) => Some(Self::State),
8550            (false, false) => None,
8551        }
8552    }
8553
8554    /// Whether the option moves.
8555    const fn option(self) -> bool {
8556        matches!(self, Self::Option | Self::Both)
8557    }
8558
8559    /// Whether the issue's state moves.
8560    const fn state(self) -> bool {
8561        matches!(self, Self::State | Self::Both)
8562    }
8563}
8564
8565/// What one write is, and the status that comes with being it.
8566///
8567/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8568/// status and a task or a project always has one, so "a document carrying a status" and
8569/// "a task carrying none" are states a write cannot be in rather than states every use
8570/// site below has to defend against.
8571enum Written<'a> {
8572    /// A document, which is not work and so has no status at all.
8573    Document,
8574    /// A task or a project, and the status it is being written with.
8575    Work(ItemKind, &'a Status),
8576}
8577
8578impl Written<'_> {
8579    /// Which of the board's three kinds this write is.
8580    const fn kind(&self) -> BoardKind {
8581        match self {
8582            Self::Document => BoardKind::Document,
8583            Self::Work(kind, _) => BoardKind::Work(*kind),
8584        }
8585    }
8586
8587    /// The status this write carries. A document carries none, so a write of one says
8588    /// nothing about the issue's open or closed state and selects no board `Status`
8589    /// option.
8590    const fn status(&self) -> Option<&Status> {
8591        match self {
8592            Self::Document => None,
8593            Self::Work(_, status) => Some(status),
8594        }
8595    }
8596
8597    /// The status this write carries with the kind whose half of `status_mapping` it is
8598    /// written through.
8599    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8600        match self {
8601            Self::Document => None,
8602            Self::Work(kind, status) => Some((*kind, status)),
8603        }
8604    }
8605}
8606
8607/// The item being written, in the one shape all three write methods reach.
8608struct Incoming<'a> {
8609    written: Written<'a>,
8610    /// The title a person wrote. A document's goes onto the issue with
8611    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8612    title: &'a str,
8613    content: Option<&'a str>,
8614    labels: &'a [Label],
8615    metadata: &'a BTreeMap<String, Value>,
8616    repositories: &'a [Repository],
8617    parent: Option<&'a NativeId>,
8618    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8619    /// what keeps either key out of their slot.
8620    delivers: &'a [TaskRef],
8621    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8622    delivered_by: &'a [TaskRef],
8623    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8624    /// project, a document, and every write to an instance with no `priority_mapping` —
8625    /// which is what keeps such a write's requests exactly what they were before.
8626    priority: Option<Priority>,
8627}
8628
8629/// What one write does to an item's `Priority` field.
8630enum PriorityWrite {
8631    /// Select this option of this field.
8632    Select {
8633        /// The `Priority` field's id.
8634        field: String,
8635        /// The mapped option's id.
8636        option: String,
8637    },
8638    /// Clear the field's value, which is what `none` is.
8639    Clear {
8640        /// The `Priority` field's id.
8641        field: String,
8642    },
8643}
8644
8645impl Incoming<'_> {
8646    /// The title this write puts on the issue.
8647    fn written_title(&self) -> String {
8648        match self.written {
8649            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8650            Written::Work(..) => self.title.to_owned(),
8651        }
8652    }
8653}
8654
8655#[derive(Clone, Copy, PartialEq, Eq)]
8656enum ContentKind {
8657    DraftIssue,
8658    Issue,
8659}
8660
8661/// What one board issue is: a document, or the work an [`ItemKind`] names.
8662///
8663/// A type of this source's own rather than an `ItemKind` with a third variant, because
8664/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8665/// document — the contract keeps a document out of that enum deliberately. Holding the
8666/// board's three answers in one value is what makes every place that asks "which is this?"
8667/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8668/// two thirds of the board.
8669#[derive(Clone, Copy, PartialEq, Eq)]
8670enum BoardKind {
8671    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8672    Document,
8673    /// Every other issue, and every draft.
8674    Work(ItemKind),
8675}
8676
8677impl BoardKind {
8678    /// Whose half of `status_mapping` an item of this kind reads its status through. A
8679    /// document has no status of its own, so the task half stands in for whatever the issue
8680    /// holds; nothing reports it.
8681    const fn status_kind(self) -> ItemKind {
8682        match self {
8683            Self::Document => ItemKind::Task,
8684            Self::Work(kind) => kind,
8685        }
8686    }
8687
8688    /// How a refusal names this kind to the person reading it.
8689    const fn describes(self) -> &'static str {
8690        match self {
8691            Self::Document => "document",
8692            Self::Work(kind) => kind.marker(),
8693        }
8694    }
8695}
8696
8697/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8698///
8699/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8700/// the shared cross-source journeys assert one answer to one question, so two sources
8701/// that disagree about what "carries the label bug" means fail them.
8702fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8703    let holds = |name: &String| {
8704        labels
8705            .iter()
8706            .any(|label| label.name.eq_ignore_ascii_case(name))
8707    };
8708    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8709        && filter.all_of.iter().all(holds)
8710        && !filter.none_of.iter().any(holds)
8711}
8712
8713/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8714/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8715fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8716    statuses.is_empty() || statuses.contains(&category)
8717}
8718
8719/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8720///
8721/// `content` is the item's own prose — the body with this source's trailing metadata
8722/// comment already taken off — so a search never matches an encoding the author of the
8723/// issue never wrote.
8724fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8725    let terms = query.terms.to_lowercase();
8726    let in_title = title.to_lowercase().contains(&terms);
8727    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8728    match query.fields {
8729        TextFields::Title => in_title,
8730        TextFields::Content => in_content,
8731        TextFields::TitleOrContent => in_title || in_content,
8732    }
8733}
8734
8735/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8736///
8737/// The project predicate is passed separately because a read narrowed to one project has
8738/// already answered it by asking *that project* for its own items — and re-applying it
8739/// there would compare the caller's selector, which may be a project's **name**, against
8740/// the id of the project that name resolved to, and keep nothing. Every other read passes
8741/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8742/// source really does apply.
8743fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8744    labels_match(&task.labels, &query.labels)
8745        && status_matches(task.status.category, &query.statuses)
8746        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8747        && match project {
8748            ProjectFilter::Any => true,
8749            ProjectFilter::Orphans => task.project.is_none(),
8750            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8751        }
8752        && query
8753            .text
8754            .as_ref()
8755            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8756        // Against the parsed metadata slot, and against the origin field, which is where
8757        // `Resolved::metadata` reads each of them from.
8758        && query.metadata_matches(&task.metadata)
8759        && query.origin_matches(&task.metadata)
8760}
8761
8762fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8763    labels_match(&project.labels, &query.labels)
8764        && status_matches(project.status.category, &query.statuses)
8765        && query
8766            .text
8767            .as_ref()
8768            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8769}
8770
8771/// The same three predicates a task query carries, minus the status filter.
8772///
8773/// A document is not work, so it has no status for one to compare against and the query
8774/// type carries none. The project predicate is the same one — a design issue filed under a
8775/// project issue is in that project, and one filed under nothing is in none — so it is
8776/// spelled the same way here rather than answered differently.
8777fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8778    labels_match(&document.labels, &query.labels)
8779        && match project {
8780            ProjectFilter::Any => true,
8781            ProjectFilter::Orphans => document.project.is_none(),
8782            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8783        }
8784        && query
8785            .text
8786            .as_ref()
8787            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8788}
8789
8790#[async_trait::async_trait]
8791impl TaskSource for GitHubProjectsSource {
8792    fn kind(&self) -> &'static str {
8793        KIND
8794    }
8795    fn capabilities(&self) -> Capabilities {
8796        Capabilities {
8797            projects: Support::Native,
8798            documents: Support::Native,
8799            comments: Support::Native,
8800            priority: if self.priorities.is_some() {
8801                Support::Native
8802            } else {
8803                Support::Unsupported
8804            },
8805            filter_by_priority: Support::Native,
8806            filter_by_comment_activity: Support::Native,
8807            filter_by_metadata: Support::Native,
8808            filter_by_origin: Support::Native,
8809            orphan_tasks: Support::Native,
8810            filter_by_label: Support::Native,
8811            filter_by_status: Support::Native,
8812            search_title: Support::Native,
8813            search_content: Support::Native,
8814            task_dependencies: DependencySupport::BothDirections,
8815            project_dependencies: DependencySupport::BothDirections,
8816            max_page_size: MAX_PAGE_SIZE,
8817        }
8818    }
8819    async fn health(&self) -> Result<Health, SourceError> {
8820        let board = self.board_page(None, 1).await?;
8821        Ok(Health {
8822            reachable: true,
8823            detail: Some(format!(
8824                "reading GitHub project {}/{} ({})",
8825                self.owner,
8826                self.project_number,
8827                required_str(&board, "title")?
8828            )),
8829        })
8830    }
8831    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8832        self.item_by_id(id)
8833            .await?
8834            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8835            .map(|item| item.task())
8836            .transpose()
8837    }
8838    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8839        Ok(self
8840            .item_by_id(id)
8841            .await?
8842            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8843            .map(|item| item.project()))
8844    }
8845    async fn query_tasks(
8846        &self,
8847        query: &TaskQuery,
8848        page: &PageRequest,
8849    ) -> Result<Page<Task>, SourceError> {
8850        validate_page(page)?;
8851        refuse_unsearchable(query)?;
8852        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8853            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8854                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8855                (Some(also), None) => Some(also),
8856                (None, Some(since)) => Some(updated_qualifier(since)),
8857                (None, None) => None,
8858            };
8859            if let Some(also) = qualifiers {
8860                return self.search_tasks(query, page, &also).await;
8861            }
8862        }
8863
8864        // A read narrowed to one project asks that project for its own tasks, so nothing
8865        // about it costs what the rest of the board holds. A read carrying a text, metadata
8866        // or origin predicate asks GitHub the narrower question those predicates are, and a
8867        // read narrowed to comment activity alone asks the board's own issue search for the
8868        // issues updated since, which is every issue a comment could have been written or
8869        // edited on since. Every other task read is a question about the whole board and is
8870        // answered by reading it.
8871        let (held, membership) = match (&query.project, query.commented_since) {
8872            (ProjectFilter::Is(project), _) => (
8873                self.project_children(project).await?,
8874                // Answered by where these items came from; see `task_matches`.
8875                &ProjectFilter::Any,
8876            ),
8877            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8878                match (self.narrowed(query).await?, since) {
8879                    (Some(narrowed), _) => (narrowed, &query.project),
8880                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8881                    (None, None) => (self.board().await?.items, &query.project),
8882                }
8883            }
8884        };
8885        // Filtered before paged: a page of a filtered result is a page of the survivors,
8886        // never the survivors of a page.
8887        let mut tasks = Vec::new();
8888        for item in held
8889            .iter()
8890            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8891        {
8892            let task = item.task()?;
8893            if task_matches(&task, query, membership)
8894                && self.commented_since(item, query.commented_since).await?
8895            {
8896                tasks.push(task);
8897            }
8898        }
8899        Ok(offset_page(
8900            tasks,
8901            numeric_cursor(page.cursor.as_ref())?,
8902            page.limit.min(MAX_PAGE_SIZE) as usize,
8903        ))
8904    }
8905    async fn query_projects(
8906        &self,
8907        query: &ProjectQuery,
8908        page: &PageRequest,
8909    ) -> Result<Page<Project>, SourceError> {
8910        validate_page(page)?;
8911        refuse_unsearchable_text(query.text.as_ref())?;
8912        // The projects a board holds are found by an issue search scoped to that board,
8913        // never by walking the board's own item connection: what tells a project from a
8914        // task is the `parent` each issue carries, which costs nothing to read. A query
8915        // carrying a text asks that search for the text too, so it reads the issues that
8916        // hold it rather than every issue of the board.
8917        let held = match self.text_searched(query.text.as_ref()).await? {
8918            Some(searched) => searched,
8919            None => self.board_issues().await?,
8920        };
8921        let projects = held
8922            .iter()
8923            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8924            .map(Resolved::project)
8925            .filter(|project| project_matches(project, query))
8926            .collect();
8927        Ok(offset_page(
8928            projects,
8929            numeric_cursor(page.cursor.as_ref())?,
8930            page.limit.min(MAX_PAGE_SIZE) as usize,
8931        ))
8932    }
8933    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8934        Ok(self
8935            .item_by_id(id)
8936            .await?
8937            .filter(|item| item.kind == BoardKind::Document)
8938            .map(|item| item.document()))
8939    }
8940    async fn query_documents(
8941        &self,
8942        query: &DocumentQuery,
8943        page: &PageRequest,
8944    ) -> Result<Page<Document>, SourceError> {
8945        validate_page(page)?;
8946        // Narrowed to one project, this is the same sub-issue read a task list scoped to
8947        // that project makes — a document filed under a project is a sub-issue of it too,
8948        // and which of them come back is the kind this caller asked for. Unscoped, a query
8949        // carrying a text asks the board-scoped issue search for it, as a task query does,
8950        // and only one carrying none reads the board.
8951        let (held, membership) = match &query.project {
8952            ProjectFilter::Is(project) => (
8953                self.project_children(project).await?,
8954                // Answered by where these items came from; see `task_matches`.
8955                &ProjectFilter::Any,
8956            ),
8957            ProjectFilter::Any | ProjectFilter::Orphans => {
8958                refuse_unsearchable_text(query.text.as_ref())?;
8959                match self.text_searched(query.text.as_ref()).await? {
8960                    Some(searched) => (searched, &query.project),
8961                    None => (self.board().await?.items, &query.project),
8962                }
8963            }
8964        };
8965        // Filtered before paged, exactly as a task read is: a page of a filtered result is
8966        // a page of the survivors, never the survivors of a page.
8967        let documents = held
8968            .iter()
8969            .filter(|item| item.kind == BoardKind::Document)
8970            .map(Resolved::document)
8971            .filter(|document| document_matches(document, query, membership))
8972            .collect();
8973        Ok(offset_page(
8974            documents,
8975            numeric_cursor(page.cursor.as_ref())?,
8976            page.limit.min(MAX_PAGE_SIZE) as usize,
8977        ))
8978    }
8979    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8980        validate_page(page)?;
8981        let offset = numeric_cursor(page.cursor.as_ref())?;
8982        let mut labels = self
8983            .board()
8984            .await?
8985            .items
8986            .into_iter()
8987            .flat_map(|item| item.labels)
8988            .fold(Vec::new(), |mut all, label| {
8989                if !all.iter().any(|x: &Label| x.id == label.id) {
8990                    all.push(label);
8991                }
8992                all
8993            });
8994        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8995        Ok(offset_page(
8996            labels,
8997            offset,
8998            page.limit.min(MAX_PAGE_SIZE) as usize,
8999        ))
9000    }
9001    async fn task_dependencies(
9002        &self,
9003        id: &NativeId,
9004        direction: Direction,
9005        page: &PageRequest,
9006    ) -> Result<Page<DependencyEdge>, SourceError> {
9007        self.dependencies(id, ItemKind::Task, direction, page).await
9008    }
9009    async fn project_dependencies(
9010        &self,
9011        id: &NativeId,
9012        direction: Direction,
9013        page: &PageRequest,
9014    ) -> Result<Page<DependencyEdge>, SourceError> {
9015        self.dependencies(id, ItemKind::Project, direction, page)
9016            .await
9017    }
9018
9019    fn writes(&self) -> WriteSupport {
9020        WriteSupport::Supported
9021    }
9022
9023    /// Create or update one task.
9024    ///
9025    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9026    /// neither may name the task itself or name one task twice — and land in the body's
9027    /// metadata slot under their reserved keys, in place of any caller metadata of those
9028    /// names.
9029    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9030        let near = write.target.as_ref().unwrap_or(&write.item.id);
9031        for (key, entries) in [
9032            (TaskRef::DELIVERS_KEY, &write.item.delivers),
9033            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9034        ] {
9035            TaskRef::listed(key, near, Some(&self.name), entries.clone())
9036                .map_err(|message| SourceError::Refused { message })?;
9037        }
9038        if self.priorities.is_none() && write.item.priority != Priority::None {
9039            return Err(self.holds_no_priority());
9040        }
9041        self.write_item(
9042            &Incoming {
9043                written: Written::Work(ItemKind::Task, &write.item.status),
9044                title: &write.item.title,
9045                content: write.item.content.as_deref(),
9046                labels: &write.item.labels,
9047                metadata: &write.item.metadata,
9048                repositories: &write.item.repositories,
9049                parent: write.item.project.as_ref(),
9050                delivers: &write.item.delivers,
9051                delivered_by: &write.item.delivered_by,
9052                priority: self.priorities.as_ref().map(|_| write.item.priority),
9053            },
9054            write.target.as_ref(),
9055            &write.depends_on,
9056        )
9057        .await
9058    }
9059
9060    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9061        self.write_item(
9062            &Incoming {
9063                written: Written::Work(ItemKind::Project, &write.item.status),
9064                title: &write.item.title,
9065                content: write.item.content.as_deref(),
9066                labels: &write.item.labels,
9067                metadata: &write.item.metadata,
9068                repositories: &write.item.repositories,
9069                parent: None,
9070                delivers: &[],
9071                delivered_by: &[],
9072                priority: None,
9073            },
9074            write.target.as_ref(),
9075            &write.depends_on,
9076        )
9077        .await
9078    }
9079
9080    /// Create or update one document, which is one issue titled the way this board spells
9081    /// a document.
9082    ///
9083    /// Everything else is exactly a task write: caller metadata goes to the same canonical
9084    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9085    /// or a field this board cannot carry is refused by name rather than dropped, a target
9086    /// naming an issue this board does not hold is refused rather than created, and an
9087    /// issue this call created is taken back when the rest of the write fails.
9088    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9089        // A document takes part in no dependency graph, so there is no far end to write
9090        // natively and none to record: a caller naming one is told so rather than having it
9091        // stored under the reserved key, where a later read would report an edge the
9092        // contract says cannot exist.
9093        if !write.depends_on.is_empty() {
9094            return Err(SourceError::Refused {
9095                message: format!(
9096                    "this write names {} dependencies for a document, and a document takes \
9097                     part in no dependency graph; next: put the dependency on the task or \
9098                     project the document is about",
9099                    write.depends_on.len()
9100                ),
9101            });
9102        }
9103        self.write_item(
9104            &Incoming {
9105                written: Written::Document,
9106                title: &write.item.title,
9107                content: write.item.content.as_deref(),
9108                labels: &write.item.labels,
9109                metadata: &write.item.metadata,
9110                repositories: &write.item.repositories,
9111                parent: write.item.project.as_ref(),
9112                delivers: &[],
9113                delivered_by: &[],
9114                priority: None,
9115            },
9116            write.target.as_ref(),
9117            &[],
9118        )
9119        .await
9120    }
9121
9122    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9123    /// which reads nothing; then the board's `Status` option. Over an existing item that is
9124    /// read off the item, as the write reads it, and the item is held among this command's
9125    /// resolved records so the write that follows reuses that read rather than repeating it;
9126    /// an item that does not carry the field takes the board's fields, which are held once
9127    /// read. A create is checked against the board's fields only when this command already
9128    /// holds them, because a create reads them together with its repository, in one request,
9129    /// and refuses a missing option before it writes anything.
9130    async fn check_status_write(
9131        &self,
9132        kind: ItemKind,
9133        category: StatusCategory,
9134        target: Option<&NativeId>,
9135    ) -> Result<(), SourceError> {
9136        let status = self.resolved_target(kind, category)?;
9137        if status.option().is_none() {
9138            return Ok(());
9139        }
9140        let fields = match target {
9141            Some(target) => {
9142                // A target this board does not hold is the write's own refusal to make.
9143                let Some(item) = self.bound_item(target).await? else {
9144                    return Ok(());
9145                };
9146                self.resolved_cache()?.insert(target.clone(), item.clone());
9147                self.fields_for(Some(&item), true, false).await?.fields
9148            }
9149            None => {
9150                let held = self
9151                    .board_cache()?
9152                    .as_ref()
9153                    .map(|board| board.fields.clone());
9154                match held.or_else(|| {
9155                    self.fields_cache()
9156                        .ok()
9157                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9158                }) {
9159                    Some(fields) => fields,
9160                    None => return Ok(()),
9161                }
9162            }
9163        };
9164        self.column_for(&fields, kind, category, &status)
9165            .map(|_| ())
9166    }
9167
9168    /// Set one task's status alone.
9169    ///
9170    /// An open target reopens a closed issue with an `updateIssue` carrying only its
9171    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9172    /// terminal target selects its mapped option, then closes with its fixed reason. No
9173    /// request carries a title, a body or a label. The status
9174    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9175    /// what a re-read reports.
9176    async fn set_task_status(
9177        &self,
9178        id: &NativeId,
9179        category: StatusCategory,
9180    ) -> Result<Option<Status>, SourceError> {
9181        self.set_status(id, category).await
9182    }
9183
9184    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9185    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9186    /// for `none`. Refused by an instance with no `priority_mapping`.
9187    async fn set_task_priority(
9188        &self,
9189        id: &NativeId,
9190        priority: Priority,
9191    ) -> Result<Option<Priority>, SourceError> {
9192        self.set_priority(id, priority).await
9193    }
9194
9195    /// Replace one task's content with a single body update that keeps the metadata slot
9196    /// byte for byte.
9197    async fn set_task_content(
9198        &self,
9199        id: &NativeId,
9200        content: &str,
9201    ) -> Result<Option<()>, SourceError> {
9202        self.replace_content(id, content).await
9203    }
9204
9205    /// Replace one task issue's content and its provenance slot entry with a single body
9206    /// update. The answers are not kept: see `replace_rendering`.
9207    async fn set_task_rendering(
9208        &self,
9209        id: &NativeId,
9210        content: &str,
9211        provenance: &Value,
9212        _answers: &BTreeMap<String, Value>,
9213    ) -> Result<Option<()>, SourceError> {
9214        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9215            .await
9216    }
9217
9218    /// Replace one design-document issue's content and its provenance slot entry, on exactly
9219    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9220    async fn set_document_rendering(
9221        &self,
9222        id: &NativeId,
9223        content: &str,
9224        provenance: &Value,
9225        _answers: &BTreeMap<String, Value>,
9226    ) -> Result<Option<()>, SourceError> {
9227        self.replace_rendering(id, BoardKind::Document, content, provenance)
9228            .await
9229    }
9230
9231    /// Apply a targeted update with one read of the item and a write only for what differs:
9232    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9233    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9234    async fn update_task(
9235        &self,
9236        id: &NativeId,
9237        update: &TaskUpdate,
9238    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9239        self.targeted_update(id, update).await
9240    }
9241
9242    /// Replace one task's `delivered_by` with a single body update that changes the
9243    /// metadata slot and nothing outside it.
9244    async fn set_delivered_by(
9245        &self,
9246        id: &NativeId,
9247        delivered_by: &[TaskRef],
9248    ) -> Result<Option<()>, SourceError> {
9249        self.replace_delivered_by(id, delivered_by).await
9250    }
9251
9252    /// Set one key of one task issue's metadata with a single body update that changes the
9253    /// metadata slot and nothing outside it — no title, label, state or board field request —
9254    /// and sends nothing when the task already holds that value under the key.
9255    async fn set_task_metadata(
9256        &self,
9257        id: &NativeId,
9258        key: &MetadataKey,
9259        value: &Value,
9260    ) -> Result<Option<Task>, SourceError> {
9261        Ok(self
9262            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9263            .await?
9264            .map(|item| item.task())
9265            .transpose()?)
9266    }
9267
9268    /// Set one key of one project issue's metadata, on exactly the terms of
9269    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9270    async fn set_project_metadata(
9271        &self,
9272        id: &NativeId,
9273        key: &MetadataKey,
9274        value: &Value,
9275    ) -> Result<Option<Project>, SourceError> {
9276        Ok(self
9277            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9278            .await?
9279            .map(|item| item.project()))
9280    }
9281
9282    /// Set one key of one design-document issue's metadata, on exactly the terms of
9283    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9284    async fn set_document_metadata(
9285        &self,
9286        id: &NativeId,
9287        key: &MetadataKey,
9288        value: &Value,
9289    ) -> Result<Option<Document>, SourceError> {
9290        Ok(self
9291            .set_slot_key(id, BoardKind::Document, key, value)
9292            .await?
9293            .map(|item| item.document()))
9294    }
9295
9296    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9297        self.delete_item(id).await
9298    }
9299
9300    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9301        self.delete_item(id).await
9302    }
9303
9304    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9305        self.delete_item(id).await
9306    }
9307
9308    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9309    ///
9310    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9311    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9312    ///
9313    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9314    /// board is the read of its comments. A draft this process already resolved is refused
9315    /// without one.
9316    async fn task_comments(
9317        &self,
9318        task: &NativeId,
9319        page: &PageRequest,
9320    ) -> Result<Option<Page<Comment>>, SourceError> {
9321        validate_page(page)?;
9322        let cached = self.resolved_cache()?.get(task).cloned();
9323        if let Some(item) = cached {
9324            if item.kind != BoardKind::Work(ItemKind::Task) {
9325                return Ok(None);
9326            }
9327            if item.content_kind == ContentKind::DraftIssue {
9328                return Err(self.draft_has_no_comments(task));
9329            }
9330        }
9331        match self.issue_detail(task, page).await? {
9332            Some(TaskDetailRead {
9333                comments: Some(comments),
9334                ..
9335            }) => comments,
9336            _ => Ok(None),
9337        }
9338    }
9339
9340    /// Every id's task, with the first page of its comments when `comments` names it:
9341    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9342    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9343    async fn get_task_details(
9344        &self,
9345        ids: &[NativeId],
9346        comments: Option<&PageRequest>,
9347    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9348        if let Some(page) = comments
9349            && let Err(error) = validate_page(page)
9350        {
9351            return ids.iter().map(|_| Err(error.clone())).collect();
9352        }
9353        match (ids, comments) {
9354            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9355            ([id], None) => vec![self.task_read(id).await],
9356            _ => self.issue_details(ids, comments).await,
9357        }
9358    }
9359
9360    /// Add one comment to the task's issue, as the account the token belongs to.
9361    ///
9362    /// The author is refused before anything is sent — not even the task is read — because
9363    /// no answer GitHub could give would make posting under another name than the one asked
9364    /// for the right outcome.
9365    async fn add_comment(
9366        &self,
9367        task: &NativeId,
9368        comment: &NewComment,
9369    ) -> Result<Option<Comment>, SourceError> {
9370        if let Some(author) = &comment.author {
9371            return Err(SourceError::Refused {
9372                message: format!(
9373                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9374                     the token signs in as the author of every comment; next: leave --author \
9375                     out, and the comment is posted as that account",
9376                    self.name
9377                ),
9378            });
9379        }
9380        let Some(issue) = self.commented_issue(task).await? else {
9381            return Ok(None);
9382        };
9383        let data = self
9384            .graphql(
9385                graphql::ADD_COMMENT,
9386                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9387            )
9388            .await?;
9389        let subject = data
9390            .pointer("/addComment/subject")
9391            .filter(|value| !value.is_null())
9392            .ok_or_else(|| SourceError::Malformed {
9393                message: "GitHub comment addition returned no subject".into(),
9394            })?;
9395        if required_str(subject, "id")? != issue.0 {
9396            return Err(SourceError::Malformed {
9397                message: "GitHub comment addition answered about another issue".into(),
9398            });
9399        }
9400        let added = data
9401            .pointer("/addComment/commentEdge/node")
9402            .filter(|value| !value.is_null())
9403            .ok_or_else(|| SourceError::Malformed {
9404                message: "GitHub comment addition returned no comment".into(),
9405            })?;
9406        comment_from(added).map(Some)
9407    }
9408
9409    async fn edit_comment(
9410        &self,
9411        task: &NativeId,
9412        comment: &NativeId,
9413        body: &CommentBody,
9414    ) -> Result<Option<Comment>, SourceError> {
9415        let Some(issue) = self.commented_issue(task).await? else {
9416            return Ok(None);
9417        };
9418        if !self.comment_is_on(&issue, comment).await? {
9419            return Ok(None);
9420        }
9421        let data = self
9422            .graphql(
9423                graphql::UPDATE_COMMENT,
9424                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9425            )
9426            .await?;
9427        let edited = data
9428            .pointer("/updateIssueComment/issueComment")
9429            .filter(|value| !value.is_null())
9430            .ok_or_else(|| SourceError::Malformed {
9431                message: "GitHub comment update returned no comment".into(),
9432            })?;
9433        let edited = comment_from(edited)?;
9434        if edited.id != *comment {
9435            return Err(SourceError::Malformed {
9436                message: "GitHub comment update returned the wrong comment".into(),
9437            });
9438        }
9439        Ok(Some(edited))
9440    }
9441
9442    async fn delete_comment(
9443        &self,
9444        task: &NativeId,
9445        comment: &NativeId,
9446    ) -> Result<Option<NativeId>, SourceError> {
9447        let Some(issue) = self.commented_issue(task).await? else {
9448            return Ok(None);
9449        };
9450        if !self.comment_is_on(&issue, comment).await? {
9451            return Ok(None);
9452        }
9453        let data = self
9454            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9455            .await?;
9456        // The payload says nothing about the comment it removed, so what is checked is that
9457        // GitHub answered the mutation at all rather than leaving it unanswered.
9458        data.get("deleteIssueComment")
9459            .filter(|value| !value.is_null())
9460            .ok_or_else(|| SourceError::Malformed {
9461                message: "GitHub comment deletion returned no payload".into(),
9462            })?;
9463        Ok(Some(comment.clone()))
9464    }
9465
9466    /// Every request this source has recorded, and what each of GitHub's two budgets was
9467    /// attributed — read off the same accounting the session report is rendered from, so
9468    /// the two cannot count one request two ways.
9469    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9470        Ok(Some(self.ledger.snapshot().metering()))
9471    }
9472
9473    /// Drop every item, search answer and board read this source holds, so the next command
9474    /// reads the board as a person has since left it.
9475    ///
9476    /// Every one of those is held on the assumption that nothing but this source writes the
9477    /// board while a command runs, which stops being true the moment the command is over: a
9478    /// body a person edited would be overwritten from the record held here, and a card they
9479    /// moved would be read as still where this source left it. The board's own field
9480    /// definitions go too, because a person can add or delete a `Status` option and a write
9481    /// resolved against the held list would not re-read on a miss. What stays is what stays
9482    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9483    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9484    /// accounting [`metering`](TaskSource::metering) answers from.
9485    ///
9486    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9487    /// refused, because clearing it is what puts it right.
9488    async fn end_command(&self) -> Result<(), SourceError> {
9489        fn clear<T: Default>(held: &Mutex<T>) {
9490            *held
9491                .lock()
9492                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9493            held.clear_poison();
9494        }
9495        clear(&self.created);
9496        clear(&self.updated);
9497        clear(&self.board_cache);
9498        clear(&self.search_cache);
9499        clear(&self.narrowed_cache);
9500        clear(&self.search_next);
9501        clear(&self.resolved_cache);
9502        clear(&self.fields_cache);
9503        Ok(())
9504    }
9505}
9506
9507/// One issue comment as the contract carries it.
9508///
9509/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9510/// and when it answers an actor with no login, because either way the source did not say who
9511/// wrote it — which is what an absent author means, rather than an author called nothing.
9512fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9513    Ok(Comment {
9514        id: NativeId(required_str(value, "id")?.to_owned()),
9515        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9516            .map(str::to_owned),
9517        created_at: optional_time(value, "createdAt")?,
9518        updated_at: optional_time(value, "updatedAt")?,
9519        body: required_str(value, "body")?.to_owned(),
9520        url: optional_str(value, "url")?.map(str::to_owned),
9521    })
9522}
9523
9524/// The page of comments one issue node carries, resumed from `after`.
9525fn comment_page(
9526    node: &Value,
9527    issue: &str,
9528    after: Option<&str>,
9529) -> Result<Page<Comment>, SourceError> {
9530    let connection = node
9531        .get("comments")
9532        .filter(|value| !value.is_null())
9533        .ok_or_else(|| SourceError::Malformed {
9534            message: format!("GitHub issue {issue} answered with no comments connection"),
9535        })?;
9536    let items = optional_nodes(Some(connection), "issue comments")?
9537        .into_iter()
9538        .flatten()
9539        .map(comment_from)
9540        .collect::<Result<Vec<_>, _>>()?;
9541    let next = next_cursor(connection)?;
9542    if let Some(next) = &next {
9543        validate_cursor_progress(after, &next.0)?;
9544    }
9545    Ok(Page { items, next })
9546}
9547
9548/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9549/// end — `None` when it carried none, or a page with more past it.
9550fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9551    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9552        return Ok(None);
9553    };
9554    if next_cursor(connection)?.is_some() {
9555        return Ok(None);
9556    }
9557    Ok(Some(
9558        optional_nodes(Some(connection), "blocked-by issues")?
9559            .into_iter()
9560            .flatten()
9561            .cloned()
9562            .collect(),
9563    ))
9564}
9565
9566/// Where the recorded tail of a dependency walk resumes; see
9567/// [`GitHubProjectsSource::recorded_edges`].
9568const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9569
9570/// The board text field this source keeps a copy's origin in.
9571///
9572/// Named after the key it holds, and held to that name by the guard below rather than by
9573/// a reader noticing.
9574const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9575
9576/// The metadata key that field holds.
9577///
9578/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9579/// constructs or interprets the qualified id it carries. This source names it only to
9580/// route it — a short, typed value belongs in a typed field rather than in the body slot
9581/// a caller's own prose shares.
9582///
9583/// Restated rather than imported, because no plugin crate may depend on the engine. What
9584/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9585/// target in `check`: it reads the engine's own literal and fails naming the file and the
9586/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9587/// that creates a second item every run instead of finding the one it wrote — and that is
9588/// too late to learn it.
9589const ORIGIN_KEY: &str = "onetaskgraph.origin";
9590
9591/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9592///
9593/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9594/// is derived from the far end, never written down on the near item — so only a forward
9595/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9596/// it did not come from, and it is told so rather than answered with an empty page that
9597/// reads as a walk which ended.
9598fn recorded_offset(
9599    cursor: Option<&str>,
9600    direction: Direction,
9601) -> Result<Option<usize>, SourceError> {
9602    cursor
9603        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9604        .map(|offset| {
9605            if direction != Direction::DependsOn {
9606                return Err(SourceError::Config {
9607                    message: format!(
9608                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9609                         reverse dependency read never issues; resume it in the direction \
9610                         that reported it"
9611                    ),
9612                });
9613            }
9614            offset.parse().map_err(|_| SourceError::Config {
9615                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9616            })
9617        })
9618        .transpose()
9619}
9620
9621fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9622    let mut page = offset_page(edges, offset, limit.max(1));
9623    page.next = page
9624        .next
9625        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9626    page
9627}
9628
9629/// The kind of one issue reached through a dependency connection.
9630///
9631/// The same questions the board scan asks, over the fields the dependency document
9632/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9633/// then anything with sub-issues or the marker is a project.
9634///
9635/// # Errors
9636///
9637/// A far end this board holds as a document is refused rather than reported. The two
9638/// answers that are not refusals would both be wrong: reporting it as a task names an id
9639/// no task read of this source can find, and reporting it as a project names one no
9640/// project read can. There is no third value to return — `ItemKind` has no document
9641/// variant, because nothing may point at a document — so the relationship itself is what
9642/// the person is told about.
9643fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9644    let id = required_str(value, "id")?;
9645    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9646        return Err(SourceError::Refused {
9647            message: format!(
9648                "GitHub issue {id} is a document of this board — its title begins \
9649                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9650                 on by one; next: remove that issue's blocking relationship on this board"
9651            ),
9652        });
9653    }
9654    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9655    if parent.is_some() {
9656        return Ok(ItemKind::Task);
9657    }
9658    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9659    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9660        message: format!("GitHub issue {id}: {message}"),
9661    })?;
9662    let sub_issues = sub_issue_total(value)?;
9663    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9664        ItemKind::Project
9665    } else {
9666        ItemKind::Task
9667    })
9668}
9669
9670/// The `IssueStateUpdateInput` one status target asks for.
9671///
9672/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9673/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9674/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9675/// would report a change forever. A document has no status at all, and asks for neither.
9676fn state_input(target: Option<&StatusTarget>) -> Value {
9677    match target {
9678        Some(StatusTarget::Terminal(_, reason)) => {
9679            json!({"value":"CLOSED","stateReason":reason.reason()})
9680        }
9681        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9682        // A document has no status, so a write of one says nothing about the issue's open
9683        // or closed state rather than forcing it open: `stateInput` is what carries that
9684        // instruction, and an explicit null asks for no change to it.
9685        None => Value::Null,
9686    }
9687}
9688
9689/// The metadata one write stores in the item's body slot.
9690///
9691/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9692/// rather than carried: the kind marker so an empty project stays readable, the
9693/// repository list only when it is not exactly the issue's own repository, and the far
9694/// ends no relationship here can name.
9695///
9696/// The copy origin is the one typed field that is also mirrored here, and only as a
9697/// mirror: it lands in the board's origin field as well, which stays the one every reader
9698/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9699/// and catches up with a write in seconds rather than minutes — can find the item by it.
9700/// A reader of the release before this one drops the slot's copy and reads the field, so an
9701/// item written here still reads with exactly one origin there.
9702fn slot_metadata(
9703    incoming: &Incoming<'_>,
9704    own_repository: Option<&Repository>,
9705    fallback: &[DependencyEdge],
9706) -> BTreeMap<String, Value> {
9707    let mut metadata = incoming.metadata.clone();
9708    match metadata.remove(ORIGIN_KEY) {
9709        Some(Value::String(origin)) if !origin.is_empty() => {
9710            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9711        }
9712        _ => {}
9713    }
9714    match incoming.written.kind() {
9715        BoardKind::Work(kind) => metadata.insert(
9716            ItemKind::METADATA_KEY.to_owned(),
9717            Value::String(kind.marker().to_owned()),
9718        ),
9719        // A document is told by its title, so it carries no kind marker: that key names
9720        // what a dependency endpoint points at, and nothing may point at a document.
9721        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9722    };
9723    let derivable = own_repository
9724        .map(|own| incoming.repositories == [own.clone()])
9725        .unwrap_or(incoming.repositories.is_empty());
9726    if derivable {
9727        metadata.remove(Repository::METADATA_KEY);
9728    } else {
9729        metadata.insert(
9730            Repository::METADATA_KEY.to_owned(),
9731            Value::Array(
9732                incoming
9733                    .repositories
9734                    .iter()
9735                    .map(|repository| Value::String(repository.as_str().to_owned()))
9736                    .collect(),
9737            ),
9738        );
9739    }
9740    // The typed lists are what land, whatever the caller's own metadata held under their
9741    // keys: a key of either name travelling beside the field would otherwise be a second
9742    // answer to the same question, and the field is the one the contract names.
9743    for (key, entries) in [
9744        (TaskRef::DELIVERS_KEY, incoming.delivers),
9745        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9746    ] {
9747        set_task_list(&mut metadata, key, entries);
9748    }
9749    record_edges(&mut metadata, fallback);
9750    metadata
9751}
9752
9753/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9754/// one slot's metadata, or no such key when there are none.
9755fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9756    if fallback.is_empty() {
9757        metadata.remove(DependencyEdge::RECORDED_KEY);
9758    } else {
9759        metadata.insert(
9760            DependencyEdge::RECORDED_KEY.to_owned(),
9761            Value::Array(
9762                fallback
9763                    .iter()
9764                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9765                    .collect(),
9766            ),
9767        );
9768    }
9769}
9770
9771/// Every label one item carries, from its content's own connection and nowhere else.
9772///
9773/// There is no second place to read one from: no document this source sends selects the
9774/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9775/// cannot carry one at all. The module documentation records the three schema facts that
9776/// settle it.
9777fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9778    optional_nodes(content.get("labels"), "content labels")?
9779        .into_iter()
9780        .flatten()
9781        .map(|v| {
9782            Ok(Label {
9783                id: NativeId(required_str(v, "id")?.to_owned()),
9784                name: required_str(v, "name")?.to_owned(),
9785                color: optional_str(v, "color")?.map(str::to_owned),
9786            })
9787        })
9788        .collect()
9789}
9790
9791/// The definition of each board field one item's values are values of, in the shape a read
9792/// of the board's own `fields` gives one.
9793///
9794/// A value names its field through a fragment on that field's own type, so the type is
9795/// known from which kind of value it is: a single-select value's field is a
9796/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9797/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9798fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9799    field_values
9800        .iter()
9801        .filter_map(|value| {
9802            let field = value.get("field")?.as_object()?;
9803            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9804            let typename = if value.get("text").is_some() {
9805                "ProjectV2Field"
9806            } else if value.get("name").is_some() {
9807                "ProjectV2SingleSelectField"
9808            } else {
9809                return None;
9810            };
9811            let mut defined = field.clone();
9812            defined.insert("__typename".to_owned(), json!(typename));
9813            Some(Value::Object(defined))
9814        })
9815        .collect()
9816}
9817
9818fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9819    let Some(node) = field_values
9820        .iter()
9821        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9822    else {
9823        return Ok(None);
9824    };
9825    Ok(optional_str(node, "text")?.map(str::to_owned))
9826}
9827
9828fn valid_github_owner(owner: &str) -> bool {
9829    !owner.is_empty()
9830        && owner.len() <= 39
9831        && !owner.starts_with('-')
9832        && !owner.ends_with('-')
9833        && !owner.contains("--")
9834        && owner
9835            .bytes()
9836            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9837}
9838
9839/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9840/// neither of the two names a path segment already means.
9841fn valid_github_repository_name(name: &str) -> bool {
9842    !name.is_empty()
9843        && name.len() <= 100
9844        && name != "."
9845        && name != ".."
9846        && name
9847            .bytes()
9848            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9849}
9850
9851fn valid_environment_name(name: &str) -> bool {
9852    let mut bytes = name.bytes();
9853    bytes
9854        .next()
9855        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9856        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9857}
9858
9859/// How many sub-issues one issue has.
9860///
9861/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9862/// absent or non-integer one is a response this source cannot read — and reading it as
9863/// zero would classify a project as a task, which is exactly the mistake the marker
9864/// exists to keep from happening quietly.
9865fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9866    let summary = issue
9867        .get("subIssuesSummary")
9868        .ok_or_else(|| SourceError::Malformed {
9869            message: "GitHub issue is missing subIssuesSummary".into(),
9870        })?;
9871    summary
9872        .get("total")
9873        .and_then(Value::as_u64)
9874        .ok_or_else(|| SourceError::Malformed {
9875            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9876        })
9877}
9878
9879/// One issue's own `number`.
9880///
9881/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9882/// an issue in this module asks for it. So a read of one that comes back without it, or
9883/// with something that is not an unsigned integer, is a response this source cannot read —
9884/// absence here is **not** "this issue has no number". A draft is the content that has
9885/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9886/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9887fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9888    issue
9889        .get("number")
9890        .and_then(Value::as_u64)
9891        .ok_or_else(|| SourceError::Malformed {
9892            message: "GitHub issue number is missing or is not an unsigned integer".into(),
9893        })
9894}
9895
9896/// The `number` a creating mutation answered with, and `None` when it answered without one;
9897/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9898fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9899    match created.get("number") {
9900        None | Some(Value::Null) => Ok(None),
9901        Some(value) => value
9902            .as_u64()
9903            .map(Some)
9904            .ok_or_else(|| SourceError::Malformed {
9905                message: "GitHub created issue number is not an unsigned integer".into(),
9906            }),
9907    }
9908}
9909
9910fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9911    value
9912        .get(field)
9913        .and_then(Value::as_str)
9914        .ok_or_else(|| SourceError::Malformed {
9915            message: format!("GitHub response is missing string field {field}"),
9916        })
9917}
9918
9919fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9920    let found = required_str(value, field)?;
9921    if found.trim().is_empty() {
9922        return Err(SourceError::Malformed {
9923            message: format!("GitHub response has blank string field {field}"),
9924        });
9925    }
9926    Ok(found)
9927}
9928
9929/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9930/// needs one — Linear spells them too, in its own description field.
9931///
9932/// Restated rather than shared, because a plugin crate depends on the contract crate and
9933/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9934/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9935/// source round-trips its own writes perfectly well under its own spelling.
9936const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9937const METADATA_CLOSE: &str = "\n-->";
9938
9939/// What the composer puts between a non-empty visible body and the slot, and the one thing
9940/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9941/// other trailing byte of the body comes back as it was written.
9942// 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.
9943const METADATA_SEPARATOR: &str = "\n\n";
9944
9945/// The visible body and the metadata slot at the end of it.
9946///
9947/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9948/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9949/// own content and is left alone. The visible body is everything before the slot less the
9950/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9951fn metadata_body(
9952    body: Option<String>,
9953) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9954    let Some(body) = body else {
9955        return Ok((None, BTreeMap::new()));
9956    };
9957    let Some(slot) = slot_span(&body)? else {
9958        return Ok((Some(body), BTreeMap::new()));
9959    };
9960    let metadata =
9961        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9962            SourceError::Malformed {
9963                message: format!(
9964                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9965                ),
9966            }
9967        })?;
9968    let before = &body[..slot.start];
9969    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9970    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9971}
9972
9973/// Where the metadata slot sits in one body, as byte offsets into it.
9974struct SlotSpan {
9975    /// Where [`METADATA_OPEN`] begins.
9976    start: usize,
9977    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9978    encoded_start: usize,
9979    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9980    encoded_end: usize,
9981    /// Just past [`METADATA_CLOSE`].
9982    end: usize,
9983}
9984
9985/// The slot at the very end of `body`, or `None` when it has none.
9986///
9987/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9988/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9989/// slot.
9990fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9991    let Some(start) = body.rfind(METADATA_OPEN) else {
9992        return Ok(None);
9993    };
9994    let encoded_start = start + METADATA_OPEN.len();
9995    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9996        return Err(SourceError::Malformed {
9997            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9998        });
9999    };
10000    let encoded_end = encoded_start + relative_end;
10001    let end = encoded_end + METADATA_CLOSE.len();
10002    if !body[end..].trim().is_empty() {
10003        return Ok(None);
10004    }
10005    Ok(Some(SlotSpan {
10006        start,
10007        encoded_start,
10008        encoded_end,
10009        end,
10010    }))
10011}
10012
10013/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10014/// slot as it was.
10015///
10016/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10017/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10018/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10019/// or alone in an empty body — and a body with no slot that is given no metadata is
10020/// returned as it is.
10021fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10022    let encoded = if metadata.is_empty() {
10023        None
10024    } else {
10025        Some(
10026            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10027                message: error.to_string(),
10028            })?,
10029        )
10030    };
10031    Ok(match (slot_span(body)?, encoded) {
10032        (Some(slot), Some(encoded)) => format!(
10033            "{}{encoded}{}",
10034            &body[..slot.encoded_start],
10035            &body[slot.encoded_end..]
10036        ),
10037        (Some(slot), None) => {
10038            let before = &body[..slot.start];
10039            format!(
10040                "{}{}",
10041                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10042                &body[slot.end..]
10043            )
10044        }
10045        (None, None) => body.to_owned(),
10046        (None, Some(encoded)) if body.is_empty() => {
10047            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10048        }
10049        (None, Some(encoded)) => {
10050            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10051        }
10052    })
10053}
10054
10055/// `body` with everything before its metadata slot replaced by `content`, and the slot
10056/// itself kept byte for byte.
10057///
10058/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10059/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10060/// `content` is empty — so a read of the result reports `content` as the visible body and
10061/// the slot's metadata exactly as it was.
10062fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10063    let Some(slot) = slot_span(body)? else {
10064        return Ok(content.to_owned());
10065    };
10066    let kept = &body[slot.start..];
10067    Ok(if content.is_empty() {
10068        kept.to_owned()
10069    } else {
10070        format!("{content}{METADATA_SEPARATOR}{kept}")
10071    })
10072}
10073
10074/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10075fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10076    if entries.is_empty() {
10077        metadata.remove(key);
10078    } else {
10079        metadata.insert(
10080            key.to_owned(),
10081            Value::Array(
10082                entries
10083                    .iter()
10084                    .map(|entry| Value::String(entry.as_str().to_owned()))
10085                    .collect(),
10086            ),
10087        );
10088    }
10089}
10090
10091fn compose_body(
10092    content: Option<&str>,
10093    metadata: &BTreeMap<String, Value>,
10094) -> Result<Option<String>, SourceError> {
10095    let visible = content.unwrap_or_default();
10096    if metadata.is_empty() {
10097        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10098    }
10099    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10100        message: error.to_string(),
10101    })?;
10102    Ok(Some(if visible.is_empty() {
10103        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10104    } else {
10105        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10106    }))
10107}
10108
10109fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10110    value
10111        .get(field)
10112        .and_then(Value::as_bool)
10113        .ok_or_else(|| SourceError::Malformed {
10114            message: format!("GitHub response is missing boolean field {field}"),
10115        })
10116}
10117fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10118    match value.get(field) {
10119        None | Some(Value::Null) => Ok(None),
10120        Some(value) => value
10121            .as_str()
10122            .map(Some)
10123            .ok_or_else(|| SourceError::Malformed {
10124                message: format!("GitHub response field {field} is not a string or null"),
10125            }),
10126    }
10127}
10128fn optional_nodes<'a>(
10129    connection: Option<&'a Value>,
10130    name: &str,
10131) -> Result<Option<&'a Vec<Value>>, SourceError> {
10132    match connection {
10133        None | Some(Value::Null) => Ok(None),
10134        Some(value) => value
10135            .get("nodes")
10136            .and_then(Value::as_array)
10137            .map(Some)
10138            .ok_or_else(|| SourceError::Malformed {
10139                message: format!("GitHub {name}.nodes is not an array"),
10140            }),
10141    }
10142}
10143fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10144    let page_info = connection
10145        .get("pageInfo")
10146        .ok_or_else(|| SourceError::Malformed {
10147            message: format!("GitHub {name} has no pageInfo"),
10148        })?;
10149    if required_bool(page_info, "hasNextPage")? {
10150        return Err(SourceError::Malformed {
10151            message: format!(
10152                "GitHub {name} exceeds the supported nested connection size of {size}"
10153            ),
10154        });
10155    }
10156    Ok(())
10157}
10158fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10159    optional_str(value, field)?
10160        .map(|timestamp| {
10161            timestamp.parse().map_err(|error| SourceError::Malformed {
10162                message: format!("GitHub response field {field} is not a timestamp: {error}"),
10163            })
10164        })
10165        .transpose()
10166}
10167fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10168    if page.limit == 0 {
10169        Err(SourceError::Config {
10170            message: "page limit must be at least 1".into(),
10171        })
10172    } else {
10173        Ok(())
10174    }
10175}
10176fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10177    let page = connection
10178        .get("pageInfo")
10179        .filter(|value| value.is_object())
10180        .ok_or_else(|| SourceError::Malformed {
10181            message: "GitHub connection is missing pageInfo".into(),
10182        })?;
10183    if required_bool(page, "hasNextPage")? {
10184        let cursor = required_str(page, "endCursor")?;
10185        validate_cursor_progress(None, cursor)?;
10186        Ok(Some(Cursor(cursor.into())))
10187    } else {
10188        Ok(None)
10189    }
10190}
10191fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10192    if next.is_empty() || previous == Some(next) {
10193        Err(SourceError::Malformed {
10194            message: "GitHub pagination cursor is empty or did not advance".into(),
10195        })
10196    } else {
10197        Ok(())
10198    }
10199}
10200/// The version of this plugin's opaque narrowing-search cursor.
10201pub const SEARCH_CURSOR_VERSION: u32 = 4;
10202
10203#[derive(Serialize, Deserialize)]
10204#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10205enum SearchConnection {
10206    Initial {},
10207    Continuing { after: Cursor },
10208    Exhausted {},
10209}
10210impl SearchConnection {
10211    fn after(&self) -> Option<&str> {
10212        match self {
10213            Self::Continuing { after } => Some(&after.0),
10214            _ => None,
10215        }
10216    }
10217    fn exhausted(&self) -> bool {
10218        matches!(self, Self::Exhausted { .. })
10219    }
10220    /// Whether a cursor naming this position, `offset` rows into its page, is one this
10221    /// plugin could have handed out: a page is resumed only part of the way through it — an
10222    /// offset of a whole page or more would skip rows nobody was given — an initial page
10223    /// only once some of it was handed out, and an exhausted connection has no page to be
10224    /// part of the way through.
10225    fn valid_resume(&self, offset: usize) -> bool {
10226        let within = offset < SEARCH_PAGE_SIZE as usize;
10227        match self {
10228            Self::Initial { .. } => offset > 0 && within,
10229            Self::Continuing { after } => !after.0.is_empty() && within,
10230            Self::Exhausted { .. } => offset == 0,
10231        }
10232    }
10233}
10234
10235/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10236#[derive(Serialize, Deserialize)]
10237#[serde(deny_unknown_fields)]
10238struct SearchPosition {
10239    version: u32,
10240    connection: SearchConnection,
10241    /// How many rows of the page `connection` starts were already handed out.
10242    #[serde(default, skip_serializing_if = "is_zero")]
10243    offset: usize,
10244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10245    seen: Vec<NativeId>,
10246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10247    own: Vec<NativeId>,
10248}
10249impl Default for SearchPosition {
10250    fn default() -> Self {
10251        Self {
10252            version: SEARCH_CURSOR_VERSION,
10253            connection: SearchConnection::Initial {},
10254            offset: 0,
10255            seen: Vec::new(),
10256            own: Vec::new(),
10257        }
10258    }
10259}
10260
10261fn is_zero(offset: &usize) -> bool {
10262    *offset == 0
10263}
10264
10265fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10266    cursor.map_or(Ok(0), |c| {
10267        c.0.parse().map_err(|_| SourceError::Config {
10268            message: "page cursor is invalid".into(),
10269        })
10270    })
10271}
10272fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10273    if offset > items.len() {
10274        return Page::last(vec![]);
10275    }
10276    let tail = items.split_off(offset);
10277    let mut selected = tail;
10278    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10279    selected.truncate(limit);
10280    Page {
10281        items: selected,
10282        next,
10283    }
10284}
10285
10286/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10287/// itself, for the two things no journey can observe.
10288///
10289/// The journeys in `crates/onetaskgraph/tests/e2e/end_command.rs` prove through the engine,
10290/// with and without the call, that a settlement, a board listing and a metadata search each
10291/// read afresh after it — the resolved records, the written-item overlay, the board and its
10292/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10293/// because a status write naming an option a person deleted is refused the same whether or
10294/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10295/// while one of its locks is held. So these assert those directly, and every other holder
10296/// beside them so a holder added later without a clear in the call fails here.
10297#[cfg(test)]
10298mod end_command_tests {
10299    use super::*;
10300
10301    struct Token;
10302
10303    impl SecretResolver for Token {
10304        fn get(&self, var: &str) -> Option<SecretString> {
10305            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10306        }
10307    }
10308
10309    fn source() -> GitHubProjectsSource {
10310        let config = serde_json::from_value(json!({
10311            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10312            // Nothing here is sent: the source is only built and its state inspected.
10313            "endpoint": "http://127.0.0.1:9/graphql",
10314        }))
10315        .expect("a usable configuration");
10316        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10317            .expect("the source builds")
10318    }
10319
10320    /// One issue as a board read answers it.
10321    fn resolved(source: &GitHubProjectsSource) -> Resolved {
10322        source
10323            .resolve(&json!({
10324                "id": "ITEM-1",
10325                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10326                            "body": "what a person may since have edited", "state": "OPEN",
10327                            "stateReason": null, "url": null, "number": 1,
10328                            "subIssuesSummary": {"total": 0},
10329                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10330                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10331            }))
10332            .expect("the item reads")
10333            .expect("an issue")
10334    }
10335
10336    /// Hold something in every holder the call clears, and the repository id it keeps.
10337    fn fill(source: &GitHubProjectsSource) {
10338        let item = resolved(source);
10339        source.created.lock().unwrap().push(item.clone());
10340        source.updated.lock().unwrap().push(item.clone());
10341        *source.board_cache.lock().unwrap() = Some(Board {
10342            id: "PVT-board".into(),
10343            fields: json!({"nodes": []}),
10344            items: vec![item.clone()],
10345        });
10346        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10347        source
10348            .narrowed_cache
10349            .lock()
10350            .unwrap()
10351            .insert("status:todo".into(), vec![item.clone()]);
10352        source
10353            .search_next
10354            .lock()
10355            .unwrap()
10356            .insert("status:todo".into(), Some("cursor".into()));
10357        source
10358            .resolved_cache
10359            .lock()
10360            .unwrap()
10361            .insert(item.id.clone(), item);
10362        *source.fields_cache.lock().unwrap() = Some(BoardFields {
10363            id: BoardId::parse("PVT-board").unwrap(),
10364            fields: json!({"nodes": []}),
10365        });
10366        source
10367            .repository_cache
10368            .lock()
10369            .unwrap()
10370            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10371    }
10372
10373    fn assert_dropped(source: &GitHubProjectsSource) {
10374        assert!(source.created().unwrap().is_empty(), "created");
10375        assert!(source.updated().unwrap().is_empty(), "updated");
10376        assert!(source.board_cache().unwrap().is_none(), "board");
10377        assert!(source.search_cache.lock().unwrap().is_none(), "search");
10378        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10379        assert!(
10380            source.search_next.lock().unwrap().is_empty(),
10381            "search paging"
10382        );
10383        assert!(
10384            source.resolved_cache().unwrap().is_empty(),
10385            "resolved records"
10386        );
10387        assert!(source.fields_cache().unwrap().is_none(), "board fields");
10388        assert_eq!(
10389            source.repository_cache().unwrap().len(),
10390            1,
10391            "a repository's node id stays valid and is kept"
10392        );
10393    }
10394
10395    fn end(source: &GitHubProjectsSource) {
10396        tokio::runtime::Builder::new_current_thread()
10397            .build()
10398            .unwrap()
10399            .block_on(source.end_command())
10400            .expect("the command ends");
10401    }
10402
10403    #[test]
10404    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10405        let source = source();
10406        fill(&source);
10407        end(&source);
10408        assert_dropped(&source);
10409    }
10410
10411    #[test]
10412    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10413        fn poison<T: Send>(held: &Mutex<T>) {
10414            std::thread::scope(|scope| {
10415                let _ = scope
10416                    .spawn(|| {
10417                        let _guard = held.lock().unwrap();
10418                        panic!("a failure while the lock is held");
10419                    })
10420                    .join();
10421            });
10422            assert!(held.is_poisoned());
10423        }
10424        let source = source();
10425        fill(&source);
10426        poison(&source.created);
10427        poison(&source.updated);
10428        poison(&source.board_cache);
10429        poison(&source.search_cache);
10430        poison(&source.narrowed_cache);
10431        poison(&source.search_next);
10432        poison(&source.resolved_cache);
10433        poison(&source.fields_cache);
10434        assert!(
10435            source.resolved_cache().is_err(),
10436            "a poisoned lock is refused before the call"
10437        );
10438        end(&source);
10439        assert_dropped(&source);
10440    }
10441}