Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
85//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
86//! option of the board's one `Status` field for a task and for a project: a bare name is
87//! the option for both kinds, `null` disables the category for both, and `{task, project}`
88//! names it per kind. A category the mapping does not mention keeps its shipped default for
89//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
90//! gets the shipped default for the kind it leaves out. Two categories one kind would read
91//! back from one option are refused as the configuration is read, ignoring case, while one
92//! option may stand for different categories of the two kinds. Writes go by the kind of the
93//! item written: a status that kind has no option for, or whose option the board lacks, is
94//! refused before any mutation, naming the source, the kind, the category and the key
95//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
96//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
97//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
98//! before selecting its option. Reads give a closed issue's reason precedence over its
99//! option, while an open issue's option decides its category through its own kind's
100//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
101//! The guarded [`GitHubProjectsSource::status_options`] and
102//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
103//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
104//! existing option id and verify the field and item assignments immediately afterwards.
105//! They ask for both kinds' options, counting a terminal category's mapped option as
106//! configured because a terminal write refuses without it. No ordinary source read or
107//! write calls that mutation, whose
108//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
109//! and a mistake destroys every item's status. A status this board cannot represent is a
110//! refusal naming the status and the instance instead.
111//!
112//! `unknown` has no shipped option because this source cannot preserve an open-ended
113//! status word: it writes an existing board option and never
114//! creates an option. An operator may map `unknown` to one existing option, in which case
115//! every unknown word lands on that option and reads back as `unknown` under the option's
116//! name. This differs from `local-md`, which writes and reads the original word itself.
117//!
118//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
119//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
120//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
121//! finished tasks were only moved to a "Done" column would read 0% complete forever.
122// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
123//!
124//! # What this source declares, field by field
125//!
126//! One verdict per field of [`Capabilities`], and what `Native` means when this source
127//! says it. *Proven* means a shared journey drives it against the real
128//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
129//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
130//! [`capabilities`](TaskSource::capabilities) from parting.
131//!
132//! | Field | Verdict |
133//! | --- | --- |
134//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
135//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
136//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
137//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
138//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
139//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
140//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
141//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
142//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
143//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
144//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
145//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
146//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
147//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
148//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
149//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
150//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
151//!
152//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
153//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
154//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
155//! and the raw bytes as the request body. The numeric repository id is read at most once
156//! per repository per source instance. A loopback API endpoint also selects the loopback
157//! uploads host; production needs no assets repository, commits or extra configuration.
158//! Every returned JSON `url` is read with the same token before a body is written: only
159//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
160//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
161//! asset and HTTP status and says the token type may not be accepted by the upload
162//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
163//! fine-grained PATs or OAuth tokens.
164//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
165//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
166//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
167//! and verified afresh, in the repository the issue already lives in on an update.
168//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
169//! Uploads take the same mutation spacing as content writes. Both uploads and verification
170//! reads wait out classified rate limits within the configured per-call retry budget,
171//! on the supplied clock; verifying reads take no mutation slot.
172//!
173//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
174//! source has documents and that its tasks have comments, both of which hold — and the three
175//! facts behind the uniform `Native` on the
176//! predicates beside it are recorded below rather than re-derived, because a reader who
177//! takes `Native` to mean *the remote service filters* will read that uniformity as a
178//! lie.
179//!
180//! First, the plugin contract defines `Support::Native` as *the source applies this
181//! predicate itself*, and says nothing about where it applies it. What the declaration
182//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
183//! — so that the engine may push it down and apply nothing of its own.
184//!
185//! Second, this source can keep that promise for every predicate at no additional API
186//! cost, because whichever of the reads below answers a query has already read every
187//! candidate that query will return before it filters anything. Filtering those items is
188//! in-process work over data already in hand.
189//!
190//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
191//! applied in process over what that question returned. A project filter has a relationship — a
192//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
193//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
194//! a search for metadata values, is the board-scoped issue search carrying the text and each
195//! value as quoted phrases; an origin is the board's own field filter over its origin field
196//! beside the same search for the id. **The text search narrows, and that is this source's
197//! declared semantics:** GitHub matches whole words where the substring rule this source and
198//! the local Markdown source confirm with would match inside one, so an item holding the text
199//! only inside a longer word is never a candidate. Every item returned does contain the text.
200//! A project query's text, and a document query's scoped to no project, is that same search
201//! and narrows on the same terms, its candidates confirmed by their kind as well.
202//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
203//! so those three are applied in process over the candidates, and a query carrying none of
204//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
205//! engine compensate for work this source has already done, and declaring `projects` native
206//! while ignoring the filter (which this source once did) silently returns another project's
207//! tasks, because the engine trusts the declaration and applies nothing locally.
208//!
209//! # The three ways this source reaches an item, and what each costs
210//!
211//! A board read is charged for what its *nested* connections could return rather than for
212//! what was asked, so one whole-board read costs the same whether the question was about
213//! one project or about all of them. That is why a question about one project is never
214//! answered by reading the board:
215//!
216//! | The question | What is sent | What it costs |
217//! | --- | --- | --- |
218//! | 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 |
219//! | 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 |
220//! | 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 |
221//! | 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 |
222//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
223//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
224//! | 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 |
225//! | 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 |
226//! | 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 |
227//! | 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 |
228//! | 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 |
229//! | 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 |
230//!
231//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
232//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
233//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
234//! equal to declared points equal to the row. They include the origin lookup and the
235//! field/repository discovery a create needs. A bound re-copy changes status, priority,
236//! content and metadata; comment recount means a subsequent detail read. Each request here
237//! costs one declared point. A membership beyond the embedded page can additionally require
238//! the one-point membership recovery described above. A bound re-copy of a task filed under a
239//! project adds one read, the engine confirming that project's link by its own id once per
240//! command; and the same-source far ends a write newly names — those that do not already block
241//! the item, whose own read answered for them — are read together by their own ids,
242//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
243//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
244//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
245//!
246//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
247//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
248//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
249//! holds both halves.
250//!
251//! **An existing item is written body last.** A bound re-copy and a `task update` send its
252//! board fields first — the `Status` option and the `Priority` together, in one request — then
253//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
254//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
255//! undoing an earlier field when a later one fails, so that order is what makes a write
256//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
257//! the one piece of metadata written before the body, an origin a copy re-points, is put back
258//! when a later write is refused — and when putting it back is refused too, the write's own
259//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
260//! of those writes in turn, whole and as one aliased field failing after the one before it.
261//!
262//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
263//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
264//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
265//!
266//! - **A board is accepted at creation but its item is not answered, so a create still files
267//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
268//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
269//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
270//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
271//!   against a real board on 2026-10-01 with a create sending the board there and reading the
272//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
273//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
274//!   refused "Content already exists in this project" — GitHub had filed the issue after
275//!   answering, and refuses a second filing rather than answering with the item it holds. A
276//!   create therefore sends no `projectV2Ids` and files the issue with
277//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
278//!   real is the read before it: the board's fields and the repository's id together, in
279//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
280//! - **A comment still reads its target first, so a comment is 2 requests.**
281//!   `AddCommentInput.subjectId: ID!` is declared there with
282//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
283//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
284//!   project's issue, a document's issue, an issue on no board of this source and a pull
285//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
286//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
287//!   refuses those by name.
288//!
289//! | Verb | Requests / points | Documents |
290//! | --- | --- | --- |
291//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
292//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
293//! | 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 |
294//! | 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 |
295//! | 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 |
296//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
297//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
298//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
299//! | recount | 1 | ISSUE_DETAIL |
300//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
301//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
302//! | content | 2 | ISSUE, UPDATE_ISSUE |
303//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
304//! | 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 |
305//! | record only | 1 | ISSUE |
306//!
307//! <!-- github-search-paging:start -->
308//! Board-scoped text, metadata, project-name and comment-activity searches send every
309//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
310//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
311//! needs rows. A page is never resized to the rows still needed: GitHub orders one
312//! search differently at different page sizes, so one fixed size makes a paged walk
313//! send exactly the requests one whole read sends, and the answer's order is the order
314//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
315//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
316//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
317//! returned and fetched pages: a limit is sliced from the pages it needs, and local
318//! confirmation can require more candidates than matching rows. Walking all pages
319//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
320//! cursor and how far into that page the last answer stopped, and resumes in the same
321//! process or a new one, without duplicates or gaps. It carries no rows: one process
322//! sends each page's search once, and a new process re-reads only the page it resumes
323//! in, then sends a further page once, never as a re-read, only when its limit still
324//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
325//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
326//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
327//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
328//! not required to include the original process's writes still omitted by the index.
329//! <!-- github-search-paging:end -->
330//!
331//! The board half of an issue — its board item's id, its `Status` option and this
332//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
333//! an item reached any of those ways resolves through the same
334//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
335//! same status, the same labels and the same qualified id. That connection comes back a
336//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
337//! on the page in hand and — only if that page reports more of the connection — in the
338//! last row's read of that one issue's memberships, resumed from the page's own cursor and
339//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
340//! report, which is what keeps an id naming another repository's issue from being answered
341//! as an item of this board; and because the page is where the search starts rather than
342//! where it ends, that answer is one about a connection read to exhaustion and never about
343//! an unread page. Nothing costs the extra read but an issue on more boards than a page
344//! holds: an issue this board really does not hold reports no next page, so its
345//! memberships are already exhausted where they arrived.
346//!
347//! **No document here selects the board's own `Labels` field, and nothing is lost by
348//! that.** An item's labels are read from its content alone, wherever that content is
349//! reached: the three documents above select `Issue.labels` on the fragment, and
350//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
351//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
352//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
353//! create one, and `ProjectV2FieldValue` — the whole of what
354//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
355//! it from the content, for every content type it exists on, and there is nothing it can
356//! hold that the content does not already say: for an `Issue` it *is* that issue's own
357//! labels, so selecting it beside them unions a set with itself.
358//!
359//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
360//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
361//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
362//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
363//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
364//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
365//! label set, and that set is the fixture issue's own, by
366//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
367//! item whose content is a draft reports an empty set, by
368//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
369//! selection is held over [`graphql::DOCUMENTS`] by
370//! `no_document_selects_the_boards_own_labels_field`.
371//!
372//! The whole-board row is still the board's own item connection, and deliberately: a
373//! **draft** board item is not an issue, so no search can list one, and the reads that have
374//! to answer for the whole board are the ones whose cost is the board's size anyway.
375//!
376//! **A question about one item this source already names by id never lists the board.**
377//! Whether that item is on this board, and what its board fields are, is answered by reading
378//! that item — its own `Issue.projectItems`, walked to exhaustion by
379//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
380//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
381//! holds. That covers a write's destination, the project a new item is filed under, a
382//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
383//! and the delete that takes back an item a copy made. What such a write needs of the board
384//! and the item does not carry — the board's id, the `Status` and origin field definitions —
385//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
386//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
387//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
388//! minutes. Scanning this host's 842-item board has refused a document copy and an update
389//! even though the items' own reads named that board. A scan there gives the wrong answer
390//! as well as paying for every page. So a `board.items` lookup does not belong on any of
391//! those paths.
392//!
393//! **What a read may return is capped too, and that cap is on the document rather than on
394//! the board.** GitHub limits the number of nodes **one query may return** to
395//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
396//! an error naming the connection the count crossed at, not a slow or a partial result.
397//! Every board this source reads is refused the same way, so no board is too big for these
398//! documents and none is small enough to save one that is over.
399//!
400//! The count is arithmetic over the document's own text: each connection contributes the
401//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
402//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
403//! restate them — `github-graphql-node-count` implements them, and
404//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
405//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
406//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
407//! same text on every run and fails naming any that reaches the limit — so a connection
408//! added to a shared fragment is caught there rather than by GitHub.
409//!
410//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
411//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
412//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
413//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
414//! effectively squared there, which is why it is the one the limit is most sensitive to.
415//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
416//! page of memberships misses is recovered by one further read rather than refused, so it
417//! buys a bound every read pays for at the price of a request only a multi-board issue
418//! pays.
419//!
420//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
421//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
422//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
423//! `cost` is rate-limit points, metered per hour across everything one credential does; it
424//! is what the two limiters [`Limiter`] tells apart meter, and a document under
425//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
426//! that second number, and `tests/point_cost.rs` pins every document in
427//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
428//! one under, the pin itself is the check. The credentialed lane reconciles both figures
429//! against GitHub's own, off a probe it already sends.
430//!
431//! **What is pinned that way is a per-document price and never a session's.** The record in
432//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
433//! **requests** and **worst-case nodes** — and neither is points. What one whole session
434//! consumes of the hourly point allowance is observable only from a credentialed run's own
435//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
436//! and what `tests/live.rs` prints at the end of every run.
437//!
438//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
439//!
440//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
441//! enumerations of a board can supply one alone.** Resolving a node id is strongly
442//! consistent, so a read by id and a project's own sub-issues are already current. The
443//! other two are not, and they are behind by different amounts and in different directions:
444//!
445//! - GitHub's **issue search** is an index and answers a write made moments ago with the
446//!   value from before it — usually for a second or two.
447//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
448//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
449//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
450//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
451//!
452//! That second one is a measurement rather than a caution. This repository's own
453//! credentialed journey writes a project and waits for the board to report it, then writes a
454//! task and waits for the same thing seconds later on the same board: the project wait is
455//! answered through the search and converged in two or three attempts in each of three runs,
456//! and the task wait is answered through `ProjectV2.items` and converged in none of them
457//! inside thirty. Separately, an item added to a second and larger board was read back by
458//! `Issue.projectItems` on that board's own id while every one of that connection's nine
459//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
460//! through the lagging one alone is what had a board read deny an issue that had certainly
461//! landed on it.
462//!
463//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
464//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
465//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
466//! search reports what the projection is behind on. What closes the last
467//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
468//! source answers is completed with what this process itself wrote, so an item created
469//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
470//! nothing is written down, and the record dies with the process. **A wait that has to
471//! observe GitHub's own data cannot be answered from that record** — which is why the
472//! credentialed journey asks through a source built afresh, and why the union above rather
473//! than a longer wait is what makes such a wait converge.
474//!
475//! **A narrowed read is the same bargain, stated for each of the three predicates it
476//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
477//! than walking the board, and every such answer is completed with what this process wrote —
478//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
479//! each filtered by the same predicates as the rest — so an item this command wrote a moment
480//! ago is returned by a query that matches it whether or not the index has caught up. An item
481//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
482//! consistent. What is left is stated rather than papered over:
483//!
484//! | Read | Finds | Behind by |
485//! | --- | --- | --- |
486//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
487//! | 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 |
488//! | 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 |
489//! | origin, third read | this process's own writes | nothing |
490//!
491//! So an origin carrier another process added within the last second or two, before either
492//! index has it, can be missing from an origin query, and one written by the release before
493//! this one — its origin in the field alone — can be missing for as long as the board's own
494//! item connection is behind on it. A copy that must not duplicate its own earlier write
495//! relies on the link it records, not on either index. **A board draft is not an issue**, so
496//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
497//! lists one, the origin lookup drops any the board's own field filter names, and one this
498//! process wrote is not added back either.
499//!
500//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
501//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
502//! body's metadata slot, so the issue search can find it in seconds. The field is
503//! authoritative: this source reads an item's origin from the field alone, so a slot that
504//! disagrees with it, or holds one where the field holds none, is never read as a second
505//! origin — and the release before this one reads the slot, drops that key's copy for the
506//! field's, and sees the same one origin.
507//!
508//! Filtering happens before paging, so a page of a filtered result is a page of the
509//! survivors rather than the survivors of a page. Label matching and the substring rule a
510//! text candidate is confirmed by answer the same question the same way the local Markdown
511//! source's do; which candidates a text search has to confirm is GitHub's word match, which
512//! is the one place the two sources can answer the same text differently.
513//!
514//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
515//! has one source, `capabilities`, and the note above is the reasoning behind it rather
516//! than a second copy of it: without the three facts recorded here a reader takes the
517//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
518//! crate's own capabilities test, which pins every field of it against a fully spelled-out
519//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
520//! fails to compile there rather than going unasserted. -->
521//! The fixture-server tests above run wherever this crate is selected; the credentialed
522//! lane runs in the same required check, beside them, and can fail it — it verifies the
523//! 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,
524//! one filed under neither, a label on one of the three and a closed status on another —
525//! because that shape is what tells an honoured predicate from an ignored one: a board
526//! holding a single project answers a project filter the same way whether or not this
527//! source applies it, which is exactly how the defect above went unseen.
528//!
529//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
530//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
531//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
532//! session or request, independently of the live demand. Fix that variable in the machine's
533//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
534//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
535//! policy crate; absent credentials or nominations otherwise skip.
536//!
537//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
538//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
539//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
540//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
541//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
542//! after its run reads back as `completed`, immediately before the listed-artifact batch.
543//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
544//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
545//! and the board's `onetaskgraph.origin` field untouched.
546//!
547//! # What a session of requests costs, and where the report is
548//!
549//! This source records **every** request it sends into [`accounting::Accounting`], at
550//! `send_once` — the one place a request leaves this crate, which is why a read path added
551//! later is counted without anybody remembering to count it. That is the whole of what this
552//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
553//! session's spend is arrived at, and what it deliberately does not know are set out.
554//!
555//! What one whole session of the live journey costs, counted that way against this crate's
556//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
557//! reduction it came out of, and with what it does and does not say about rate-limit points.
558//!
559//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
560//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
561//! code path — no environment variable, no feature, no build configuration — because an
562//! instrument nobody switches on measures nothing, and
563//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
564//! source's counts the whole session rather than this source's share. The credentialed lane
565//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
566//! passed or failed.
567//!
568//! **A live session refuses to start unless the account can afford it.** Before it does any
569//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
570//! GitHub documents as not counting against the REST rate limit and which answers both of
571//! its budgets at once — and starts only if, for each of them, what remains minus this
572//! session's estimated cost is still at least
573//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
574//! allowance. A session that cannot **declines**: it did not run, so it is
575//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
576//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
577//! waiting for the budget to come back. The estimate is derived offline from
578//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
579//! which is also where the published rule that model rests on is cited; the accounting
580//! above records the gate's own read like any other request, and
581//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
582//!
583//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
584//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
585//! text, which is what lets it run on every platform and on a pull request from a fork with
586//! no credential — and that is what actually stops a regression merging. But an offline
587//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
588//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
589//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
590//! number of nodes this query may return"* and whose `cost` is what that document would
591//! spend, both for a document **without executing it**, and the lane asks it for every query
592//! document this source sends, under the largest bindings this source sends, and fails when
593//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
594//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
595//! one; the offline pins still cover it. It records what those calls reported about the
596//! account's own allowance, because whether asking is free is a thing to observe rather than
597//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
598//! and `cost` is metered against an hourly allowance the accounting above reads off a
599//! credentialed run's own response headers.
600//!
601//! **GitHub has two rate limiters and this source is refused by both, so nothing here
602//! treats them as one thing.** The primary budget is the hourly allowance `gh api
603//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
604//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
605//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
606//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
607//! one part of.
608#![deny(missing_docs)]
609
610use std::collections::BTreeMap;
611use std::sync::{Arc, Mutex};
612use std::time::Duration;
613
614use chrono::{DateTime, Utc};
615use onetaskgraph_plugin_api::{
616    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
617    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
618    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
619    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SharedClock,
620    SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task,
621    TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields,
622    TextQuery, UnmappedStatus, UpdatedField, WriteSupport, system_clock,
623};
624use reqwest::{Client, StatusCode, Url};
625use schemars::{Schema, schema_for};
626use secrecy::{ExposeSecret, SecretString};
627use serde::{Deserialize, Serialize};
628use serde_json::{Value, json};
629
630pub mod accounting;
631mod assets;
632
633use accounting::Accounting;
634
635/// The registry name for this plugin.
636pub const KIND: &str = "github-projects";
637/// GitHub's maximum connection page size.
638pub const MAX_PAGE_SIZE: u32 = 100;
639/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
640/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
641/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
642pub const SEARCH_PAGE_SIZE: u32 = 20;
643/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
644/// its comments: the largest batch the node-count model prices at one point.
645///
646/// Each aliased item is resolved once, and what GitHub charges for it is the connections
647/// under it — its labels, its page of board memberships, the field values of each of those
648/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
649/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
650/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
651/// one point and fails if one item more would still be priced at one.
652pub const DETAIL_BATCH: usize = 24;
653
654/// The most nodes any one document this source sends may be asked to return.
655///
656/// GitHub's own published per-query ceiling, taken from
657/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
658/// workspace cannot hold a stale copy of somebody else's number. A query above it is
659/// **refused before it is executed**, whoever is asking and whatever board they are
660/// asking about — so this is a bound on the documents rather than a budget that runs out.
661///
662/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
663/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
664/// everything the credential does — two numbers against two limits, and this constant
665/// bounds only the first. The second is computed offline too, per document:
666/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
667/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
668/// lane. There is no constant like this one to hold a price under, because points are an
669/// hourly allowance rather than a per-call bound.
670///
671/// Neither is a session's price. What `session-cost.md` records of a whole session is its
672/// **requests** and its **worst-case nodes**; what a whole session spends in points is
673/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
674/// [`accounting`]. The module section on the three ways this source reaches an item says how
675/// the count is arrived at, and which of the page sizes below decide it.
676pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
677
678/// Nested connection size for the connections that hang off one item.
679///
680/// It multiplies through every document that reaches an item under a page — the count
681/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
682/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
683/// every document under these constants and fails naming any that reaches the limit, so
684/// raising this is caught there rather than by GitHub.
685const NESTED_PAGE_SIZE: u32 = 50;
686/// How many of one issue's board memberships are read when an issue is reached directly.
687///
688/// An issue reached through a search or through its own node id carries its board half in
689/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
690/// under a page of issues, so every point of it multiplies through the whole document and
691/// is paid for whether or not any issue is on a second board — which is why it is
692/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
693///
694/// **Three, because what a page misses is now recovered rather than refused**, and the
695/// recovery is what the value is chosen against. An issue whose entry for this board sits
696/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
697/// that page's own cursor — so the value trades a bound every read pays for a request only
698/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
699/// boards would pay that request *per issue*, which is order N against the one page per
700/// hundred issues a read costs today. At three it is only reached by an issue on four or
701/// more boards at once, which keeps the recovery path exceptional rather than routine for
702/// a plausible deployment.
703const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
704/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
705/// its two connections for.
706///
707/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
708/// second is a duplicate a copy already takes the first of. Both connections are walked to
709/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
710/// never what the answer is. It is small because every point of it is paid on every lookup,
711/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
712/// worst-case nodes than the one whole-board read they replaced.
713const ORIGIN_PAGE_SIZE: u32 = 3;
714
715pub use github_graphql_node_count::{NodeCountError, Variables};
716
717/// The largest value this source can bind to each page-size variable its documents name.
718///
719/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
720/// constant above it wherever a caller's own limit could reach it — `$first` at
721/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
722/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
723/// not one configuration of it, which is what makes a bound computed under it a bound on
724/// every read.
725pub fn largest_page_sizes() -> Variables {
726    Variables::from([
727        ("first".to_owned(), MAX_PAGE_SIZE),
728        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
729        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
730        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
731    ])
732}
733
734/// The most nodes `document` could be asked to return, by GitHub's published rules.
735///
736/// Computed offline from the document's own text under [`largest_page_sizes`] — no
737/// network, no credential and no schema — by
738/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
739/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
740/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
741///
742/// # Errors
743///
744/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
745/// no single operation, or binds a page size this source does not name — each of which is
746/// a defect in the document rather than a number.
747pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
748    node_count(document, &largest_page_sizes())
749}
750
751/// The most rate-limit points one call of `document` could spend, by GitHub's published
752/// rules.
753///
754/// Computed offline from the document's own text under [`largest_page_sizes`] — no
755/// network, no credential and no schema — by
756/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
757/// This is `cost`, metered **per hour** against the allowance one credential shares across
758/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
759/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
760/// under, so what `tests/point_cost.rs` does with it is pin every document in
761/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
762/// figures against GitHub's own reported `cost`.
763///
764/// # Errors
765///
766/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
767/// no single operation, or binds a page size this source does not name — each of which is
768/// a defect in the document rather than a number.
769pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
770    github_graphql_node_count::point_cost(document, &largest_page_sizes())
771}
772
773/// The most nodes `document` could be asked to return under `variables`.
774///
775/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
776/// [`accounting`] is this under the bindings one request really sent — one spelling of the
777/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
778/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
779///
780/// # Errors
781///
782/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
783/// no single operation, or binds a page size `variables` does not name.
784pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
785    github_graphql_node_count::node_count(document, variables)
786}
787
788/// The issue-title prefix that makes a board issue a document.
789///
790/// A GitHub Projects board has no document type — it holds issues — so the discriminator
791/// is the title, and this is the whole of it: an issue whose title begins with these bytes
792/// is a document and every other issue is the task or project the sub-issue rule makes it.
793///
794/// It is spelled **once**, here, and read rather than restated everywhere else — including
795/// by the shared journeys, which take it from this constant so a board fixture cannot
796/// drift from what this source reads. `docs/metadata.md` records the two consequences that
797/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
798/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
799/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
800pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
801
802/// Exact GraphQL query documents issued by this plugin.
803///
804/// Keeping the production documents here lets the pinned-schema test validate the same
805/// bytes that are sent to GitHub, rather than a test-only copy which could drift
806/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
807/// field, and its guarded caller always supplies the complete existing option set with ids.
808pub mod graphql {
809    /// The board half of one item: the field values every document here reads it from.
810    ///
811    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
812    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
813    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
814    /// *the same value*, because
815    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
816    /// one path. Three spellings of it is what would drift, so there is one.
817    ///
818    /// The `Status` option and this source's own origin text field are the whole of it. It
819    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
820    /// content, so it holds nothing the content's own `labels` do not already say, and it
821    /// would sit a label connection two page sizes deep.
822    macro_rules! board_item_values {
823        () => {
824            r#"fieldValues(first:$nestedFirst){nodes{
825          ... on ProjectV2ItemFieldSingleSelectValue{name field{
826            ... on ProjectV2SingleSelectField{id name options{id name}}
827          }}
828          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
829        }pageInfo{hasNextPage}}"#
830        };
831    }
832
833    /// Everything this source reads about one issue, wherever it reaches that issue.
834    ///
835    /// A macro rather than a constant so the three documents below can `concat!` it: one
836    /// spelling of these fields is what makes an issue read through the board-scoped
837    /// search, through its own node id, and through its project's sub-issue relationship
838    /// resolve to *the same* item, which is the whole of what
839    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
840    ///
841    /// `projectItems` is what carries the board half of an issue: the board item's own id
842    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
843    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
844    /// issue rather than on the board, which is what makes the cost of a read proportional
845    /// to what was asked for instead of to the board's size.
846    ///
847    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
848    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
849    /// not on that page: a page here is where the search for the entry starts rather than
850    /// where it ends.
851    ///
852    /// It does **not** select the board's `Labels` field value, and that is the whole of
853    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
854    /// a label connection there sits under `fieldValues` under `projectItems` under a page
855    /// of issues, spending `$nestedFirst` twice down one path, and took
856    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
857    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
858    /// above, and that connection is where every label this source reports comes from. No
859    /// document in this module selects the board field any longer, [`BOARD`] included; the
860    /// module documentation records why nothing it could have held is lost.
861    macro_rules! board_issue {
862        () => {
863            concat!(
864                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
865      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
866      projectItems(first:$boardItems){nodes{id project{id number}
867        "#,
868                board_item_values!(),
869                r#"}pageInfo{hasNextPage endCursor}}}"#
870            )
871        };
872    }
873
874    /// Every issue of one board, found by a search scoped to that board.
875    ///
876    /// This is how the projects a board holds are listed, and it selects no `items`
877    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
878    /// container walked page by page, so nothing nested inside a board item is paid for.
879    /// Which of the issues it returns is a project is then read off `parent` — GitHub
880    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
881    /// discriminator has to be applied to the field, which is a scalar on the issue and
882    /// costs nothing.
883    pub const SEARCH_ISSUES: &str = concat!(
884        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
885      search(query:$search,type:$type,first:$first,after:$after){
886        pageInfo{hasNextPage endCursor}
887        nodes{__typename ...BoardIssue}
888      }
889    }"#,
890        board_issue!()
891    );
892
893    /// What a dependency read selects of each far end: enough to say which kind of item it
894    /// is, its body included for the kind marker.
895    macro_rules! related_issue {
896        () => {
897            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
898        };
899    }
900
901    /// One issue by its own node id, which is what a qualified id names here — with what a
902    /// write of it needs and the issue does not carry in `board_issue!`: the field
903    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
904    ///
905    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
906    /// answers a write made moments ago with the value from before it, and resolving a node
907    /// id does not.
908    ///
909    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
910    /// reads it by its own id, and with them that one read answers everything the write
911    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
912    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
913    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
914    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
915    /// they sit under one item, and this read is still one point.
916    pub const ISSUE: &str = concat!(
917        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
918      node(id:$id){__typename ...BoardIssue ... on Issue{
919        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
920          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
921          ... on ProjectV2Field{__typename id name}
922        }pageInfo{hasNextPage}}}}}
923        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
924      }}
925    }"#,
926        board_issue!(),
927        related_issue!()
928    );
929
930    /// One project's tasks: the sub-issues of the issue that project is.
931    ///
932    /// The work this costs is the project's own size. Nothing about it grows as the board
933    /// gains projects, or as those projects gain tasks.
934    pub const SUB_ISSUES: &str = concat!(
935        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
936      node(id:$id){__typename
937        ... on Issue{subIssues(first:$first,after:$after){
938          pageInfo{hasNextPage endCursor}
939          nodes{__typename ...BoardIssue}
940        }}}
941    }"#,
942        board_issue!()
943    );
944
945    /// What a read of the board's own `items` selects of each item's content.
946    ///
947    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
948    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
949    /// content by one spelling.
950    macro_rules! board_item_content {
951        () => {
952            r#" content{
953        ... 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}}}
954        ... on PullRequest{__typename id}
955        ... on DraftIssue{__typename id title body createdAt updatedAt}
956      }"#
957        };
958    }
959
960    /// Reads the board's fields and one page of its items.
961    pub const BOARD: &str = concat!(
962        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
963      owner:repositoryOwner(login:$owner){
964        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
965      }
966    } fragment Board on ProjectV2 { id title
967      fields(first:$nestedFirst){nodes{
968        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
969        ... on ProjectV2Field{__typename id name}
970      }pageInfo{hasNextPage}}
971      items(first:$first,after:$after){nodes{id "#,
972        board_item_values!(),
973        board_item_content!(),
974        r#"} pageInfo{hasNextPage endCursor}}
975    }"#
976    );
977
978    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
979    /// the board.
980    ///
981    /// **`originItems`** is the board's own items narrowed by its own field filter —
982    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
983    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
984    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
985    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
986    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
987    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
988    /// fresh `addProjectV2ItemById` the way that connection does.
989    ///
990    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
991    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
992    /// indexes that comment, and the index catches up with a write in a second or two rather
993    /// than in minutes, so it finds a carrier another process wrote that the first read is
994    /// still behind on.
995    ///
996    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
997    /// — and resumes from its own cursor; a connection already walked to its end is resumed
998    /// from its last cursor, which answers an empty page. Every candidate either read returns
999    /// is confirmed against its own origin field before it is reported, so a token match of
1000    /// the search or anything else the filter admits never is.
1001    ///
1002    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1003    /// own whole reads counts this one among them.
1004    pub const ORIGIN_LOOKUP: &str = concat!(
1005        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1006      originItems:repositoryOwner(login:$owner){
1007        ... on ProjectV2Owner{projectV2(number:$number){
1008          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
1009        board_item_values!(),
1010        board_item_content!(),
1011        r#"} pageInfo{hasNextPage endCursor}}
1012        }}
1013      }
1014      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1015        pageInfo{hasNextPage endCursor}
1016        nodes{__typename ...BoardIssue}
1017      }
1018    }"#,
1019        board_issue!()
1020    );
1021
1022    /// The board's own id and field definitions, and not one of its items.
1023    ///
1024    /// What a write needs of the board when the item it writes does not say: the id a field
1025    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1026    /// origin fields. It selects no `items`, so what it costs is the board's field list
1027    /// however many items the board holds — and it decides nothing about which items those
1028    /// are, which is the question a read of one item by its own id answers instead.
1029    ///
1030    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1031    /// board's item reads by their root counts this one among them.
1032    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1033      boardFields:repositoryOwner(login:$owner){
1034        ... on ProjectV2Owner{projectV2(number:$number){id
1035          fields(first:$nestedFirst){nodes{
1036            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1037            ... on ProjectV2Field{__typename id name}
1038          }pageInfo{hasNextPage}}
1039        }}
1040      }
1041    }"#;
1042
1043    /// One board draft by its own node id, with the board item it sits in.
1044    ///
1045    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1046    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1047    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1048    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1049    /// board listing hands it to, and nothing has to list the board to find one.
1050    pub const DRAFT: &str = concat!(
1051        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1052      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1053        projectV2Items(first:$boardItems){nodes{id project{id number}
1054        "#,
1055        board_item_values!(),
1056        r#"}pageInfo{hasNextPage endCursor}}}}
1057    }"#
1058    );
1059
1060    /// One issue's board memberships alone, walked past the page a read of it carried.
1061    ///
1062    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1063    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1064    /// boards than that page holds may have this board's entry past its end. This asks that
1065    /// one issue for its memberships and nothing else — the caller already holds the issue —
1066    /// so an answer of "this board does not hold it" is only ever given about a connection
1067    /// read to exhaustion.
1068    ///
1069    /// It selects the board item's id, its project number and the same
1070    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1071    /// very same resolver: an issue recovered this way reports the same title, the same
1072    /// status, the same labels and the same qualified id as one whose entry was on the
1073    /// page.
1074    ///
1075    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1076    /// multiplies through it and the membership connection can be walked at
1077    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1078    /// further request for any issue a person really keeps.
1079    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1080        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1081      node(id:$id){
1082        ... on Issue{projectItems(first:$first,after:$after){
1083          nodes{id project{id number}
1084        "#,
1085        board_item_values!(),
1086        r#"}
1087          pageInfo{hasNextPage endCursor}}}
1088      }
1089    }"#
1090    );
1091    /// Resolves the configured repository's node id, which creating an issue requires.
1092    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1093    /// What creating an issue needs and has not read yet: the board's own id and field
1094    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1095    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1096    ///
1097    /// Sent at the point a create knows which repository it is for, when neither half is
1098    /// already known to this process; a create needing only one of them sends that one's own
1099    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1100    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1101    /// status.
1102    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1103      boardFields:repositoryOwner(login:$owner){
1104        ... on ProjectV2Owner{projectV2(number:$number){id
1105          fields(first:$nestedFirst){nodes{
1106            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1107            ... on ProjectV2Field{__typename id name}
1108          }pageInfo{hasNextPage}}
1109        }}
1110      }
1111      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1112    }"#;
1113    /// Reads both dependency directions for one issue, with each far end's own kind — and
1114    /// the issue's own body, which is where an edge to another source is recorded, so that
1115    /// half of a dependency read needs no second read of the issue or of the board.
1116    pub const ISSUE_DEPENDENCIES: &str = concat!(
1117        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1118      ... on Issue{body
1119        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1120        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1121      }}}"#,
1122        related_issue!()
1123    );
1124    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1125    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1126    /// answered when it was.
1127    pub const CREATE_ISSUE: &str =
1128        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1129    /// Puts an existing issue on the configured board.
1130    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1131    /// Updates an issue's visible fields and its open or closed state in one call.
1132    pub const UPDATE_ISSUE: &str =
1133        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1134    /// Updates an existing draft's user-visible fields.
1135    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1136    /// Updates a text or single-select value on one project item.
1137    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}}}}}}}}"#;
1138    /// Writes up to three board fields and an optional clear in one ordered mutation.
1139    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}}}"#;
1140    /// Clears one project item's value of one field, which is what a `none` priority is.
1141    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}}}}}}}}"#;
1142    /// Creates one single-select field with its options. Only the guarded field setup may use
1143    /// this document, and only for a field the board lacks.
1144    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1145    /// Replaces a single-select field's options. Only the guarded field setup — the
1146    /// `status-options` and `fields` operations — may use this document, because GitHub
1147    /// treats the input as the complete option list.
1148    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1149    /// A fresh snapshot of the Status field and every board item's assignment.
1150    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}}}}}}"#;
1151    /// Files one issue under another as a sub-issue, which is what project membership is.
1152    pub const ADD_SUB_ISSUE: &str =
1153        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1154    /// Takes one issue back out of its parent.
1155    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1156    /// Adds GitHub's native issue blocked-by relationship.
1157    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1158    /// Removes one native issue blocked-by relationship.
1159    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1160    /// Deletes one issue, which takes its board item with it.
1161    ///
1162    /// The engine sends this in one situation only: undoing a copy that could not finish,
1163    /// over the items that same copy created. Deleting the issue removes the board item
1164    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1165    pub const DELETE_ISSUE: &str =
1166        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1167
1168    /// Everything this source reads about one issue comment, wherever it reaches one.
1169    ///
1170    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1171    /// and a comment just edited are handed to one mapper, so they are selected by one
1172    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1173    /// longer exists, and `login` is the one member every kind of actor carries.
1174    macro_rules! issue_comment {
1175        () => {
1176            "id author{login} createdAt updatedAt body url"
1177        };
1178    }
1179
1180    /// One task's comments: a page of its issue's own `comments` connection.
1181    ///
1182    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1183    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1184    /// list every time somebody edited it; left unordered the connection answers in the order
1185    /// the comments were written, which is the order GitHub documents for the same collection
1186    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1187    /// node count and the caller's own page size is pushed straight down.
1188    pub const ISSUE_COMMENTS: &str = concat!(
1189        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1190        issue_comment!(),
1191        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1192    );
1193    /// One issue by its own node id, with a page of its comments: what `task show` and a
1194    /// comment listing read, in one request.
1195    ///
1196    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1197    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1198    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1199    /// connection there would multiply through both of those documents' price, and neither
1200    /// needs one.
1201    pub const ISSUE_DETAIL: &str = concat!(
1202        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1203      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1204        issue_comment!(),
1205        r#"}pageInfo{hasNextPage endCursor}}}}
1206    }"#,
1207        board_issue!()
1208    );
1209
1210    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1211    /// page of its comments when `$comments` asks for them.
1212    macro_rules! issue_details_alias {
1213        ($n:literal) => {
1214            concat!(
1215                "\n      i",
1216                stringify!($n),
1217                ":node(id:$id",
1218                stringify!($n),
1219                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1220                issue_comment!(),
1221                "}pageInfo{hasNextPage endCursor}}}}"
1222            )
1223        };
1224    }
1225
1226    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1227    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1228    ///
1229    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1230    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1231    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1232    /// neither — so every connection under it would be priced at nothing and the pin in
1233    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1234    /// one-item read the model already prices, so the batch costs what its aliases cost.
1235    ///
1236    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1237    /// slots it has no item for to the last item it does, and reads that item again; the
1238    /// price is the document's, whatever its variables, so a short batch costs what a full
1239    /// one does and nothing more.
1240    pub const ISSUE_DETAILS: &str = concat!(
1241        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!){"#,
1242        issue_details_alias!(0),
1243        issue_details_alias!(1),
1244        issue_details_alias!(2),
1245        issue_details_alias!(3),
1246        issue_details_alias!(4),
1247        issue_details_alias!(5),
1248        issue_details_alias!(6),
1249        issue_details_alias!(7),
1250        issue_details_alias!(8),
1251        issue_details_alias!(9),
1252        issue_details_alias!(10),
1253        issue_details_alias!(11),
1254        issue_details_alias!(12),
1255        issue_details_alias!(13),
1256        issue_details_alias!(14),
1257        issue_details_alias!(15),
1258        issue_details_alias!(16),
1259        issue_details_alias!(17),
1260        issue_details_alias!(18),
1261        issue_details_alias!(19),
1262        issue_details_alias!(20),
1263        issue_details_alias!(21),
1264        issue_details_alias!(22),
1265        issue_details_alias!(23),
1266        "\n    }",
1267        board_issue!()
1268    );
1269
1270    /// Which issue one comment is on, read before that comment is edited or removed.
1271    ///
1272    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1273    /// comment id given against the wrong task would change a comment on another issue.
1274    pub const COMMENT_ISSUE: &str =
1275        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1276    /// Adds one comment to an issue, signed as the account the token belongs to.
1277    pub const ADD_COMMENT: &str = concat!(
1278        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1279        issue_comment!(),
1280        r#"}}}}"#
1281    );
1282    /// Replaces the body of one issue comment.
1283    pub const UPDATE_COMMENT: &str = concat!(
1284        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1285        issue_comment!(),
1286        r#"}}}"#
1287    );
1288    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1289    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1290
1291    /// Every document above, with what this source is doing when it sends one.
1292    ///
1293    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1294    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1295    /// document added later with "talking to GitHub" and never say so.
1296    ///
1297    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1298    /// const` here that this list omits, so the two cannot part — which is the same guard
1299    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1300    pub const DOCUMENTS: [(&str, &str); 33] = [
1301        (SEARCH_ISSUES, "searching this board's issues"),
1302        (ISSUE, "reading one issue"),
1303        (
1304            ISSUE_BOARD_ITEMS,
1305            "reading one issue's board memberships past the page it came with",
1306        ),
1307        (SUB_ISSUES, "reading a project's tasks"),
1308        (BOARD, "reading the board"),
1309        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1310        (BOARD_FIELDS, "reading the board's fields"),
1311        (DRAFT, "reading one draft"),
1312        (REPOSITORY, "reading the destination repository"),
1313        (
1314            CREATION_CONTEXT,
1315            "reading the board's fields and the destination repository",
1316        ),
1317        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1318        (CREATE_ISSUE, "creating an issue"),
1319        (ADD_TO_BOARD, "adding an issue to the board"),
1320        (UPDATE_ISSUE, "updating an issue"),
1321        (UPDATE_DRAFT, "updating a draft item"),
1322        (UPDATE_FIELD, "writing a board field"),
1323        (UPDATE_FIELDS, "writing board fields together"),
1324        (CLEAR_FIELD, "clearing a board field"),
1325        (
1326            CREATE_FIELD,
1327            "creating a board single-select field with its options",
1328        ),
1329        (
1330            STATUS_OPTIONS_SNAPSHOT,
1331            "snapshotting board Status options and assignments",
1332        ),
1333        (
1334            STATUS_OPTIONS_UPDATE,
1335            "safely replacing the board Status option list",
1336        ),
1337        (ADD_SUB_ISSUE, "filing an issue under its project"),
1338        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1339        (ADD_BLOCKED_BY, "recording a dependency"),
1340        (REMOVE_BLOCKED_BY, "removing a dependency"),
1341        (DELETE_ISSUE, "deleting an issue"),
1342        (ISSUE_COMMENTS, "reading a task's comments"),
1343        (ISSUE_DETAIL, "reading one issue with its comments"),
1344        (
1345            ISSUE_DETAILS,
1346            "reading a batch of issues with their comments",
1347        ),
1348        (COMMENT_ISSUE, "reading which issue a comment is on"),
1349        (ADD_COMMENT, "adding a comment"),
1350        (UPDATE_COMMENT, "editing a comment"),
1351        (DELETE_COMMENT, "deleting a comment"),
1352    ];
1353}
1354
1355/// Which of GitHub's two rate limiters refused a request.
1356///
1357/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1358/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1359/// the whole reason this is carried rather than collapsed into "rate limited".
1360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361enum Limiter {
1362    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1363    Primary,
1364    /// The burst limiter over content-generating requests, which nothing reports.
1365    Secondary,
1366}
1367
1368/// The wordings GitHub answers a secondary rate limit with.
1369///
1370/// It sends them under a forbidden status, under a too-many-requests status, and inside
1371/// the `errors` of a *successful* response, which is why the text is what this matches on
1372/// rather than the status. `abuse detection` is the wording GitHub used before the
1373/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1374/// what a burst of content creation is refused with.
1375///
1376/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1377/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1378/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1379/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1380pub const SECONDARY_WORDINGS: [&str; 5] = [
1381    "secondary rate limit",
1382    "temporarily blocked from content creation",
1383    "abuse detection",
1384    "submitted too quickly",
1385    "exceeded a secondary",
1386];
1387
1388/// The wordings GitHub answers an exhausted primary budget with.
1389///
1390/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1391/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1392/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1393/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1394/// two phrases is a substring of it, so without it that answer read as a refusal that will
1395/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1396/// one reason.
1397pub const PRIMARY_WORDINGS: [&str; 4] = [
1398    "api rate limit exceeded",
1399    "api rate limit already exceeded",
1400    "rate limit exceeded",
1401    "rate_limited",
1402];
1403
1404/// What a response *says about itself*, which is the only place a refusal can be read.
1405///
1406/// Deliberately not the whole response body. A board is a place people write about their
1407/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1408/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1409/// reported. So the item data is never read: what is read is GitHub's own REST-style
1410/// `message` envelope, which is what a forbidden status carries, and the `message` and
1411/// `type` of each GraphQL error, which is where a *successful* response says it.
1412///
1413/// A body that is not JSON at all has nothing structured to read, so only a failing
1414/// response's own text is taken — a successful response that is not JSON is malformed
1415/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1416fn refusal_wording(status: StatusCode, body: &str) -> String {
1417    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1418        return if status.is_success() {
1419            String::new()
1420        } else {
1421            body.to_owned()
1422        };
1423    };
1424    let mut said: Vec<&str> = parsed
1425        .get("message")
1426        .and_then(Value::as_str)
1427        .into_iter()
1428        .collect();
1429    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1430        for error in errors {
1431            said.extend(
1432                ["message", "type"]
1433                    .into_iter()
1434                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1435            );
1436        }
1437    }
1438    said.join("; ")
1439}
1440
1441impl Limiter {
1442    /// Which limiter refused this response, or `None` when none of them did.
1443    ///
1444    /// The wording is read first and the status only decides what carries none of it,
1445    /// because GitHub answers a secondary limit with a forbidden status far more often
1446    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1447    /// really is a credential this token lacks.
1448    ///
1449    /// A response is a refusal because of its status or its own wording. A spent budget
1450    /// only ever explains one; it never turns an answer into a refusal.
1451    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1452        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1453        if SECONDARY_WORDINGS
1454            .iter()
1455            .any(|wording| normalized.contains(wording))
1456        {
1457            return Some(Self::Secondary);
1458        }
1459        if status == StatusCode::TOO_MANY_REQUESTS {
1460            return Some(Self::Primary);
1461        }
1462        // An exhausted budget *explains* a response that failed; it does not make one that
1463        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1464        // request the budget allowed as well as on the ones it then refuses, so reading
1465        // the header alone threw away a good answer — and, once refusals were retried,
1466        // replayed a request that had already taken effect.
1467        if !status.is_success() && budget_exhausted {
1468            return Some(Self::Primary);
1469        }
1470        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1471        // `errors` of an HTTP 200, where nothing about the status says so at all.
1472        if status.is_success()
1473            && PRIMARY_WORDINGS
1474                .iter()
1475                .any(|wording| normalized.contains(wording))
1476        {
1477            return Some(Self::Primary);
1478        }
1479        None
1480    }
1481
1482    /// What this limiter is called where an operator can look it up.
1483    const fn name(self) -> &'static str {
1484        match self {
1485            Self::Primary => "GitHub's primary API rate limit",
1486            Self::Secondary => "GitHub's secondary rate limit",
1487        }
1488    }
1489
1490    /// What the endpoint an operator would go and check says about this limiter.
1491    const fn where_to_look(self) -> &'static str {
1492        match self {
1493            Self::Primary => {
1494                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1495                 comes back."
1496            }
1497            Self::Secondary => {
1498                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1499                 primary budget and does not report this one, so budget showing there says \
1500                 nothing about this refusal, and every further attempt extends it."
1501            }
1502        }
1503    }
1504
1505    /// The next step this limiter actually calls for.
1506    const fn what_to_do(self) -> &'static str {
1507        match self {
1508            Self::Primary => {
1509                "wait for the reset `gh api rate_limit` reports, then run the command again."
1510            }
1511            Self::Secondary => {
1512                "leave this board alone for a few minutes, then run the command again — or \
1513                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1514            }
1515        }
1516    }
1517}
1518
1519/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1520#[derive(Debug, Clone, Copy)]
1521struct Limited {
1522    limiter: Limiter,
1523    hint: Option<u64>,
1524}
1525
1526impl Limited {
1527    /// What the caller is told once this source has waited as long as it may.
1528    ///
1529    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1530    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1531    /// about *which* limiter it was makes it a different kind of failure. What differs is
1532    /// the operator's next step, and that is what the message carries — a secondary
1533    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1534    /// budget looks fine, and then back to retry the very burst that was refused.
1535    fn exhausted(
1536        self,
1537        doing: &str,
1538        waits: u32,
1539        waited: Duration,
1540        needed: Duration,
1541        budget: Duration,
1542    ) -> SourceError {
1543        SourceError::RateLimited {
1544            retry_after_seconds: self.hint,
1545            message: Some(format!(
1546                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1547                 again, and the next wait of {} would take it past the {} one call may spend \
1548                 waiting. {} next: {}",
1549                self.limiter.name(),
1550                plural(waits, "refusal"),
1551                seconds(waited),
1552                seconds(needed),
1553                seconds(budget),
1554                self.limiter.where_to_look(),
1555                self.limiter.what_to_do(),
1556            )),
1557        }
1558    }
1559}
1560
1561/// One HTTP attempt's result, with what its response said about the rate limit.
1562///
1563/// The two travel together so the record and the outcome are written from the same place:
1564/// what a response said about the budget is only readable while that response is in hand,
1565/// and what the attempt *meant* is only decidable once its body has been read.
1566struct Attempted {
1567    result: Result<Value, Attempt>,
1568    limits: accounting::RateLimit,
1569    /// GitHub's own reported cost for this call, for a document that asked for it.
1570    reported_cost: Option<u64>,
1571}
1572
1573/// One attempt's outcome: an error to report, or a rate limit to wait out.
1574enum Attempt {
1575    Failed(SourceError),
1576    Limited(Limited),
1577}
1578
1579fn plural(count: u32, thing: &str) -> String {
1580    if count == 1 {
1581        format!("{count} {thing}")
1582    } else {
1583        format!("{count} {thing}s")
1584    }
1585}
1586
1587fn seconds(duration: Duration) -> String {
1588    format!("{:.1}s", duration.as_secs_f64())
1589}
1590
1591/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1592///
1593/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1594/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1595/// header, and neither is what makes a response a refusal — so the whole cost of one this
1596/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1597/// instead. Refusing the response over the header would turn a readable refusal into an
1598/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1599fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1600    value
1601        .and_then(|value| value.to_str().ok())
1602        .and_then(|value| value.trim().parse::<u64>().ok())
1603}
1604
1605/// Every mutation this source sends creates content — an issue, a board item, a field of
1606/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1607/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1608/// and what the keyword says are the same set. That is what makes the keyword a sound test
1609/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1610/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1611fn is_mutation(query: &str) -> bool {
1612    query.trim_start().starts_with("mutation")
1613}
1614
1615/// What this source was doing, for a diagnostic that has to say so.
1616///
1617/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1618/// a document added without a description is caught by that list's own gate instead of
1619/// falling through to the vague arm below.
1620fn operation_description(query: &str) -> &'static str {
1621    graphql::DOCUMENTS
1622        .iter()
1623        .find(|(document, _)| *document == query)
1624        .map_or("talking to GitHub", |(_, doing)| *doing)
1625}
1626
1627/// GitHub's published ceiling on content-generating requests, per minute.
1628///
1629/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1630/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1631/// from it, so a pacing value checked only against itself cannot go stale here.
1632pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1633/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1634/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1635/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1636pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1637/// Shortest interval between two content-creating mutations, in milliseconds.
1638///
1639/// GitHub documents two secondary limits on content-generating requests:
1640/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1641/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1642/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1643/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1644/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1645/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1646/// deliberately *not* what this paces at. An installation that wants the hourly bound
1647/// honoured for a long sequence of copies says so through
1648/// `pacing.min_mutation_interval_ms`.
1649pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1650/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1651///
1652/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1653/// own advice for a secondary limit — wait, and wait longer each time — without spending
1654/// the first minute of a transient refusal doing nothing.
1655pub const RETRY_BACKOFF_MS: u64 = 1_000;
1656/// Total time one call may spend waiting out rate limits before it reports a failure.
1657///
1658/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1659/// short enough that a command an operator is watching returns. The bound is what makes
1660/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1661/// the limiter, not in a process nobody can tell from a wedged one.
1662pub const RETRY_BUDGET_MS: u64 = 120_000;
1663
1664fn default_token_env() -> String {
1665    "GH_PROJECTS_TOKEN".to_owned()
1666}
1667fn default_endpoint() -> String {
1668    "https://api.github.com/graphql".to_owned()
1669}
1670
1671/// The name of a `Status` single-select option on the board.
1672///
1673/// Validated on the way in rather than checked later, so a blank option name — which
1674/// nothing on a board can be — is a state this type cannot hold.
1675#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1676#[serde(try_from = "String")]
1677#[schemars(extend("minLength" = 1))]
1678pub struct ColumnName(String);
1679
1680impl ColumnName {
1681    /// The option name, as the board spells it.
1682    fn as_str(&self) -> &str {
1683        &self.0
1684    }
1685}
1686
1687impl TryFrom<String> for ColumnName {
1688    type Error = String;
1689
1690    fn try_from(name: String) -> Result<Self, Self::Error> {
1691        if name.trim().is_empty() {
1692            return Err("a status_mapping option name cannot be blank".to_owned());
1693        }
1694        Ok(Self(name))
1695    }
1696}
1697
1698/// The two closed states this product can mean.
1699///
1700/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1701/// work nor abandoned work, so nothing here ever writes it.
1702#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1703#[serde(rename_all = "kebab-case")]
1704pub enum ClosedState {
1705    /// `COMPLETED` — precisely done.
1706    Completed,
1707    /// `NOT_PLANNED` — precisely cancelled.
1708    NotPlanned,
1709}
1710
1711impl ClosedState {
1712    const fn reason(self) -> &'static str {
1713        match self {
1714            Self::Completed => "COMPLETED",
1715            Self::NotPlanned => "NOT_PLANNED",
1716        }
1717    }
1718}
1719
1720/// Configuration for one GitHub Projects v2 board.
1721#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1722#[serde(default, deny_unknown_fields)]
1723pub struct GitHubProjectsConfig {
1724    /// Login of the user or organization which owns the board.
1725    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1726    /// The project number shown in the board's GitHub URL.
1727    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1728    // 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.
1729    /// `owner/name` of the repository this source creates an issue in when the item's own
1730    /// `repositories` field does not decide it.
1731    ///
1732    /// An item naming exactly one repository is created there; a task or a document naming
1733    /// none or several is created in its parent project's repository; and a project, or a
1734    /// task or document with no parent, naming none or several is created here. A board
1735    /// has no repository of its own and `createIssue` requires one, so a write without
1736    /// this is refused naming the field. Reads never need it.
1737    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1738    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1739    /// Environment variable containing a fine-grained token with Projects and Issues
1740    /// read/write plus Pull requests read-only access for every repository represented on
1741    /// the board.
1742    #[serde(default = "default_token_env")]
1743    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1744    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1745    #[serde(default = "default_endpoint")]
1746    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1747    /// Per-instance mapping from a status category to the option of the board's one
1748    /// `Status` field it lands on, for a task and for a project.
1749    ///
1750    /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1751    /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1752    /// where a kind left out leaves the category unmapped for that kind. A category this
1753    /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1754    /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1755    /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1756    /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1757    /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1758    /// either kind. No two categories may name one option for the same kind, ignoring case.
1759    /// `unknown` may name one existing option; every unknown word then lands on it and
1760    /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1761    /// each unknown word because it never creates board options.
1762    #[serde(default)]
1763    pub status_mapping: StatusMapping,
1764    /// Per-instance mapping from a task's priority to an option of this board's
1765    /// single-select field named `Priority`.
1766    ///
1767    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1768    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1769    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1770    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1771    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1772    /// no two levels may name one option. Reads and writes never create the field or an
1773    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1774    /// the board lacks is refused pointing there.
1775    #[serde(default)]
1776    pub priority_mapping: Option<PriorityMappingConfig>,
1777    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1778    ///
1779    /// Every field keeps its shipped default when it is absent, and the defaults are
1780    /// GitHub's own published limits rather than taste. See [`Pacing`].
1781    #[serde(default)]
1782    pub pacing: PacingConfig,
1783}
1784
1785/// Which option of the board's `Priority` field each priority lands on.
1786///
1787/// One member per level rather than a map, so a key that is not a level is refused where
1788/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1789/// value in the field, not an option of it.
1790#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1791#[serde(default, deny_unknown_fields)]
1792pub struct PriorityMappingConfig {
1793    /// The option `urgent` lands on; `Urgent` when absent.
1794    pub urgent: Option<PriorityOptionName>,
1795    /// The option `high` lands on; `High` when absent.
1796    pub high: Option<PriorityOptionName>,
1797    /// The option `medium` lands on; `Medium` when absent.
1798    pub medium: Option<PriorityOptionName>,
1799    /// The option `low` lands on; `Low` when absent.
1800    pub low: Option<PriorityOptionName>,
1801}
1802
1803/// The name of an option of the board's `Priority` single-select field.
1804///
1805/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1806/// blank name.
1807#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1808#[serde(try_from = "String")]
1809#[schemars(extend("minLength" = 1))]
1810pub struct PriorityOptionName(String);
1811
1812impl PriorityOptionName {
1813    /// The option name, as the board spells it.
1814    fn as_str(&self) -> &str {
1815        &self.0
1816    }
1817}
1818
1819impl TryFrom<String> for PriorityOptionName {
1820    type Error = String;
1821
1822    fn try_from(name: String) -> Result<Self, Self::Error> {
1823        if name.trim().is_empty() {
1824            return Err("a priority_mapping option name cannot be blank".to_owned());
1825        }
1826        Ok(Self(name))
1827    }
1828}
1829
1830/// The name of the board field a priority is held in.
1831pub const PRIORITY_FIELD: &str = "Priority";
1832
1833/// The four priorities a board option can hold, in the order a new `Priority` field lists
1834/// them. `none` is not among them: it is the field holding no value.
1835///
1836/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1837/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1838/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1839/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1840pub const PRIORITY_LEVELS: [Priority; 4] = [
1841    Priority::Urgent,
1842    Priority::High,
1843    Priority::Medium,
1844    Priority::Low,
1845];
1846
1847/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1848/// see that list for what this pins.
1849#[must_use]
1850pub const fn level_position(priority: Priority) -> Option<usize> {
1851    match priority {
1852        Priority::None => None,
1853        Priority::Urgent => Some(0),
1854        Priority::High => Some(1),
1855        Priority::Medium => Some(2),
1856        Priority::Low => Some(3),
1857    }
1858}
1859
1860/// This instance's complete priority-to-option mapping, read in both directions.
1861///
1862/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1863/// two levels name one option.
1864#[derive(Debug, Clone)]
1865struct PriorityMapping {
1866    options: [PriorityOptionName; 4],
1867}
1868
1869impl PriorityMapping {
1870    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1871        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1872        let mapping = Self {
1873            options: [
1874                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1875                config.high.unwrap_or_else(|| shipped("High")),
1876                config.medium.unwrap_or_else(|| shipped("Medium")),
1877                config.low.unwrap_or_else(|| shipped("Low")),
1878            ],
1879        };
1880        for (index, option) in mapping.options.iter().enumerate() {
1881            if let Some(earlier) = mapping.options[..index]
1882                .iter()
1883                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1884            {
1885                return Err(SourceError::Config {
1886                    message: format!(
1887                        "priority_mapping of source {instance} sends both {} and {} to the board \
1888                         option {:?}; one option cannot read back as two priorities",
1889                        PRIORITY_LEVELS[earlier],
1890                        PRIORITY_LEVELS[index],
1891                        option.as_str()
1892                    ),
1893                });
1894            }
1895        }
1896        Ok(mapping)
1897    }
1898
1899    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1900    fn option(&self, priority: Priority) -> Option<&str> {
1901        level_position(priority).map(|index| self.options[index].as_str())
1902    }
1903
1904    /// The priority a board option name reports, or `None` when nothing maps to it.
1905    fn priority_of(&self, option: &str) -> Option<Priority> {
1906        self.options
1907            .iter()
1908            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1909            .map(|index| PRIORITY_LEVELS[index])
1910    }
1911
1912    /// Every mapped option name, in the order a new `Priority` field lists them.
1913    fn names(&self) -> impl Iterator<Item = &str> {
1914        self.options.iter().map(PriorityOptionName::as_str)
1915    }
1916}
1917
1918/// What one item's `Priority` field says, read through this instance's mapping.
1919#[derive(Debug, Clone, PartialEq, Eq)]
1920enum HeldPriority {
1921    /// A priority this source reports: an option the mapping names, or no value (`none`).
1922    Read(Priority),
1923    /// An option the mapping does not name, which is never read as a level or as `none`.
1924    Unmapped(String),
1925}
1926
1927/// How fast this source writes, and how long it waits out a rate-limit refusal.
1928///
1929/// Configurable because a GitHub Enterprise installation sets its own limits and an
1930/// operator who has already been refused may want to go slower still — not because the
1931/// defaults are guesses.
1932#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1933#[serde(default, deny_unknown_fields)]
1934pub struct PacingConfig {
1935    /// Shortest interval between two content-creating mutations, in milliseconds.
1936    ///
1937    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1938    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1939    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.
1940    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1941    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1942    /// zero while there is a budget to spend, because a schedule of zero-length waits
1943    /// consumes none of it and so never ends.
1944    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.
1945    /// Total time one call may spend waiting out rate limits, in milliseconds.
1946    ///
1947    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1948    /// the bound is what makes this a wait rather than a hang.
1949    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.
1950}
1951
1952/// The largest any pacing setting may be, in milliseconds.
1953///
1954/// One hour. GitHub's own harshest published bound on content-generating requests works
1955/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1956/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1957/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1958/// and an interval beyond it is a command that never sends its second mutation. It also
1959/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1960/// what a `Duration` can hold on every platform.
1961pub const MAX_PACING_MS: u64 = 3_600_000;
1962
1963/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1964/// source holds.
1965#[derive(Debug, Clone, Copy)]
1966struct Pacing {
1967    min_mutation_interval: Duration,
1968    retry_backoff: Duration,
1969    retry_budget: Duration,
1970}
1971
1972impl Pacing {
1973    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1974    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1975        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1976            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1977                message: format!(
1978                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1979                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1980                     GitHub's own harshest published limit"
1981                ),
1982            }),
1983            Some(value) => Ok(Duration::from_millis(value)),
1984            None => Ok(Duration::from_millis(default)),
1985        };
1986        let retry_backoff = bounded(
1987            config.retry_backoff_ms,
1988            RETRY_BACKOFF_MS,
1989            "retry_backoff_ms",
1990        )?;
1991        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1992        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1993            return Err(SourceError::Config {
1994                message: format!(
1995                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1996                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1997                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1998                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1999                     waiting at all",
2000                    retry_budget.as_millis()
2001                ),
2002            });
2003        }
2004        Ok(Self {
2005            min_mutation_interval: bounded(
2006                config.min_mutation_interval_ms,
2007                MIN_MUTATION_INTERVAL_MS,
2008                "min_mutation_interval_ms",
2009            )?,
2010            retry_backoff,
2011            retry_budget,
2012        })
2013    }
2014}
2015
2016/// Factory for [`GitHubProjectsSource`].
2017#[derive(Debug, Clone, Copy, Default)]
2018pub struct Plugin;
2019
2020impl SourcePlugin for Plugin {
2021    fn kind(&self) -> &'static str {
2022        KIND
2023    }
2024    fn config_schema(&self) -> Schema {
2025        schema_for!(GitHubProjectsConfig)
2026    }
2027    fn build(
2028        &self,
2029        name: &SourceName,
2030        config: &Value,
2031        secrets: &dyn SecretResolver,
2032    ) -> Result<Box<dyn TaskSource>, SourceError> {
2033        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2034    }
2035
2036    fn build_with_clock(
2037        &self,
2038        name: &SourceName,
2039        config: &Value,
2040        secrets: &dyn SecretResolver,
2041        clock: SharedClock,
2042    ) -> Result<Box<dyn TaskSource>, SourceError> {
2043        self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2044    }
2045}
2046
2047impl Plugin {
2048    /// Build a source recording every request it sends into an accounting the caller holds.
2049    ///
2050    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2051    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2052    /// session total rather than two — see [`accounting`] and
2053    /// [`GitHubProjectsSource::recording_into`].
2054    ///
2055    /// # Errors
2056    ///
2057    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2058    /// [`SourceError::Config`] for configuration this plugin cannot use and
2059    /// [`SourceError::Auth`] for a credential it cannot find.
2060    pub fn build_recording_into(
2061        &self,
2062        name: &SourceName,
2063        config: &Value,
2064        secrets: &dyn SecretResolver,
2065        ledger: Arc<Accounting>,
2066    ) -> Result<Box<dyn TaskSource>, SourceError> {
2067        self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2068    }
2069
2070    fn build_recording_with_clock(
2071        &self,
2072        name: &SourceName,
2073        config: &Value,
2074        secrets: &dyn SecretResolver,
2075        ledger: Arc<Accounting>,
2076        clock: SharedClock,
2077    ) -> Result<Box<dyn TaskSource>, SourceError> {
2078        let config: GitHubProjectsConfig =
2079            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2080                message: format!("source {name}: {e}"),
2081            })?;
2082        let prefix = format!("source {name}: ");
2083        let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2084            .map_err(|error| match error {
2085                // The shared `StatusMapping::distinct` names the source itself.
2086                SourceError::Config { message } if message.starts_with(&prefix) => {
2087                    SourceError::Config { message }
2088                }
2089                SourceError::Config { message } => SourceError::Config {
2090                    message: format!("{prefix}{message}"),
2091                },
2092                SourceError::Auth { message } => SourceError::Auth {
2093                    message: format!("source {name}: {message}"),
2094                },
2095                other => other,
2096            })?;
2097        source.clock = clock;
2098        Ok(Box::new(source))
2099    }
2100}
2101
2102/// Where a status category lands on this board, once configuration is resolved.
2103#[derive(Debug, Clone, PartialEq, Eq)]
2104enum StatusTarget {
2105    /// Not usable against this instance for this kind, and why.
2106    Disabled(UnmappedStatus),
2107    /// The board's `Status` option of this name.
2108    Column(ColumnName),
2109    /// A closed issue, with both its board option and the reason that says which closed it means.
2110    // 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.
2111    Terminal(ColumnName, ClosedState),
2112}
2113
2114/// Every status category, in the order the vocabulary declares them.
2115///
2116/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2117/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2118/// added to the shared vocabulary fails to compile until it is named there, and this
2119/// crate's suite reconciles this list against that enum's own derived schema, which is
2120/// generated from the variants rather than written beside them. The schema is what
2121/// catches a list left one short — a list checking only the positions it already holds
2122/// would pass while every mapping indexed by the new position panicked.
2123pub const CATEGORIES: [StatusCategory; 8] = [
2124    StatusCategory::Draft,
2125    StatusCategory::Backlog,
2126    StatusCategory::Todo,
2127    StatusCategory::Queued,
2128    StatusCategory::InProgress,
2129    StatusCategory::Done,
2130    StatusCategory::Cancelled,
2131    StatusCategory::Unknown,
2132];
2133
2134/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2135#[must_use]
2136pub const fn category_position(category: StatusCategory) -> usize {
2137    match category {
2138        StatusCategory::Draft => 0,
2139        StatusCategory::Backlog => 1,
2140        StatusCategory::Todo => 2,
2141        StatusCategory::Queued => 3,
2142        StatusCategory::InProgress => 4,
2143        StatusCategory::Done => 5,
2144        StatusCategory::Cancelled => 6,
2145        StatusCategory::Unknown => 7,
2146    }
2147}
2148
2149/// The spelling a status category is configured and reported under.
2150fn category_name(category: StatusCategory) -> &'static str {
2151    match category {
2152        StatusCategory::Draft => "draft",
2153        StatusCategory::Backlog => "backlog",
2154        StatusCategory::Todo => "todo",
2155        StatusCategory::Queued => "queued",
2156        StatusCategory::InProgress => "in-progress",
2157        StatusCategory::Done => "done",
2158        StatusCategory::Cancelled => "cancelled",
2159        StatusCategory::Unknown => "unknown",
2160    }
2161}
2162
2163/// A shipped default's option name.
2164///
2165/// The literals below are this file's own and non-blank, and they are validated by the
2166/// one constructor a configured name goes through rather than beside it.
2167fn shipped_column(name: &'static str) -> ColumnName {
2168    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2169}
2170
2171/// The shipped default for one category this instance's `status_mapping` does not mention,
2172/// for either kind.
2173fn shipped_default(category: StatusCategory) -> StatusTarget {
2174    match category {
2175        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2176        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2177        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2178        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2179        StatusCategory::Done => {
2180            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2181        }
2182        StatusCategory::Cancelled => {
2183            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2184        }
2185        StatusCategory::Draft | StatusCategory::Unknown => {
2186            StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2187        }
2188    }
2189}
2190
2191/// The two kinds a status is written and read for, each with its own half of the mapping.
2192const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2193
2194/// This instance's complete category-to-target mapping for each kind, read in both
2195/// directions.
2196///
2197/// One target per category per kind, held at that category's own [`category_position`], so
2198/// a category missing from the mapping, named twice in it, or filed out of order is a state
2199/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2200/// targets are options of the board's one `Status` field.
2201#[derive(Debug, Clone)]
2202struct BoardStatuses {
2203    tasks: [StatusTarget; CATEGORIES.len()],
2204    projects: [StatusTarget; CATEGORIES.len()],
2205}
2206
2207impl BoardStatuses {
2208    /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2209    /// would read back from one option.
2210    ///
2211    /// A category the mapping does not mention keeps its shipped default for both kinds; one
2212    /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2213    /// omits unmapped rather than defaulted.
2214    fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2215        let resolve_kind =
2216            |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2217                // `CATEGORIES[position] == category` for every category — the crate's suite
2218                // asserts it — so mapping the list in order fills each category's own slot.
2219                let mut targets = CATEGORIES.map(shipped_default);
2220                for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2221                    if !configured.mentions(category) {
2222                        continue;
2223                    }
2224                    *slot = match configured.name_for(category, kind) {
2225                        Err(why) => StatusTarget::Disabled(why),
2226                        Ok(name) => {
2227                            let option = ColumnName::try_from(name.as_str().to_owned())
2228                                .map_err(|message| SourceError::Config { message })?;
2229                            match category {
2230                                StatusCategory::Done => {
2231                                    StatusTarget::Terminal(option, ClosedState::Completed)
2232                                }
2233                                StatusCategory::Cancelled => {
2234                                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2235                                }
2236                                _ => StatusTarget::Column(option),
2237                            }
2238                        }
2239                    };
2240                }
2241                StatusMapping::distinct(
2242                    instance,
2243                    kind,
2244                    CATEGORIES
2245                        .iter()
2246                        .zip(&targets)
2247                        .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2248                )?;
2249                Ok(targets)
2250            };
2251        Ok(Self {
2252            tasks: resolve_kind(ItemKind::Task)?,
2253            projects: resolve_kind(ItemKind::Project)?,
2254        })
2255    }
2256
2257    /// Every category's target for `kind`, in category order.
2258    const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2259        match kind {
2260            ItemKind::Task => &self.tasks,
2261            ItemKind::Project => &self.projects,
2262        }
2263    }
2264
2265    fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2266        &self.targets(kind)[category_position(category)]
2267    }
2268
2269    /// The category a board option name reports for `kind`, or `None` when nothing of that
2270    /// kind maps to it.
2271    fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2272        CATEGORIES.into_iter().find(|category| {
2273            self.target(kind, *category)
2274                .option()
2275                .is_some_and(|name| name.eq_ignore_ascii_case(option))
2276        })
2277    }
2278
2279    /// Every option name either kind maps a category to, each once ignoring case, in
2280    /// category order with a task's name before a project's — what the guarded setup asks
2281    /// the `Status` field to hold.
2282    fn wanted(&self) -> Vec<String> {
2283        let mut wanted: Vec<String> = Vec::new();
2284        for category in CATEGORIES {
2285            for kind in STATUS_KINDS {
2286                if let Some(name) = self.target(kind, category).option()
2287                    && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2288                {
2289                    wanted.push(name.to_owned());
2290                }
2291            }
2292        }
2293        wanted
2294    }
2295
2296    /// The status an item of `kind` reports, from the three things a read of it says: its
2297    /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2298    ///
2299    /// The closed state decides the category and the `Status` option decides the name, so
2300    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2301    /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2302    /// a duplicate is not finished work, and calling it done is a lie the next copy would
2303    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2304    /// it is read permissively rather than refused — reads are faithful, and refusals
2305    /// belong on writes. An open item's option reads through its own kind's mapping, and an
2306    /// option that mapping does not name reads as `Unknown` under its own name.
2307    ///
2308    /// One function of those three rather than of a response, so a narrow status write can
2309    /// answer what a re-read would report by applying it to the state it has just written.
2310    fn status(
2311        &self,
2312        kind: ItemKind,
2313        option: Option<&str>,
2314        closed: bool,
2315        reason: Option<&str>,
2316    ) -> Status {
2317        if closed {
2318            let category = match reason {
2319                None | Some("COMPLETED") => StatusCategory::Done,
2320                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2321                Some(_) => StatusCategory::Unknown,
2322            };
2323            let fallback = match category {
2324                StatusCategory::Done => "Done",
2325                StatusCategory::Cancelled => "Cancelled",
2326                _ => "Closed",
2327            };
2328            return Status {
2329                category,
2330                name: option.unwrap_or(fallback).to_owned(),
2331            };
2332        }
2333        let name = option.unwrap_or("Open").to_owned();
2334        Status {
2335            category: self
2336                .category_of(kind, &name)
2337                .unwrap_or(StatusCategory::Unknown),
2338            name,
2339        }
2340    }
2341}
2342
2343impl BoardStatuses {
2344    /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2345    /// case; a kind lacking none is left out.
2346    fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2347        STATUS_KINDS
2348            .into_iter()
2349            .filter_map(|kind| {
2350                let missing: Vec<String> = self
2351                    .targets(kind)
2352                    .iter()
2353                    .filter_map(StatusTarget::option)
2354                    .filter(|wanted| {
2355                        !existing
2356                            .iter()
2357                            .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2358                    })
2359                    .map(str::to_owned)
2360                    .collect();
2361                (!missing.is_empty()).then_some(KindMissing { kind, missing })
2362            })
2363            .collect()
2364    }
2365}
2366
2367impl StatusTarget {
2368    /// The board option this target selects, or `None` for an unmapped one.
2369    fn option(&self) -> Option<&str> {
2370        match self {
2371            Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2372            Self::Disabled(_) => None,
2373        }
2374    }
2375}
2376
2377// 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.
2378/// One repository this source can create an issue in, as `owner/name`.
2379///
2380/// Every `createIssue` this source sends names one of these: the item's own single
2381/// `repositories` entry, else its parent project issue's repository, else the configured
2382/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2383/// that choice and says what it refuses before `createIssue`.
2384// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2385#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2386struct RepositoryTarget {
2387    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2388    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2389}
2390
2391impl RepositoryTarget {
2392    fn parse(value: &str) -> Result<Self, SourceError> {
2393        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2394            message: format!(
2395                "repository must be spelled owner/name; {value:?} names no repository"
2396            ),
2397        })?;
2398        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2399            return Err(SourceError::Config {
2400                message: format!(
2401                    "repository must be spelled owner/name with a GitHub login and one \
2402                     repository name; {value:?} is not"
2403                ),
2404            });
2405        }
2406        Ok(Self {
2407            owner: owner.to_owned(),
2408            name: name.to_owned(),
2409        })
2410    }
2411
2412    /// The one host whose repositories this source creates issues in, spelled once: it is
2413    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2414    const HOST: &str = "github.com";
2415
2416    fn origin(&self) -> String {
2417        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2418    }
2419
2420    /// The repository a normalized origin names, or why it is none this source can create
2421    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2422    fn from_origin(origin: &Repository) -> Result<Self, String> {
2423        let not_here = || {
2424            format!(
2425                "{} is not a {}/owner/name repository",
2426                origin.as_str(),
2427                Self::HOST
2428            )
2429        };
2430        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2431        if host != Self::HOST {
2432            return Err(not_here());
2433        }
2434        Self::parse(rest).map_err(|_| not_here())
2435    }
2436
2437    fn slug(&self) -> String {
2438        format!("{}/{}", self.owner, self.name)
2439    }
2440}
2441
2442/// A source which reads GitHub afresh for every operation.
2443pub struct GitHubProjectsSource {
2444    /// This source's configured name, used both to tell a far end naming this source
2445    /// from one naming a system it knows nothing about, and to name the instance a
2446    /// status refusal is about.
2447    name: SourceName,
2448    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2449    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2450    repository: Option<RepositoryTarget>,
2451    endpoint: Url,
2452    token: SecretString,
2453    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2454    statuses: BoardStatuses,
2455    /// Where each priority lands on this board, or `None` when this instance holds none.
2456    priorities: Option<PriorityMapping>,
2457    client: Client,
2458    asset_client: Client,
2459    /// Every item this source has created in this command, in the order it created them —
2460    /// dropped by [`TaskSource::end_command`].
2461    ///
2462    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2463    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2464    /// a copy resolving a dependency on an item it had just created refused it as not
2465    /// found. A board read is completed from this — an item remembered here and absent from
2466    /// the read is added back, because the board really does hold it and only the read is
2467    /// behind.
2468    ///
2469    /// It is not a cache of a user's work: nothing is remembered that this process did not
2470    /// itself just write, it lives and dies with the process, and it is never consulted for
2471    /// an item this source did not create.
2472    created: Mutex<Vec<Resolved>>,
2473    /// Every item that already existed and that this source has written in this command, as
2474    /// it wrote it — dropped by [`TaskSource::end_command`].
2475    ///
2476    /// The other half of [`Self::created`], held on the same terms and for the reason a
2477    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2478    /// filter is an index behind a write this process made moments ago, so a query matching
2479    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2480    /// is remembered that this process did not itself just write.
2481    updated: Mutex<Vec<Resolved>>,
2482    /// Every issue this source has added a comment to or edited a comment of in this command
2483    /// — dropped by [`TaskSource::end_command`].
2484    ///
2485    /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
2486    /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
2487    /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
2488    /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
2489    /// from before — until the index caught up. Each id here is a candidate of every such
2490    /// search-narrowed read, and wherever it is a candidate its comments are read rather than
2491    /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
2492    /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
2493    /// process wrote is still found only once the index has it.
2494    commented: Mutex<Vec<NativeId>>,
2495    /// How fast this source writes, and how long it waits out a refusal.
2496    pacing: Pacing,
2497    /// When the last content-creating mutation finished, or the moment the furthest-out
2498    /// reserved slot releases the next one, whichever is later — so the one after it can be
2499    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2500    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2501    /// what it is measured from.
2502    last_mutation: Mutex<Option<Duration>>,
2503    clock: SharedClock,
2504    numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
2505    /// The board as this process last read it, for the length of one command — dropped by
2506    /// [`TaskSource::end_command`].
2507    ///
2508    /// A copy of a project used to re-read the whole board, paged, before writing each of
2509    /// its items, which is by far the largest part of a copy's request count and none of
2510    /// its work. Nothing else changes this board while a command runs — this source's own
2511    /// writes are the only writer — so one read answers them all.
2512    ///
2513    /// It is not a store of a user's work and it is not the cache the no-persistence
2514    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2515    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2516    /// an item this command created and then depends on resolves whether or not GitHub's
2517    /// own eventually-consistent read has caught up. A write to an item already on the
2518    /// board updates the entry here too, so what this holds is the last read plus this
2519    /// process's own writes rather than a snapshot taken before them.
2520    board_cache: Mutex<Option<Board>>,
2521    /// Every issue this board's own search reported, for the length of one command — dropped
2522    /// by [`TaskSource::end_command`].
2523    ///
2524    /// The second half of a board read, and cached for the same reason and on the same
2525    /// terms as the first: it lives and dies with the process, nothing is written down, and
2526    /// a write this process makes updates the entry here exactly as it updates the one in
2527    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2528    /// that lists this board's projects and its tasks pays for one search rather than two.
2529    search_cache: Mutex<Option<Vec<Resolved>>>,
2530    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2531    /// the length of one command — dropped by [`TaskSource::end_command`].
2532    ///
2533    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2534    /// and dies with the process, nothing is written down, a write this process makes
2535    /// updates the entry here as it updates the other two, and every answer is completed
2536    /// with this process's own writes each time it is given. A command that asks the same
2537    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2538    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2539    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2540    search_next: Mutex<BTreeMap<String, Option<String>>>,
2541    /// Records already resolved in this command, reused by writes and for comment identity.
2542    /// Explicit item reads still reach GitHub. Nothing is persisted, and
2543    /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2544    /// its item as a person has since left it.
2545    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2546    /// The board's own id and field definitions as this process last read them on their
2547    /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2548    ///
2549    /// What a write needs of the board and its item does not say, read once per command
2550    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2551    /// lives and dies with the process and nothing is written down. It holds no item and so
2552    /// can answer no question about one — see [`Self::board_fields`].
2553    fields_cache: Mutex<Option<BoardFields>>,
2554    /// Each destination repository's node id, resolved once per repository
2555    /// rather than per issue created.
2556    ///
2557    /// A repository's node id does not change, and re-reading it for every issue of a copy
2558    /// spent one request per item on an answer this source already had. It is a map rather
2559    /// than one entry because a copy files each item in the repository its own
2560    /// `repositories` field names, so a plan across five repositories asks GitHub five
2561    /// times and not once per item.
2562    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2563    /// What every request this source sends is recorded into.
2564    ///
2565    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2566    /// a request leaves this crate, so nothing has to be switched on for a session to be
2567    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2568    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2569    /// source's reads and writes — adds up one accounting instead of two. See
2570    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2571    ledger: Arc<Accounting>,
2572}
2573
2574/// GitHub's closed single-select color vocabulary.
2575#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2576#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2577pub enum StatusOptionColor {
2578    /// Gray.
2579    Gray,
2580    /// Blue.
2581    Blue,
2582    /// Green.
2583    Green,
2584    /// Yellow.
2585    Yellow,
2586    /// Purple.
2587    Purple,
2588    /// Red.
2589    Red,
2590    /// Orange.
2591    Orange,
2592    /// Pink.
2593    Pink,
2594}
2595
2596/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2597/// applies its additions.
2598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2599pub enum SetupMode {
2600    /// Read without mutation.
2601    Plan,
2602    /// Apply and verify.
2603    Apply,
2604}
2605
2606/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2607/// against it goes on compiling.
2608pub type StatusOptionsMode = SetupMode;
2609
2610/// The explicit result of the requested operation.
2611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2612#[serde(rename_all = "kebab-case")]
2613pub enum StatusOptionsOutcome {
2614    /// A read-only plan.
2615    Planned,
2616    /// Apply found nothing missing.
2617    Unchanged,
2618    /// Additions were applied and verified.
2619    Applied,
2620}
2621
2622/// A GitHub single-select option's opaque GraphQL node identifier.
2623#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2624#[serde(transparent)]
2625pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2626
2627impl TryFrom<String> for StatusOptionId {
2628    type Error = String;
2629
2630    fn try_from(id: String) -> Result<Self, Self::Error> {
2631        if id.trim().is_empty() {
2632            return Err("a GitHub Status option id cannot be blank".to_owned());
2633        }
2634        Ok(Self(id))
2635    }
2636}
2637
2638/// One existing or proposed option in a guarded Status-field update.
2639#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2640pub struct StatusOption {
2641    /// GitHub's stable id.
2642    pub id: StatusOptionId,
2643    /// The visible option name.
2644    pub name: ColumnName,
2645    /// GitHub's single-select color token.
2646    pub color: StatusOptionColor,
2647    /// The option description, including an empty one.
2648    pub description: String,
2649}
2650
2651/// One board item's Status assignment, retained as recovery data.
2652#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2653pub struct StatusAssignment {
2654    /// The project item id whose assignment this is.
2655    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2656    // carried verbatim as operator recovery data; introducing a semantic type would claim
2657    // validation rules GitHub does not publish and no operation here interprets.
2658    pub item_id: String,
2659    /// The selected option, absent when the item has no status.
2660    #[serde(skip_serializing_if = "Option::is_none")]
2661    pub option: Option<AssignedStatusOption>,
2662}
2663
2664/// The inseparable id and name of an assigned option.
2665#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2666pub struct AssignedStatusOption {
2667    /// GitHub's stable id.
2668    pub id: StatusOptionId,
2669    /// The visible name.
2670    pub name: ColumnName,
2671}
2672
2673/// The plan and verified outcome of reconciling configured Status options.
2674#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2675pub struct StatusOptionsReport {
2676    /// The configured source name.
2677    pub source: SourceName,
2678    /// Configured option names absent before the operation.
2679    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2680    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2681    // serialized string here preserves the report's intentionally simple public contract.
2682    pub missing: Vec<String>,
2683    /// What the requested operation did.
2684    pub outcome: StatusOptionsOutcome,
2685    /// The complete option list observed before any mutation.
2686    pub existing: Vec<StatusOption>,
2687}
2688
2689#[derive(Debug, Clone, PartialEq, Eq)]
2690struct StatusSnapshot {
2691    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2692    // passed back as the mutation's project identity; a newtype could enforce no stronger
2693    // invariant because GitHub publishes no grammar for it.
2694    board_id: String,
2695    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2696    // passed back as the mutation's field identity; a newtype could enforce no stronger
2697    // invariant because GitHub publishes no grammar for it.
2698    field_id: String,
2699    options: Vec<StatusOption>,
2700    assignments: Vec<StatusAssignment>,
2701}
2702
2703/// The name of the board field a status is held in.
2704const STATUS_FIELD: &str = "Status";
2705
2706/// Every item's value of each field `report` names, as it stood before the setup wrote
2707/// anything — what a person puts back when the setup is refused part way.
2708fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2709    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2710        .fields
2711        .iter()
2712        .map(|field| (field.field.name(), before.assignments(field.field)))
2713        .collect();
2714    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2715        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2716    })
2717}
2718
2719/// One board field the guarded setup reads and writes — every one it reads, and the only
2720/// ones it writes.
2721#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2722pub enum BoardField {
2723    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2724    Status,
2725    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2726    Priority,
2727}
2728
2729impl BoardField {
2730    /// The field's name on the board.
2731    #[must_use]
2732    pub const fn name(self) -> &'static str {
2733        match self {
2734            Self::Status => STATUS_FIELD,
2735            Self::Priority => PRIORITY_FIELD,
2736        }
2737    }
2738
2739    /// The field a board calls `name`, or `None` for one this setup does not own.
2740    fn named(name: &str) -> Option<Self> {
2741        [Self::Status, Self::Priority]
2742            .into_iter()
2743            .find(|field| field.name() == name)
2744    }
2745}
2746
2747/// What the guarded setup did to one field.
2748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2749#[serde(rename_all = "kebab-case")]
2750pub enum FieldOutcome {
2751    /// A read-only plan.
2752    Planned,
2753    /// Apply found the field there with every configured option.
2754    Unchanged,
2755    /// Missing options were added to the field that was there, and verified.
2756    Applied,
2757    /// The field was not there; it was created holding the configured options, and verified.
2758    Created,
2759}
2760
2761/// One field's plan, or its verified outcome.
2762#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2763pub struct FieldReport {
2764    /// Which field.
2765    pub field: BoardField,
2766    /// Whether the board had the field before the operation.
2767    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2768    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2769    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2770    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2771    // one constructor, and it derives `outcome` from `exists` in one match.
2772    pub exists: bool,
2773    /// Configured option names the field lacked before the operation — every one of them,
2774    /// in the order a new field lists them, when the field was not there at all.
2775    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2776    // mapping name and has therefore already passed its nonblank validation; the serialized
2777    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2778    pub missing: Vec<String>,
2779    /// For the `Status` field, which item kind each missing name is configured for: one
2780    /// entry per kind `status_mapping` names a missing option for, task before project, each
2781    /// listing that kind's missing names in category order. A name both kinds use is in
2782    /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2783    /// `Priority`, which only a task holds.
2784    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2785    // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2786    // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2787    #[schemars(!skip_serializing_if)]
2788    pub kinds: Vec<KindMissing>,
2789    /// What the requested operation did.
2790    pub outcome: FieldOutcome,
2791    /// The field's complete option list observed before any mutation; empty when the field
2792    /// was not there.
2793    pub existing: Vec<StatusOption>,
2794}
2795
2796/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2797#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2798pub struct KindMissing {
2799    /// The kind these names are configured for.
2800    pub kind: ItemKind,
2801    /// The names that kind maps a category to and the field lacked, in category order.
2802    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2803    // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2804    // intentionally simple public contract.
2805    pub missing: Vec<String>,
2806}
2807
2808/// The plan and verified outcome of setting up every field a source's configuration names.
2809#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2810pub struct FieldsReport {
2811    /// The configured source name.
2812    pub source: SourceName,
2813    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2814    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2815    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2816    // per field would change a published JSON shape. The states the list could hold and the
2817    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2818    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2819    pub fields: Vec<FieldReport>,
2820}
2821
2822/// Which options one field is configured with, in the order a new field would list them.
2823struct FieldPlan {
2824    field: BoardField,
2825    wanted: Vec<String>,
2826}
2827
2828/// One single-select field as the guarded setup snapshots it.
2829#[derive(Debug, Clone, PartialEq, Eq)]
2830struct SnapshotField {
2831    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2832    // passed back as the mutation's field identity; a newtype could enforce no stronger
2833    // invariant because GitHub publishes no grammar for it.
2834    field_id: String,
2835    options: Vec<StatusOption>,
2836}
2837
2838/// Every single-select field of a board and every item's value of each.
2839#[derive(Debug, Clone, PartialEq, Eq)]
2840struct BoardSnapshot {
2841    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2842    // passed back as the mutation's project identity; a newtype could enforce no stronger
2843    // invariant because GitHub publishes no grammar for it.
2844    board_id: String,
2845    fields: BTreeMap<BoardField, SnapshotField>,
2846    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2847    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2848}
2849
2850impl BoardSnapshot {
2851    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2852    /// carries.
2853    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2854        self.items
2855            .iter()
2856            .map(|(item_id, values)| StatusAssignment {
2857                item_id: item_id.clone(),
2858                option: values.get(&field).cloned(),
2859            })
2860            .collect()
2861    }
2862}
2863
2864impl GitHubProjectsSource {
2865    /// Report missing configured Status options and, when `apply` is true, add them with
2866    /// a whole-list mutation that preserves every existing id and verifies the result.
2867    ///
2868    /// # Errors
2869    ///
2870    /// Refuses a board without a single-select `Status` field. A post-write difference in
2871    /// any pre-existing option id or item assignment is refused with the complete pre-write
2872    /// assignment snapshot in the diagnostic for recovery.
2873    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2874    // successful mutation, both drift refusals, source selection, missing Status, casing,
2875    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2876    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2877    // responses from entering the defensive malformed-response branches below.
2878    pub async fn status_options(
2879        &self,
2880        mode: StatusOptionsMode,
2881    ) -> Result<StatusOptionsReport, SourceError> {
2882        let before = self.status_snapshot().await?;
2883        // A terminal category's option is as configured as an open one's: a terminal
2884        // write validates it before closing and refuses when the board lacks it. Both
2885        // kinds' names are options of the one field, so both are asked for.
2886        let missing = self
2887            .statuses
2888            .wanted()
2889            .into_iter()
2890            .filter(|wanted| {
2891                !before
2892                    .options
2893                    .iter()
2894                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2895            })
2896            .collect::<Vec<_>>();
2897        let report = StatusOptionsReport {
2898            source: self.name.clone(),
2899            missing: missing.clone(),
2900            outcome: match (mode, missing.is_empty()) {
2901                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2902                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2903                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2904            },
2905            existing: before.options.clone(),
2906        };
2907        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2908            return Ok(report);
2909        }
2910        let mut options = before
2911            .options
2912            .iter()
2913            .map(|option| {
2914                json!({
2915                    "id": option.id, "name": option.name, "color": option.color,
2916                    "description": option.description,
2917                })
2918            })
2919            .collect::<Vec<_>>();
2920        options.extend(missing.iter().map(|name| {
2921            json!({
2922                "name": name, "color": "GRAY", "description": ""
2923            })
2924        }));
2925        self.graphql(
2926            graphql::STATUS_OPTIONS_UPDATE,
2927            json!({"input": {
2928                "projectId": before.board_id, "fieldId": before.field_id,
2929                "singleSelectOptions": options,
2930            }}),
2931        )
2932        .await?;
2933        let after = self.status_snapshot().await?;
2934        let options_preserved = before
2935            .options
2936            .iter()
2937            .all(|old| after.options.iter().any(|new| new == old));
2938        let additions_present = missing.iter().all(|wanted| {
2939            after
2940                .options
2941                .iter()
2942                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2943        });
2944        if !options_preserved || !additions_present || after.assignments != before.assignments {
2945            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2946                SourceError::Malformed {
2947                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2948                }
2949            })?;
2950            return Err(SourceError::Refused {
2951                message: format!(
2952                    "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}"
2953                ),
2954            });
2955        }
2956        Ok(report)
2957    }
2958
2959    /// A fresh snapshot of the Status field and every board item's assignment of it.
2960    ///
2961    /// # Errors
2962    ///
2963    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2964    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2965        // Status alone, as this operation has always read it: a `Priority` field is another
2966        // operation's, so nothing about it can refuse this one.
2967        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2968        let field = board
2969            .fields
2970            .remove(&BoardField::Status)
2971            .ok_or_else(|| self.no_status_field())?;
2972        Ok(StatusSnapshot {
2973            assignments: board.assignments(BoardField::Status),
2974            board_id: board.board_id,
2975            field_id: field.field_id,
2976            options: field.options,
2977        })
2978    }
2979
2980    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2981    fn no_status_field(&self) -> SourceError {
2982        SourceError::Refused {
2983            message: format!("source {} board has no Status field", self.name),
2984        }
2985    }
2986
2987    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2988    // the real CLI loopback journey, including pagination. The individual malformed guards
2989    // are defensive validation of a schema-pinned third-party response, not separate user
2990    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2991    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2992    /// every board item's value of each, walked to the end of the board's items. A field not
2993    /// in `owned` is read past whatever it holds.
2994    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2995        let mut after: Option<String> = None;
2996        let mut snapshot: Option<BoardSnapshot> = None;
2997        loop {
2998            let data = self
2999                .graphql(
3000                    graphql::STATUS_OPTIONS_SNAPSHOT,
3001                    json!({
3002                        "owner": self.owner, "number": self.project_number,
3003                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3004                    }),
3005                )
3006                .await?;
3007            let board = data
3008                .pointer("/owner/projectV2")
3009                .filter(|board| board.is_object())
3010                .ok_or_else(|| SourceError::Refused {
3011                    message: format!(
3012                        "source {} has no accessible GitHub Projects board",
3013                        self.name
3014                    ),
3015                })?;
3016            if board
3017                .pointer("/fields/pageInfo/hasNextPage")
3018                .and_then(Value::as_bool)
3019                != Some(false)
3020            {
3021                return Err(SourceError::Malformed {
3022                    message:
3023                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3024                            .into(),
3025                });
3026            }
3027            let mut fields = BTreeMap::new();
3028            // Only the fields this setup owns, by name: a node the single-select fragment did not
3029            // match carries no name, and a person's own single-select field — a `Size`, a
3030            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3031            // `Status` or `Priority` field without its options is malformed, not absent.
3032            // 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.
3033            for (owned, field) in board
3034                .pointer("/fields/nodes")
3035                .and_then(Value::as_array)
3036                .ok_or_else(|| SourceError::Malformed {
3037                    message: "GitHub project fields.nodes is not an array".into(),
3038                })?
3039                .iter()
3040                .filter_map(|field| {
3041                    let named = BoardField::named(field.get("name")?.as_str()?)?;
3042                    owned.contains(&named).then_some((named, field))
3043                })
3044            {
3045                let options = field
3046                    .get("options")
3047                    .and_then(Value::as_array)
3048                    .ok_or_else(|| SourceError::Malformed {
3049                        message: "GitHub single-select field options is not an array".into(),
3050                    })?
3051                    .iter()
3052                    .map(|option| {
3053                        Ok(StatusOption {
3054                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3055                                .map_err(|message| SourceError::Malformed { message })?,
3056                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3057                                .map_err(|message| SourceError::Malformed {
3058                                    message: format!(
3059                                        "GitHub single-select option name is invalid: {message}"
3060                                    ),
3061                                })?,
3062                            color: serde_json::from_value(
3063                                option.get("color").cloned().unwrap_or(Value::Null),
3064                            )
3065                            .map_err(|error| {
3066                                SourceError::Malformed {
3067                                    message: format!(
3068                                        "GitHub single-select option color is invalid: {error}"
3069                                    ),
3070                                }
3071                            })?,
3072                            description: optional_str(option, "description")?
3073                                .unwrap_or_default()
3074                                .to_owned(),
3075                        })
3076                    })
3077                    .collect::<Result<Vec<_>, SourceError>>()?;
3078                let snapshot = SnapshotField {
3079                    field_id: required_nonblank_str(field, "id")?.to_owned(),
3080                    options,
3081                };
3082                // A board's field names are unique, so a second one is an answer that cannot
3083                // say which field the setup would act on — refused rather than one chosen.
3084                if fields.insert(owned, snapshot).is_some() {
3085                    return Err(SourceError::Malformed {
3086                        message: format!(
3087                            "GitHub answered two {} fields for this board",
3088                            owned.name()
3089                        ),
3090                    });
3091                }
3092            }
3093            let board_id = required_nonblank_str(board, "id")?.to_owned();
3094            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3095                board_id,
3096                fields,
3097                items: Vec::new(),
3098            });
3099            let items = board
3100                .pointer("/items/nodes")
3101                .and_then(Value::as_array)
3102                .ok_or_else(|| SourceError::Malformed {
3103                    message: "GitHub project items.nodes is not an array".into(),
3104                })?;
3105            for item in items {
3106                let field_values =
3107                    item.get("fieldValues")
3108                        .ok_or_else(|| SourceError::Malformed {
3109                            message: "GitHub project item is missing fieldValues".into(),
3110                        })?;
3111                if field_values
3112                    .pointer("/pageInfo/hasNextPage")
3113                    .and_then(Value::as_bool)
3114                    != Some(false)
3115                {
3116                    return Err(SourceError::Malformed {
3117                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3118                    });
3119                }
3120                let values = item
3121                    .pointer("/fieldValues/nodes")
3122                    .and_then(Value::as_array)
3123                    .ok_or_else(|| SourceError::Malformed {
3124                        message: "GitHub project item fieldValues.nodes is not an array".into(),
3125                    })?;
3126                let item_id = required_nonblank_str(item, "id")?;
3127                let mut assigned = BTreeMap::new();
3128                for value in values {
3129                    let Some(field) = value
3130                        .pointer("/field/name")
3131                        .and_then(Value::as_str)
3132                        .and_then(BoardField::named)
3133                        .filter(|field| owned.contains(field))
3134                    else {
3135                        continue;
3136                    };
3137                    let held = assigned.insert(
3138                        field,
3139                        AssignedStatusOption {
3140                            id: StatusOptionId::try_from(
3141                                required_str(value, "optionId")?.to_owned(),
3142                            )
3143                            .map_err(|message| SourceError::Malformed { message })?,
3144                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3145                                .map_err(|message| SourceError::Malformed {
3146                                    message: format!(
3147                                        "GitHub assigned {} name is invalid: {message}",
3148                                        field.name()
3149                                    ),
3150                                })?,
3151                        },
3152                    );
3153                    // An item holds one value of a field, so a second one leaves no way to
3154                    // tell which it holds — and a verification or recovery built on either
3155                    // could restore the wrong one.
3156                    if held.is_some() {
3157                        return Err(SourceError::Malformed {
3158                            message: format!(
3159                                "GitHub answered two {} values for board item {item_id}",
3160                                field.name()
3161                            ),
3162                        });
3163                    }
3164                }
3165                current.items.push((item_id.to_owned(), assigned));
3166            }
3167            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3168                message: "GitHub project is missing items".into(),
3169            })?;
3170            let has_next = page
3171                .pointer("/pageInfo/hasNextPage")
3172                .and_then(Value::as_bool)
3173                .ok_or_else(|| SourceError::Malformed {
3174                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3175                })?;
3176            if !has_next {
3177                break;
3178            }
3179            let next =
3180                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3181            validate_cursor_progress(after.as_deref(), next)?;
3182            after = Some(next.to_owned());
3183        }
3184        snapshot.ok_or_else(|| SourceError::Malformed {
3185            message: "GitHub returned no board field snapshot".into(),
3186        })
3187    }
3188    // llmlint: ignore-end[changed_behavior_has_e2e]
3189
3190    /// Report every board field this source's configuration names and, with
3191    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3192    /// the `Priority` field when the board has none.
3193    ///
3194    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3195    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3196    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3197    /// color and description: the whole option list goes back with every existing id, because
3198    /// a re-minted id clears every item's value.
3199    ///
3200    /// # Errors
3201    ///
3202    /// Refuses a board without a single-select `Status` field. After an apply the board is
3203    /// read again, and a pre-existing option or any item's value of either field that moved is
3204    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3205    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3206    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3207    // with no Status field and a non-github-projects source through the compiled CLI against
3208    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3209    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3210        let owned: Vec<BoardField> = if self.priorities.is_some() {
3211            vec![BoardField::Status, BoardField::Priority]
3212        } else {
3213            vec![BoardField::Status]
3214        };
3215        let before = self.board_snapshot(&owned).await?;
3216        let mut plans = vec![FieldPlan {
3217            field: BoardField::Status,
3218            wanted: self.statuses.wanted(),
3219        }];
3220        if !before.fields.contains_key(&BoardField::Status) {
3221            return Err(self.no_status_field());
3222        }
3223        if let Some(mapping) = &self.priorities {
3224            plans.push(FieldPlan {
3225                field: BoardField::Priority,
3226                wanted: mapping.names().map(str::to_owned).collect(),
3227            });
3228        }
3229        // The snapshot reads single-select fields alone, so a field it did not find may still
3230        // be on the board under the name, of another type: creating one beside it would fail
3231        // part way, or leave two fields of one name. Asked of the board's own field list, and
3232        // only when a field is missing.
3233        if plans
3234            .iter()
3235            .any(|plan| !before.fields.contains_key(&plan.field))
3236        {
3237            let board = self.board_fields().await?;
3238            for plan in plans
3239                .iter()
3240                .filter(|plan| !before.fields.contains_key(&plan.field))
3241            {
3242                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3243                    return Err(SourceError::Refused {
3244                        message: format!(
3245                            "source {}'s board has a {} field that is not a single-select field \
3246                             (it is a {}), so it cannot hold this source's options; next: rename \
3247                             or remove that field, then run this again",
3248                            self.name,
3249                            plan.field.name(),
3250                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3251                        ),
3252                    });
3253                }
3254            }
3255        }
3256        let mut reports = Vec::new();
3257        for plan in &plans {
3258            let held = before.fields.get(&plan.field);
3259            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3260            let mut missing: Vec<String> = Vec::new();
3261            for wanted in &plan.wanted {
3262                let present = existing
3263                    .iter()
3264                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3265                    || missing
3266                        .iter()
3267                        .any(|named| named.eq_ignore_ascii_case(wanted));
3268                if !present {
3269                    missing.push(wanted.clone());
3270                }
3271            }
3272            let kinds = match plan.field {
3273                BoardField::Status => self.statuses.missing_by_kind(&existing),
3274                BoardField::Priority => Vec::new(),
3275            };
3276            reports.push(FieldReport {
3277                field: plan.field,
3278                exists: held.is_some(),
3279                kinds,
3280                outcome: match (mode, held.is_some(), missing.is_empty()) {
3281                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3282                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3283                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3284                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3285                },
3286                missing,
3287                existing,
3288            });
3289        }
3290        let report = FieldsReport {
3291            source: self.name.clone(),
3292            fields: reports,
3293        };
3294        let writes: Vec<&FieldReport> = report
3295            .fields
3296            .iter()
3297            .filter(|field| !field.missing.is_empty() || !field.exists)
3298            .collect();
3299        if mode == SetupMode::Plan || writes.is_empty() {
3300            return Ok(report);
3301        }
3302        let mut landed: Vec<&str> = Vec::new();
3303        for field in &writes {
3304            let added = field
3305                .missing
3306                .iter()
3307                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3308            let sent = match before.fields.get(&field.field) {
3309                Some(held) => {
3310                    let mut options = held
3311                        .options
3312                        .iter()
3313                        .map(|option| {
3314                            json!({
3315                                "id": option.id, "name": option.name, "color": option.color,
3316                                "description": option.description,
3317                            })
3318                        })
3319                        .collect::<Vec<_>>();
3320                    options.extend(added);
3321                    self.graphql(
3322                        graphql::STATUS_OPTIONS_UPDATE,
3323                        json!({"input": {
3324                            "projectId": before.board_id, "fieldId": held.field_id,
3325                            "singleSelectOptions": options,
3326                        }}),
3327                    )
3328                    .await
3329                }
3330                None => {
3331                    self.graphql(
3332                        graphql::CREATE_FIELD,
3333                        json!({"input": {
3334                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3335                            "name": field.field.name(),
3336                            "singleSelectOptions": added.collect::<Vec<_>>(),
3337                        }}),
3338                    )
3339                    .await
3340                }
3341            };
3342            // A mutation that failed does not establish that GitHub left its field as it was,
3343            // so every failure from here on carries the recovery data a drift refusal does.
3344            match sent {
3345                Ok(_) => landed.push(field.field.name()),
3346                Err(error) => {
3347                    let changed = if landed.is_empty() {
3348                        String::new()
3349                    } else {
3350                        format!("changed the {} field and then ", landed.join(" and "))
3351                    };
3352                    return Err(SourceError::Refused {
3353                        message: format!(
3354                            "the guarded field setup {changed}failed on the {} field, which it may \
3355                             have changed part way: {error}; the pre-write item assignments \
3356                             are:\n{}",
3357                            field.field.name(),
3358                            recovery(&report, &before)?
3359                        ),
3360                    });
3361                }
3362            }
3363        }
3364        // The board has been written, so a verification read that fails leaves it unverified
3365        // rather than unchanged, and says what to put back.
3366        let after = match self.board_snapshot(&owned).await {
3367            Ok(after) => after,
3368            Err(error) => {
3369                return Err(SourceError::Refused {
3370                    message: format!(
3371                        "the guarded field setup changed the {} field and then could not read the \
3372                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3373                        landed.join(" and "),
3374                        recovery(&report, &before)?
3375                    ),
3376                });
3377            }
3378        };
3379        let mut moved = Vec::new();
3380        for field in &report.fields {
3381            let name = field.field.name();
3382            let now = after
3383                .fields
3384                .get(&field.field)
3385                .map(|held| held.options.as_slice())
3386                .unwrap_or_default();
3387            if !field.existing.iter().all(|old| now.contains(old)) {
3388                moved.push(format!(
3389                    "a pre-existing {name} option id, name, color or description"
3390                ));
3391            }
3392            if !field.missing.iter().all(|wanted| {
3393                now.iter()
3394                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3395            }) {
3396                moved.push(format!("an added {name} option"));
3397            }
3398            if after.assignments(field.field) != before.assignments(field.field) {
3399                moved.push(format!("an item's {name} value"));
3400            }
3401        }
3402        if !moved.is_empty() {
3403            return Err(SourceError::Refused {
3404                message: format!(
3405                    "GitHub changed {} after the guarded field setup; the pre-write item \
3406                     assignments are:\n{}",
3407                    moved.join(", "),
3408                    recovery(&report, &before)?
3409                ),
3410            });
3411        }
3412        Ok(report)
3413    }
3414
3415    /// Validate configuration and capture the named credential without exposing it.
3416    ///
3417    /// # Errors
3418    ///
3419    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3420    /// [`SourceError::Auth`] when the named credential is missing or empty.
3421    pub fn new(
3422        name: &SourceName,
3423        config: GitHubProjectsConfig,
3424        secrets: &dyn SecretResolver,
3425    ) -> Result<Self, SourceError> {
3426        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3427    }
3428
3429    /// The same, recording every request it sends into an accounting the caller holds too.
3430    ///
3431    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3432    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3433    /// up — passes the one it records those into, so the session total accounts for the
3434    /// whole session rather than for this source's share of it.
3435    ///
3436    /// # Errors
3437    ///
3438    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3439    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3440    pub fn recording_into(
3441        name: &SourceName,
3442        config: GitHubProjectsConfig,
3443        secrets: &dyn SecretResolver,
3444        ledger: Arc<Accounting>,
3445    ) -> Result<Self, SourceError> {
3446        if !valid_github_owner(&config.owner) {
3447            return Err(SourceError::Config {
3448                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3449            });
3450        }
3451        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3452            return Err(SourceError::Config {
3453                message: format!("project_number must be between 1 and {}", i32::MAX),
3454            });
3455        }
3456        if !valid_environment_name(&config.token_env) {
3457            return Err(SourceError::Config {
3458                message: "token_env must be a valid environment-variable name".into(),
3459            });
3460        }
3461        let repository = config
3462            .repository
3463            .as_deref()
3464            .map(RepositoryTarget::parse)
3465            .transpose()?;
3466        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3467            message: format!("endpoint is not a valid URL: {e}"),
3468        })?;
3469        if endpoint.scheme() != "https"
3470            && !(endpoint.scheme() == "http"
3471                && endpoint
3472                    .host_str()
3473                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3474        {
3475            return Err(SourceError::Config {
3476                message:
3477                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3478                        .into(),
3479            });
3480        }
3481        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3482            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),
3483        })?;
3484        Ok(Self {
3485            name: name.clone(),
3486            owner: config.owner,
3487            project_number: config.project_number,
3488            repository,
3489            asset_client: assets::client(&endpoint)?,
3490            endpoint,
3491            token,
3492            credential_name: config.token_env,
3493            statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3494            priorities: config
3495                .priority_mapping
3496                .map(|mapping| PriorityMapping::resolve(mapping, name))
3497                .transpose()?,
3498            client: Client::builder()
3499                .user_agent("onetaskgraph")
3500                .build()
3501                .map_err(|e| SourceError::Config {
3502                    message: format!("cannot build HTTP client: {e}"),
3503                })?,
3504            created: Mutex::new(Vec::new()),
3505            updated: Mutex::new(Vec::new()),
3506            commented: Mutex::new(Vec::new()),
3507            pacing: Pacing::resolve(config.pacing, name)?,
3508            last_mutation: Mutex::new(None),
3509            clock: system_clock(),
3510            numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
3511            board_cache: Mutex::new(None),
3512            search_cache: Mutex::new(None),
3513            narrowed_cache: Mutex::new(BTreeMap::new()),
3514            resolved_cache: Mutex::new(BTreeMap::new()),
3515            search_next: Mutex::new(BTreeMap::new()),
3516            fields_cache: Mutex::new(None),
3517            repository_cache: Mutex::new(BTreeMap::new()),
3518            ledger,
3519        })
3520    }
3521
3522    /// A snapshot of every request this source has sent, and what each cost.
3523    ///
3524    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3525    /// of them can sit side by side. When this source was built with
3526    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3527    /// point of building it that way.
3528    #[must_use]
3529    pub fn accounting(&self) -> accounting::Session {
3530        self.ledger.snapshot()
3531    }
3532
3533    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3534    /// rate limit rather than handing it straight back as an error.
3535    ///
3536    /// Retrying is safe for every document here, including the mutations, and the reason
3537    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3538    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3539    /// this replays has already taken effect. An outcome this source cannot know — the
3540    /// send failed, or the body could not be read, so the mutation may well have landed —
3541    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3542    /// attempt. A duplicate write would come from replaying one of those, and none is
3543    /// replayed.
3544    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3545        if is_mutation(query)
3546            && ![
3547                graphql::ADD_COMMENT,
3548                graphql::UPDATE_COMMENT,
3549                graphql::DELETE_COMMENT,
3550            ]
3551            .contains(&query)
3552        {
3553            let mut cache = self.resolved_cache()?;
3554            for argument in ["input", "second", "third", "clear"] {
3555                if let Some(input) = variables.get(argument) {
3556                    cache.retain(|id, item| {
3557                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3558                            input
3559                                .get(key)
3560                                .and_then(Value::as_str)
3561                                .is_some_and(|value| value == id.0 || value == item.item_id)
3562                        })
3563                    });
3564                }
3565            }
3566        }
3567        let doing = operation_description(query);
3568        let mut waited = Duration::ZERO;
3569        let mut waits = 0_u32;
3570        let mut backoff = self.pacing.retry_backoff;
3571        loop {
3572            if is_mutation(query) {
3573                let spacing = self.reserve_mutation_slot();
3574                if !spacing.is_zero() {
3575                    self.clock.sleep(spacing).await;
3576                }
3577            }
3578            let attempt = self.send_once(query, &variables).await;
3579            if is_mutation(query) {
3580                self.finish_mutation();
3581            }
3582            let limited = match attempt {
3583                Ok(data) => return Ok(data),
3584                Err(Attempt::Failed(error)) => return Err(error),
3585                Err(Attempt::Limited(limited)) => limited,
3586            };
3587            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3588            // move that extends a secondary limit, so a hint below the schedule's own next
3589            // wait is raised to it.
3590            let wait = match limited.hint {
3591                Some(hint) => Duration::from_secs(hint).max(backoff),
3592                None => backoff,
3593            };
3594            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3595            // A wait of nothing spends none of the budget, so it is exhaustion rather
3596            // than a retry. `Pacing::resolve` rules out every way of configuring one
3597            // except a budget of zero, where reporting the first refusal is the ask.
3598            if wait.is_zero() || wait > remaining {
3599                return Err(limited.exhausted(
3600                    doing,
3601                    waits,
3602                    waited,
3603                    wait,
3604                    self.pacing.retry_budget,
3605                ));
3606            }
3607            self.clock.sleep(wait).await;
3608            waited += wait;
3609            waits += 1;
3610            backoff = backoff.saturating_mul(2);
3611        }
3612    }
3613
3614    /// The next moment a content-creating mutation may leave this source, as a wait from
3615    /// now.
3616    ///
3617    /// The slot is reserved under the lock and the waiting happens outside it, so two
3618    /// callers take two slots rather than the same one — and no lock is held across an
3619    /// await.
3620    ///
3621    /// The moment it is spaced from is the previous mutation's *completion*, which
3622    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3623    /// own is the wrong thing to measure from.
3624    fn reserve_mutation_slot(&self) -> Duration {
3625        if self.pacing.min_mutation_interval.is_zero() {
3626            return Duration::ZERO;
3627        }
3628        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3629        // it would turn an earlier failure into a second one for no gain.
3630        let mut last = self
3631            .last_mutation
3632            .lock()
3633            .unwrap_or_else(std::sync::PoisonError::into_inner);
3634        let now = self.clock.now();
3635        // `checked_add` rather than `+`: adding durations can panic on overflow, and
3636        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3637        let at = last.map_or(now, |previous| {
3638            previous
3639                .checked_add(self.pacing.min_mutation_interval)
3640                .map_or(now, |earliest| earliest.max(now))
3641        });
3642        *last = Some(at);
3643        at.saturating_sub(now)
3644    }
3645
3646    /// Record that a content-creating mutation has finished, so the next one is spaced
3647    /// from here rather than from the moment this one was released.
3648    ///
3649    /// This source can only choose when a request *departs*; the limiter counts when it
3650    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3651    /// departure from the last therefore hands the limiter a gap of the interval less that
3652    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3653    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3654    /// slower machine while passing on a quick one.
3655    ///
3656    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3657    /// previous request had already arrived before its response came back, so its arrival
3658    /// is no later than this moment, and the next mutation is released at least the
3659    /// interval after this moment and arrives no earlier than it is released: the gap the
3660    /// limiter measures is therefore at least the interval, whatever transit costs and on
3661    /// whatever platform. The price is that a mutation's own round trip no longer counts
3662    /// towards its spacing, which makes this source slightly slower than the configured
3663    /// rate rather than slightly faster — the safe side of a limit that punishes being
3664    /// wrong by refusing reads for the next fifty minutes.
3665    ///
3666    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3667    /// and one that never left costs only a wait nobody needed.
3668    fn finish_mutation(&self) {
3669        if self.pacing.min_mutation_interval.is_zero() {
3670            return;
3671        }
3672        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3673        let mut last = self
3674            .last_mutation
3675            .lock()
3676            .unwrap_or_else(std::sync::PoisonError::into_inner);
3677        let now = self.clock.now();
3678        // `max` rather than an assignment: a concurrent caller may already have reserved a
3679        // slot further out, and completing this request must never pull that slot back in.
3680        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3681    }
3682
3683    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3684    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3685    ///
3686    /// This is the one place a request leaves this crate, which is why the accounting is
3687    /// here rather than at each of the callers: a read path added later is counted without
3688    /// anybody remembering to count it, and
3689    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3690    /// when one is not.
3691    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3692        let Attempted {
3693            result,
3694            limits,
3695            reported_cost,
3696        } = self.attempt(query, variables).await;
3697        // No `otherwise` name: every document this source sends is one of its own, and the
3698        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3699        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3700        let outcome = match &result {
3701            Ok(_) => accounting::Outcome::Answered,
3702            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3703            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3704        };
3705        self.ledger.record(sending.finished(outcome, limits));
3706        result
3707    }
3708
3709    /// The attempt itself, with what its response said about the rate limit alongside.
3710    ///
3711    /// The two are returned together rather than recorded here because every one of the
3712    /// early exits below is a different outcome, and a record written at each of them is a
3713    /// record one of them can be added without.
3714    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3715        let mut limits = accounting::RateLimit::default();
3716        let mut reported_cost = None;
3717        let result = self
3718            .attempted(query, variables, &mut limits, &mut reported_cost)
3719            .await;
3720        Attempted {
3721            result,
3722            limits,
3723            reported_cost,
3724        }
3725    }
3726
3727    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3728    async fn attempted(
3729        &self,
3730        query: &str,
3731        variables: &Value,
3732        limits: &mut accounting::RateLimit,
3733        reported_cost: &mut Option<u64>,
3734    ) -> Result<Value, Attempt> {
3735        let response = self
3736            .client
3737            .post(self.endpoint.clone())
3738            .bearer_auth(self.token.expose_secret())
3739            .json(&json!({"query": query, "variables": variables}))
3740            .send()
3741            .await
3742            .map_err(|e| {
3743                Attempt::Failed(SourceError::Unavailable {
3744                    message: format!("GitHub GraphQL request failed: {e}"),
3745                })
3746            })?;
3747        let status = response.status();
3748        let header = |name: &str| whole_seconds(response.headers().get(name));
3749        *limits = accounting::RateLimit::read(|name| {
3750            response
3751                .headers()
3752                .get(name)
3753                .and_then(|value| value.to_str().ok())
3754                .map(str::to_owned)
3755        });
3756        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3757        // that are not text at all — is "not known to be exhausted". This never makes a
3758        // response a refusal on its own: it says which limiter a refusal is attributed to
3759        // and where its hint comes from, so a value this cannot read costs a hint rather
3760        // than an answer.
3761        let exhausted = response
3762            .headers()
3763            .get("x-ratelimit-remaining")
3764            .and_then(|value| value.to_str().ok())
3765            == Some("0");
3766        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3767        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3768        // which is the same question answered as an absolute time. Nothing else here is a
3769        // hint, and a schedule is what answers a refusal that carries none.
3770        let hint = header("retry-after").or_else(|| {
3771            exhausted
3772                .then(|| header("x-ratelimit-reset"))
3773                .flatten()
3774                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3775        });
3776        // Read before it is parsed, because the evidence which tells a secondary rate
3777        // limit from a rejected credential is in the body of a response whose status says
3778        // only "forbidden" — and a non-success response was never parsed at all.
3779        let body = response.text().await.map_err(|e| {
3780            Attempt::Failed(SourceError::Unavailable {
3781                message: format!("GitHub GraphQL response could not be read: {e}"),
3782            })
3783        })?;
3784        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3785            return Err(Attempt::Limited(Limited { limiter, hint }));
3786        }
3787        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3788            return Err(Attempt::Failed(SourceError::Auth {
3789                message: format!(
3790                    "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"
3791                ),
3792            }));
3793        }
3794        if !status.is_success() {
3795            return Err(Attempt::Failed(SourceError::Unavailable {
3796                message: format!("GitHub GraphQL returned HTTP {status}"),
3797            }));
3798        }
3799        // GitHub reports what a call cost only when the document asked it to, and no
3800        // document this source sends does — so this is `None` here and carries the figure
3801        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3802        // pick up is a `dryRun` probe's cost, which is some other document's.
3803        *reported_cost = serde_json::from_str::<Value>(&body)
3804            .ok()
3805            .as_ref()
3806            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3807            .and_then(Value::as_u64);
3808        self.answer(&body).map_err(Attempt::Failed)
3809    }
3810
3811    /// What one successful HTTP response says, once its GraphQL errors are read.
3812    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3813        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3814            message: format!("GitHub returned invalid JSON: {e}"),
3815        })?;
3816        let errors = body
3817            .get("errors")
3818            .map(|value| {
3819                value.as_array().ok_or_else(|| SourceError::Malformed {
3820                    message: "GitHub response errors is not an array".into(),
3821                })
3822            })
3823            .transpose()?;
3824        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3825            let messages = errors
3826                .iter()
3827                .filter_map(|e| e.get("message").and_then(Value::as_str))
3828                .collect::<Vec<_>>()
3829                .join("; ");
3830            let message = if messages.is_empty() {
3831                "GitHub returned GraphQL errors".into()
3832            } else {
3833                messages
3834            };
3835            let normalized = message.to_ascii_lowercase();
3836            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3837                return Err(SourceError::Auth {
3838                    message: format!(
3839                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3840                        self.credential_name
3841                    ),
3842                });
3843            }
3844            return Err(SourceError::Refused { message });
3845        }
3846        body.get("data")
3847            .filter(|data| data.is_object())
3848            .cloned()
3849            .ok_or_else(|| SourceError::Malformed {
3850                message: "GitHub response has no data object".into(),
3851            })
3852    }
3853
3854    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3855    // GraphQL cannot independently page them inside the outer item page. This source page is
3856    // deliberately bounded at that published maximum; the live drift journey exercises it.
3857    async fn board_page(
3858        &self,
3859        items_after: Option<&str>,
3860        items_first: u32,
3861    ) -> Result<Value, SourceError> {
3862        let data = self
3863            .graphql(
3864                graphql::BOARD,
3865                json!({"owner":self.owner,"number":self.project_number,
3866                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3867                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3868            )
3869            .await?;
3870        data.pointer("/owner/projectV2")
3871            .filter(|v| !v.is_null())
3872            .cloned()
3873            .ok_or_else(|| SourceError::Refused {
3874                message: format!(
3875                    "GitHub project {}/{} was not found or is not visible to the token",
3876                    self.owner, self.project_number
3877                ),
3878            })
3879    }
3880
3881    /// The search that finds the issues of this board, narrowed by `also` when it is
3882    /// given.
3883    ///
3884    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3885    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3886    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3887    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3888    /// from a task by the `parent` field each issue carries rather than by the search.
3889    fn board_search(&self, also: Option<&str>) -> String {
3890        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3891        match also {
3892            Some(also) => format!("{scope} {also}"),
3893            None => scope,
3894        }
3895    }
3896
3897    /// One issue this source reached directly, as the board item a read of the board would
3898    /// have produced — or `None` when this board does not hold it.
3899    ///
3900    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3901    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3902    /// item's own id, that item's field values, and the issue as its content. One resolver
3903    /// for both routes is what makes an issue read through a search, through its own node
3904    /// id, or through its project's sub-issues report the same title, the same status, the
3905    /// same labels and the same qualified id.
3906    ///
3907    /// An issue with no entry for *this* board is not this source's to report, which is
3908    /// what keeps an id naming some other repository's issue from being answered as an item
3909    /// of this board. That answer is given about an **exhausted** connection and never
3910    /// about an unread page: the entry is looked for on the page in hand, and only if that
3911    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3912    /// rest of it.
3913    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3914        if optional_str(issue, "__typename")? != Some("Issue") {
3915            return Ok(None);
3916        }
3917        let memberships = issue
3918            .get("projectItems")
3919            .ok_or_else(|| SourceError::Malformed {
3920                message: "GitHub issue is missing projectItems".into(),
3921            })?;
3922        let nodes = memberships
3923            .get("nodes")
3924            .and_then(Value::as_array)
3925            .ok_or_else(|| SourceError::Malformed {
3926                message: "GitHub issue projectItems.nodes is not an array".into(),
3927            })?;
3928        let held = match self.board_entry(nodes) {
3929            Some(held) => held.clone(),
3930            None => {
3931                let info = memberships
3932                    .get("pageInfo")
3933                    .ok_or_else(|| SourceError::Malformed {
3934                        message: "GitHub issue projectItems has no pageInfo".into(),
3935                    })?;
3936                // The page held no entry for this board. Whether that means the issue is
3937                // not on it is a question about the rest of the connection, and only a
3938                // connection with no rest answers it here.
3939                if !required_bool(info, "hasNextPage")? {
3940                    return Ok(None);
3941                }
3942                let cursor = required_str(info, "endCursor")?;
3943                validate_cursor_progress(None, cursor)?;
3944                let issue_id = required_str(issue, "id")?;
3945                match self.board_membership(issue_id, cursor).await? {
3946                    Some(held) => held,
3947                    None => return Ok(None),
3948                }
3949            }
3950        };
3951        let item = json!({
3952            "id": required_str(&held, "id")?,
3953            "project": held.get("project"),
3954            "fieldValues": held.get("fieldValues"),
3955            "content": issue,
3956        });
3957        self.resolve(&item)
3958    }
3959
3960    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3961    ///
3962    /// One spelling of *which membership is this board's*, so the page a read carries and
3963    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3964    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3965        nodes.iter().find(|node| {
3966            node.pointer("/project/number").and_then(Value::as_u64)
3967                == Some(u64::from(self.project_number))
3968        })
3969    }
3970
3971    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3972    ///
3973    /// The recovery read: a page of memberships that holds no entry for this board says
3974    /// nothing about the memberships past it, so the connection is walked to exhaustion
3975    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3976    /// positive answer — the whole connection was read and no entry named this board —
3977    /// rather than a failure, and the walk is held to
3978    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3979    /// with a cursor that does not advance is refused instead of spun on.
3980    async fn board_membership(
3981        &self,
3982        issue: &str,
3983        after: &str,
3984    ) -> Result<Option<Value>, SourceError> {
3985        let mut after = after.to_owned();
3986        loop {
3987            let data = self
3988                .graphql(
3989                    graphql::ISSUE_BOARD_ITEMS,
3990                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3991                           "nestedFirst":NESTED_PAGE_SIZE}),
3992                )
3993                .await?;
3994            let Some(connection) = data
3995                .pointer("/node/projectItems")
3996                .filter(|value| !value.is_null())
3997            else {
3998                // The id resolved to nothing, or to something with no memberships to walk —
3999                // which is the same answer as a connection holding no entry for this board.
4000                return Ok(None);
4001            };
4002            let nodes = connection
4003                .get("nodes")
4004                .and_then(Value::as_array)
4005                .ok_or_else(|| SourceError::Malformed {
4006                    message: "GitHub issue projectItems.nodes is not an array".into(),
4007                })?;
4008            if let Some(held) = self.board_entry(nodes) {
4009                return Ok(Some(held.clone()));
4010            }
4011            let info = connection
4012                .get("pageInfo")
4013                .ok_or_else(|| SourceError::Malformed {
4014                    message: "GitHub issue projectItems has no pageInfo".into(),
4015                })?;
4016            let next = required_bool(info, "hasNextPage")?
4017                .then(|| required_str(info, "endCursor"))
4018                .transpose()?;
4019            match next {
4020                Some(next) => {
4021                    validate_cursor_progress(Some(&after), next)?;
4022                    after = next.to_owned();
4023                }
4024                None => return Ok(None),
4025            }
4026        }
4027    }
4028
4029    /// One page of a board-scoped issue search, and where the next page resumes.
4030    async fn search_page(
4031        &self,
4032        search: &str,
4033        first: u32,
4034        after: Option<&str>,
4035    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
4036        let data = self
4037            .graphql(
4038                graphql::SEARCH_ISSUES,
4039                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
4040                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
4041                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4042            )
4043            .await?;
4044        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4045            message: "GitHub search response has no search connection".into(),
4046        })?;
4047        let mut found = Vec::new();
4048        for node in connection
4049            .get("nodes")
4050            .and_then(Value::as_array)
4051            .ok_or_else(|| SourceError::Malformed {
4052                message: "GitHub search nodes is not an array".into(),
4053            })?
4054        {
4055            if let Some(resolved) = self.resolve_issue(node).await? {
4056                found.push(resolved);
4057            }
4058        }
4059        let info = connection
4060            .get("pageInfo")
4061            .ok_or_else(|| SourceError::Malformed {
4062                message: "GitHub search connection has no pageInfo".into(),
4063            })?;
4064        let next = required_bool(info, "hasNextPage")?
4065            .then(|| required_str(info, "endCursor"))
4066            .transpose()?
4067            .map(str::to_owned);
4068        if let Some(next) = &next {
4069            validate_cursor_progress(after, next)?;
4070        }
4071        Ok((found, next))
4072    }
4073
4074    /// Every issue this board holds, completed with what this run wrote.
4075    ///
4076    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4077    /// is an index and is eventually consistent, so an issue this run created seconds ago
4078    /// can be absent from it, and a project listed straight after being written would
4079    /// otherwise be missing from its own board. What is added back is only what this
4080    /// process itself wrote, out of [`Self::created`], which lives and dies with the
4081    /// process.
4082    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4083        let found = self.searched_issues().await?;
4084        self.completed_with_written(found, |_| true)
4085    }
4086
4087    /// Every issue this board's own search reports, walked to exhaustion, read once per
4088    /// source.
4089    ///
4090    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4091    /// needs it too and the two would otherwise walk the same search twice in one command.
4092    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4093    /// is.
4094    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4095        let cached = self.search_cache()?.clone();
4096        if let Some(held) = cached {
4097            return Ok(held);
4098        }
4099        let mut after: Option<String> = None;
4100        let mut found = Vec::new();
4101        let search = self.board_search(None);
4102        loop {
4103            let (page, next) = self
4104                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4105                .await?;
4106            found.extend(page);
4107            match next {
4108                Some(next) => after = Some(next),
4109                None => break,
4110            }
4111        }
4112        *self.search_cache()? = Some(found.clone());
4113        Ok(found)
4114    }
4115
4116    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4117    fn search_cache(
4118        &self,
4119    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4120        self.search_cache
4121            .lock()
4122            .map_err(|_| SourceError::Unavailable {
4123                message: "this source's view of the board's issues was left inconsistent by an \
4124                      earlier failure; next: run the command again"
4125                    .into(),
4126            })
4127    }
4128
4129    /// `found`, with everything this run wrote that `keep` accepts and the read did not
4130    /// report.
4131    ///
4132    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4133    /// at all: the search index is behind, and a node read of an item filed moments ago can
4134    /// be too.
4135    fn completed_with_written(
4136        &self,
4137        mut found: Vec<Resolved>,
4138        keep: impl Fn(&Resolved) -> bool,
4139    ) -> Result<Vec<Resolved>, SourceError> {
4140        for own in self.created()?.iter().filter(|own| keep(own)) {
4141            if !found.iter().any(|item| item.id == own.id) {
4142                found.push(own.clone());
4143            }
4144        }
4145        Ok(found)
4146    }
4147
4148    /// What resolving one node id reached.
4149    ///
4150    /// Three answers rather than an `Option`, because a board *draft* is none of the other
4151    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4152    /// is completed by a read of the draft itself rather than reported as nothing.
4153    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4154        let asked = self
4155            .graphql(
4156                graphql::ISSUE,
4157                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4158                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4159            )
4160            .await;
4161        let data = match asked {
4162            Ok(data) => data,
4163            // A string that is not a node id at all is not a failure to report: it is an id
4164            // this board does not hold, which is what every read of one already answers.
4165            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4166            Err(error) => return Err(error),
4167        };
4168        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4169            return Ok(Reached::Nothing);
4170        };
4171        if optional_str(node, "__typename")? == Some("DraftIssue") {
4172            return Ok(Reached::Draft);
4173        }
4174        Ok(match self.resolve_issue(node).await? {
4175            Some(item) => Reached::Held(Box::new(item)),
4176            None => Reached::Nothing,
4177        })
4178    }
4179
4180    /// One item of this board by its own id, whatever kind it is.
4181    ///
4182    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4183    /// run wrote is read first, because a node read of an item created moments ago can
4184    /// still be behind the board field values written onto it — see [`Self::created`].
4185    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4186        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4187            return Ok(Some(own.clone()));
4188        }
4189        match self.reach(id).await? {
4190            Reached::Held(item) => Ok(Some(*item)),
4191            Reached::Nothing => Ok(None),
4192            Reached::Draft => self.draft_by_id(id).await,
4193        }
4194    }
4195
4196    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4197    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4198    /// than one request per id.
4199    ///
4200    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4201    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4202    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4203    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4204    /// is completed by a read of the draft, exactly as there.
4205    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4206        let mut found: Vec<Option<Option<Resolved>>> = {
4207            let created = self.created()?;
4208            ids.iter()
4209                .map(|id| {
4210                    created
4211                        .iter()
4212                        .find(|own| own.id == *id)
4213                        .map(|own| Some(own.clone()))
4214                })
4215                .collect()
4216        };
4217        let unread: Vec<NativeId> = ids
4218            .iter()
4219            .zip(&found)
4220            .filter(|(_, found)| found.is_none())
4221            .map(|(id, _)| id.clone())
4222            .collect();
4223        let mut read = Vec::with_capacity(unread.len());
4224        if let [one] = unread.as_slice() {
4225            read.push(self.item_by_id(one).await?);
4226        } else {
4227            for batch in unread.chunks(DETAIL_BATCH) {
4228                let data = match self
4229                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4230                    .await
4231                {
4232                    Ok(data) => data,
4233                    Err(error) if unresolvable_node(&error) => {
4234                        for id in batch {
4235                            read.push(self.item_by_id(id).await?);
4236                        }
4237                        continue;
4238                    }
4239                    Err(error) => return Err(error),
4240                };
4241                for (slot, id) in batch.iter().enumerate() {
4242                    let node =
4243                        data.get(format!("i{slot}"))
4244                            .ok_or_else(|| SourceError::Malformed {
4245                                message: format!(
4246                                    "GitHub answered a batch read with no item for {}",
4247                                    id.0
4248                                ),
4249                            })?;
4250                    read.push(if node.is_null() {
4251                        None
4252                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4253                        self.draft_by_id(id).await?
4254                    } else {
4255                        if optional_str(node, "__typename")? == Some("Issue")
4256                            && required_str(node, "id")? != id.0
4257                        {
4258                            return Err(SourceError::Malformed {
4259                                message: format!(
4260                                    "GitHub answered the read of {} with issue {}",
4261                                    id.0,
4262                                    required_str(node, "id")?
4263                                ),
4264                            });
4265                        }
4266                        self.resolve_issue(node).await?
4267                    });
4268                }
4269            }
4270        }
4271        let mut read = read.into_iter();
4272        Ok(found
4273            .iter_mut()
4274            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4275            .collect())
4276    }
4277
4278    fn resolved_cache(
4279        &self,
4280    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4281        self.resolved_cache
4282            .lock()
4283            .map_err(|_| SourceError::Unavailable {
4284                message: "resolved item records were left inconsistent; run the command again"
4285                    .into(),
4286            })
4287    }
4288
4289    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4290    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4291    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4292        let cached = self.resolved_cache()?.get(id).cloned();
4293        match cached {
4294            Some(item) => Ok(Some(item)),
4295            None => self.item_by_id(id).await,
4296        }
4297    }
4298
4299    /// One board draft by its own id, with the board item it sits in — or `None` when no
4300    /// item of this board is that draft's.
4301    ///
4302    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4303    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4304    /// links a draft to one board item, so the page this read carries is the whole of that
4305    /// connection, and a page that reports more than it holds is refused rather than read
4306    /// as an answer about memberships nobody read.
4307    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4308        let data = self
4309            .graphql(
4310                graphql::DRAFT,
4311                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4312                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4313            )
4314            .await?;
4315        // Gone between the two reads is an answer — the draft is no longer there. Anything
4316        // else than the draft [`Self::reach`] was just told this id is, is not one.
4317        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4318            return Ok(None);
4319        };
4320        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4321            return Err(SourceError::Malformed {
4322                message: format!(
4323                    "GitHub answered {} as a draft and then as something else",
4324                    id.0
4325                ),
4326            });
4327        }
4328        if required_str(draft, "id")? != id.0 {
4329            return Err(SourceError::Malformed {
4330                message: format!("GitHub answered a different draft for {}", id.0),
4331            });
4332        }
4333        let memberships = draft
4334            .get("projectV2Items")
4335            .ok_or_else(|| SourceError::Malformed {
4336                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4337            })?;
4338        let nodes = memberships
4339            .get("nodes")
4340            .and_then(Value::as_array)
4341            .ok_or_else(|| SourceError::Malformed {
4342                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4343            })?;
4344        let info = memberships
4345            .get("pageInfo")
4346            .ok_or_else(|| SourceError::Malformed {
4347                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4348            })?;
4349        // Read whether or not this board's entry is on the page: a page claiming more than
4350        // the one item GitHub links a draft to is a malformed answer either way.
4351        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4352            return Err(SourceError::Malformed {
4353                message: format!(
4354                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4355                     to",
4356                    id.0
4357                ),
4358            });
4359        }
4360        if let Some(node) = nodes.first()
4361            && node
4362                .pointer("/project/number")
4363                .and_then(Value::as_u64)
4364                .is_none()
4365        {
4366            return Err(SourceError::Malformed {
4367                message: format!(
4368                    "GitHub draft {} board item has no numeric project number",
4369                    id.0
4370                ),
4371            });
4372        }
4373        let Some(held) = self.board_entry(nodes) else {
4374            return Ok(None);
4375        };
4376        if required_str(
4377            held.get("project").ok_or_else(|| SourceError::Malformed {
4378                message: format!("GitHub draft {} board item has no project", id.0),
4379            })?,
4380            "id",
4381        )? != self.board_fields().await?.id.as_str()
4382        {
4383            return Ok(None);
4384        }
4385        let item = json!({
4386            "id": required_str(held, "id")?,
4387            "project": held.get("project"),
4388            "fieldValues": held.get("fieldValues"),
4389            "content": draft,
4390        });
4391        self.resolve(&item)
4392    }
4393
4394    /// The board's own id and field definitions, for a write whose item does not carry
4395    /// them — never its items.
4396    ///
4397    /// A board this command has already listed supplies them, since it read them beside its
4398    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4399    /// is consulted about which items the board holds: see the module documentation for
4400    /// why a question about one known item is answered by reading that item.
4401    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4402        if let Some(board) = self.board_cache()?.as_ref() {
4403            return Ok(BoardFields {
4404                id: BoardId::parse(&board.id)?,
4405                fields: board.fields.clone(),
4406            });
4407        }
4408        if let Some(held) = self.fields_cache()?.clone() {
4409            return Ok(held);
4410        }
4411        let data = self
4412            .graphql(
4413                graphql::BOARD_FIELDS,
4414                json!({"owner":self.owner,"number":self.project_number,
4415                       "nestedFirst":NESTED_PAGE_SIZE}),
4416            )
4417            .await?;
4418        self.fields_read(&data)
4419    }
4420
4421    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4422    /// the rest of this command.
4423    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4424        let board = data
4425            .pointer("/boardFields/projectV2")
4426            .filter(|value| !value.is_null())
4427            .ok_or_else(|| SourceError::Refused {
4428                message: format!(
4429                    "GitHub project {}/{} was not found or is not visible to the token",
4430                    self.owner, self.project_number
4431                ),
4432            })?;
4433        let read = BoardFields {
4434            id: BoardId::parse(required_str(board, "id")?)?,
4435            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4436        };
4437        *self.fields_cache()? = Some(read.clone());
4438        Ok(read)
4439    }
4440
4441    /// Read what creating an issue in `repository` needs and this command has not read yet —
4442    /// the board's fields and the repository's node id — in one request when it needs both.
4443    ///
4444    /// When either is already known this sends nothing, and the other is read by its own
4445    /// document where it is asked for, so no create reads anything twice.
4446    async fn creation_context(
4447        &self,
4448        repository: &RepositoryTarget,
4449        incoming: &Incoming<'_>,
4450    ) -> Result<(), SourceError> {
4451        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4452        if fields_known || self.repository_cache()?.contains_key(repository) {
4453            return Ok(());
4454        }
4455        let data = self
4456            .graphql(
4457                graphql::CREATION_CONTEXT,
4458                json!({"owner":self.owner,"number":self.project_number,
4459                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4460                       "repositoryName":repository.name}),
4461            )
4462            .await?;
4463        self.fields_read(&data)?;
4464        self.repository_read(&data, repository, incoming)?;
4465        Ok(())
4466    }
4467
4468    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4469    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4470        self.fields_cache
4471            .lock()
4472            .map_err(|_| SourceError::Unavailable {
4473                message: "this source's view of the board's fields was left inconsistent by an \
4474                      earlier failure; next: run the command again"
4475                    .into(),
4476            })
4477    }
4478
4479    /// What a write to `item` needs of the board, read off that item when it says enough and
4480    /// off [`Self::board_fields`] when it does not.
4481    ///
4482    /// A node read of an item names its board and carries the definition of every field it
4483    /// holds a value of — so an item naming its board, holding a value of the origin field,
4484    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4485    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4486    /// of may still be on the board, and a view reading it as absent would refuse a write the
4487    /// board can take or skip a field write the board needs, so such an item — and a create,
4488    /// which has no item yet — takes the board's fields from their own read instead.
4489    async fn fields_for(
4490        &self,
4491        item: Option<&Resolved>,
4492        writes_status: bool,
4493        selects_priority: bool,
4494    ) -> Result<BoardFields, SourceError> {
4495        if let Some(board) = item.and_then(Resolved::carried_board) {
4496            return Ok(board);
4497        }
4498        if let Some(item) = item
4499            && let Some(board_id) = item.named_board()
4500            && item.defines(ORIGIN_FIELD)
4501            && (!writes_status || item.defines("Status"))
4502            && (!selects_priority || item.defines(PRIORITY_FIELD))
4503        {
4504            return Ok(BoardFields {
4505                id: board_id,
4506                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4507            });
4508        }
4509        self.board_fields().await
4510    }
4511
4512    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4513    /// when that id names nothing here with a sub-issue relationship to walk.
4514    ///
4515    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4516    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4517    /// empty vector is a project that holds nothing.
4518    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4519        let mut after: Option<String> = None;
4520        let mut children = Vec::new();
4521        loop {
4522            let asked = self
4523                .graphql(
4524                    graphql::SUB_ISSUES,
4525                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4526                           "nestedFirst":NESTED_PAGE_SIZE,
4527                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4528                )
4529                .await;
4530            let data = match asked {
4531                Ok(data) => data,
4532                // A string that is not a node id at all is not a failure to report: it is
4533                // the ordinary answer to a selector naming a project by its name.
4534                Err(error) if unresolvable_node(&error) => return Ok(None),
4535                Err(error) => return Err(error),
4536            };
4537            let Some(connection) = data
4538                .pointer("/node/subIssues")
4539                .filter(|value| !value.is_null())
4540            else {
4541                // No such node, or one with no sub-issue relationship — a board draft is
4542                // the one this board can really hold.
4543                return Ok(None);
4544            };
4545            for node in connection
4546                .get("nodes")
4547                .and_then(Value::as_array)
4548                .ok_or_else(|| SourceError::Malformed {
4549                    message: "GitHub subIssues.nodes is not an array".into(),
4550                })?
4551            {
4552                if let Some(resolved) = self.resolve_issue(node).await? {
4553                    children.push(resolved);
4554                }
4555            }
4556            let info = connection
4557                .get("pageInfo")
4558                .ok_or_else(|| SourceError::Malformed {
4559                    message: "GitHub subIssues connection has no pageInfo".into(),
4560                })?;
4561            let next = required_bool(info, "hasNextPage")?
4562                .then(|| required_str(info, "endCursor"))
4563                .transpose()?;
4564            match next {
4565                Some(next) => {
4566                    validate_cursor_progress(after.as_deref(), next)?;
4567                    after = Some(next.to_owned());
4568                }
4569                None => return Ok(Some(children)),
4570            }
4571        }
4572    }
4573
4574    /// Which issue of this board a project *name* is, or `None` when none is.
4575    ///
4576    /// One bounded query which filters on that name at the server, rather than a walk of
4577    /// every issue the board holds. The name is compared again here: the qualifier narrows
4578    /// what GitHub sends, and this source decides what it names.
4579    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4580        let search = self.board_search(Some(&title_qualifier(name)));
4581        let mut after = None;
4582        loop {
4583            let (candidates, next) = self
4584                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4585                .await?;
4586            if let Some(item) = candidates.into_iter().find(|item| {
4587                item.kind == BoardKind::Work(ItemKind::Project)
4588                    && item.title.eq_ignore_ascii_case(name)
4589            }) {
4590                return Ok(Some(item.id));
4591            }
4592            match next {
4593                Some(next) => after = Some(next),
4594                None => return Ok(None),
4595            }
4596        }
4597    }
4598
4599    /// Everything filed under one project of this board: the sub-issues of the issue that
4600    /// project is.
4601    ///
4602    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4603    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4604    /// gains projects, or as another project gains tasks.
4605    ///
4606    /// A qualified id names the issue and is asked for its sub-issues directly: one
4607    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4608    /// read as a project *name*, which costs the one bounded search
4609    /// [`Self::project_by_name`] makes.
4610    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4611        let (project, children) = match self.sub_issues(selector).await? {
4612            Some(children) => (selector.clone(), children),
4613            None => match self.project_by_name(&selector.0).await? {
4614                Some(project) => {
4615                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4616                    (project, children)
4617                }
4618                None => return Ok(Vec::new()),
4619            },
4620        };
4621        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4622    }
4623
4624    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4625    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4626    ///
4627    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4628    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4629    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4630    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4631    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4632    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4633    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4634    /// rather than silently narrowing a caller's answer.
4635    ///
4636    /// The instant is written to the second, rounded down, which can only widen what the
4637    /// search returns; confirmation against each candidate's own comments is what makes the
4638    /// answer exact. The search is an index that lags a write by a second or two — the module
4639    /// documentation records it — so a caller that asks again from its last instant should
4640    /// overlap the two by more than that.
4641    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4642        let found = self.searched(&updated_qualifier(since)).await?;
4643        self.completed_with_written(found, |_| true)
4644    }
4645
4646    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4647    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4648    ///
4649    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4650    /// own record is the fresher of the two.
4651    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4652        let search = self.board_search(Some(also));
4653        let mut after: Option<String> = None;
4654        let mut found = Vec::new();
4655        loop {
4656            let (page, next) = self
4657                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4658                .await?;
4659            found.extend(page);
4660            match next {
4661                Some(next) => after = Some(next),
4662                None => return Ok(found),
4663            }
4664        }
4665    }
4666
4667    /// A bounded task answer; the versioned cursor carries the connection position, how
4668    /// many rows of the page starting there were already handed out, and the own-write ids
4669    /// already observed, including across a new source instance.
4670    ///
4671    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4672    /// sliced from the pages it needs; why is the module documentation's paging contract.
4673    async fn search_tasks(
4674        &self,
4675        query: &TaskQuery,
4676        page: &PageRequest,
4677        also: &str,
4678    ) -> Result<Page<Task>, SourceError> {
4679        let mut position = match &page.cursor {
4680            None => SearchPosition::default(),
4681            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4682                .ok()
4683                .filter(|position| {
4684                    position.version == SEARCH_CURSOR_VERSION
4685                        && position.connection.valid_resume(position.offset)
4686                })
4687                .ok_or_else(|| SourceError::Config {
4688                    message: "page cursor is invalid".into(),
4689                })?,
4690        };
4691        let search = self.board_search(Some(also));
4692        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4693        let own = self.with_own_writes(Vec::new())?;
4694        // An issue this process commented on is a candidate of a comment-activity read
4695        // whether or not the search has caught up with the comment; see `Self::commented`.
4696        let commented = match query.commented_since {
4697            Some(_) => self.commented()?.clone(),
4698            None => Vec::new(),
4699        };
4700        for id in own.iter().map(|item| &item.id).chain(&commented) {
4701            if !position.own.contains(id) {
4702                position.own.push(id.clone());
4703            }
4704        }
4705        let mut tasks = Vec::new();
4706        while !position.connection.exhausted() && tasks.len() < limit {
4707            let first = SEARCH_PAGE_SIZE;
4708            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4709            let key =
4710                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4711                    .expect("search page key is serializable");
4712            let cached = if query.commented_since.is_none() {
4713                self.narrowed_cache()?.get(&key).cloned()
4714            } else {
4715                None
4716            };
4717            let (found, next) = match cached {
4718                Some(found) => {
4719                    let next = self
4720                        .search_next
4721                        .lock()
4722                        .map_err(|_| SourceError::Unavailable {
4723                            message:
4724                                "search pagination was left inconsistent; run the command again"
4725                                    .into(),
4726                        })?
4727                        .get(&key)
4728                        .cloned()
4729                        .flatten();
4730                    (found, next)
4731                }
4732                None => {
4733                    let (found, next) = self
4734                        .search_page(&search, first, position.connection.after())
4735                        .await?;
4736                    if query.commented_since.is_none() {
4737                        self.search_next
4738                            .lock()
4739                            .map_err(|_| SourceError::Unavailable {
4740                                message:
4741                                    "search pagination was left inconsistent; run the command again"
4742                                        .into(),
4743                            })?
4744                            .insert(key.clone(), next.clone());
4745                        self.narrowed_cache()?.insert(key, found.clone());
4746                    }
4747                    (found, next)
4748                }
4749            };
4750            let rows = found.len();
4751            for mut item in found.into_iter().skip(position.offset) {
4752                if tasks.len() == limit {
4753                    break;
4754                }
4755                position.offset += 1;
4756                if position.own.contains(&item.id) {
4757                    if position.seen.contains(&item.id) {
4758                        continue;
4759                    }
4760                    position.seen.push(item.id.clone());
4761                    // The search's own copy of an issue this process only commented on is as
4762                    // good as a node read of it, since its comments are read either way.
4763                    let only_commented = commented.contains(&item.id)
4764                        && !own.iter().any(|written| written.id == item.id);
4765                    if !only_commented {
4766                        let updated_at = item.updated_at;
4767                        let Some(written) = self.search_written(&own, &item.id).await? else {
4768                            continue;
4769                        };
4770                        item = written;
4771                        item.updated_at = item.updated_at.max(updated_at);
4772                        self.resolved_cache()?.insert(item.id.clone(), item.clone());
4773                    }
4774                }
4775                if item.kind == BoardKind::Work(ItemKind::Task) {
4776                    let task = item.task()?;
4777                    if task_matches(&task, query, &query.project)
4778                        && self.commented_since(&item, query.commented_since).await?
4779                    {
4780                        tasks.push(task);
4781                    }
4782                }
4783            }
4784            if position.offset < rows {
4785                continue;
4786            }
4787            position.offset = 0;
4788            position.connection = match next {
4789                Some(after) => SearchConnection::Continuing {
4790                    after: Cursor(after),
4791                },
4792                None => SearchConnection::Exhausted {},
4793            };
4794        }
4795        if position.connection.exhausted() {
4796            for id in position.own.clone() {
4797                if position.seen.contains(&id) {
4798                    continue;
4799                }
4800                if tasks.len() == limit {
4801                    break;
4802                }
4803                position.seen.push(id.clone());
4804                let Some(item) = self.search_written(&own, &id).await? else {
4805                    continue;
4806                };
4807                if item.kind == BoardKind::Work(ItemKind::Task) {
4808                    let task = item.task()?;
4809                    if task_matches(&task, query, &query.project)
4810                        && self.commented_since(&item, query.commented_since).await?
4811                    {
4812                        tasks.push(task);
4813                    }
4814                }
4815            }
4816        }
4817        let more = !position.connection.exhausted()
4818            || position.own.iter().any(|id| !position.seen.contains(id));
4819        Ok(Page {
4820            items: tasks,
4821            next: more.then(|| {
4822                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4823            }),
4824        })
4825    }
4826
4827    /// A resumed process has the ids but no write records; resolve only a record the
4828    /// current page needs, by its uncached node read rather than the lagging search index.
4829    async fn search_written(
4830        &self,
4831        own: &[Resolved],
4832        id: &NativeId,
4833    ) -> Result<Option<Resolved>, SourceError> {
4834        match own.iter().find(|item| item.id == *id) {
4835            Some(item) => Ok(Some(item.clone())),
4836            None => self.item_by_id(id).await,
4837        }
4838    }
4839
4840    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4841    /// without enumerating the board — or `None` for a query carrying none of the three, which
4842    /// keeps the reads it always had.
4843    ///
4844    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4845    /// because it names at most a handful of items. Text and metadata are answered by one
4846    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4847    /// further by `updated:>=` when the query also asks for comment activity, since both
4848    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4849    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4850    ///
4851    /// Completed with what this process wrote, its own record winning over the index's copy
4852    /// of the same item: see [`Self::with_own_writes`].
4853    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4854        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4855            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4856            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4857                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4858                None => qualifiers,
4859            }),
4860            (None, None) => return Ok(None),
4861        };
4862        // A question about comment activity is asked afresh every time, as it always was: it
4863        // is the one a caller polls from one source while waiting for the index, and an
4864        // answer held from the first poll would be the answer to every later one.
4865        let key = query.commented_since.is_none().then(|| asked.key());
4866        let cached = match &key {
4867            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4868            None => None,
4869        };
4870        let found = match cached {
4871            Some(found) => found,
4872            None => {
4873                let found = match &asked {
4874                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4875                    Narrowing::Search(also) => self.searched(also).await?,
4876                };
4877                if let Some(key) = key {
4878                    self.narrowed_cache()?.insert(key, found.clone());
4879                }
4880                found
4881            }
4882        };
4883        self.with_own_writes(found).map(Some)
4884    }
4885
4886    /// The candidates for a project or unscoped document query carrying a searchable text,
4887    /// read without enumerating the board — or `None` for a query with no text or a blank one,
4888    /// which keeps the read it always had.
4889    ///
4890    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4891    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4892    /// costs is the issues that match and never the board. Its answer is held for the command
4893    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4894    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4895    /// substring rule, exactly as an item of the wider read was, and is completed with what this
4896    /// process wrote: see [`Self::with_own_writes`].
4897    async fn text_searched(
4898        &self,
4899        text: Option<&TextQuery>,
4900    ) -> Result<Option<Vec<Resolved>>, SourceError> {
4901        let Some(also) = text_qualifiers(text) else {
4902            return Ok(None);
4903        };
4904        let key = Narrowing::Search(also.clone()).key();
4905        let cached = self.narrowed_cache()?.get(&key).cloned();
4906        let found = match cached {
4907            Some(found) => found,
4908            None => {
4909                let found = self.searched(&also).await?;
4910                self.narrowed_cache()?.insert(key, found.clone());
4911                found
4912            }
4913        };
4914        self.with_own_writes(found).map(Some)
4915    }
4916
4917    /// Every item of this board that may carry `origin` — a superset of those that do — found
4918    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4919    ///
4920    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4921    /// which reads the field every carrier holds, whichever release wrote it — and the
4922    /// board-scoped issue search for the same id as a phrase in the body, where this source
4923    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4924    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4925    /// query's, exactly.
4926    ///
4927    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4928    /// already ended is sent its last cursor again, which answers an empty page, so the one
4929    /// document serves every page of either. What the two leave is stated in the module
4930    /// documentation: a carrier another process added within the last second or two, before
4931    /// either index has it.
4932    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4933        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4934        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4935        let mut items_after: Option<String> = None;
4936        let mut search_after: Option<String> = None;
4937        let mut found: Vec<Resolved> = Vec::new();
4938        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4939            if !found.iter().any(|held| held.id == resolved.id) {
4940                found.push(resolved);
4941            }
4942        };
4943        loop {
4944            let data = self
4945                .graphql(
4946                    graphql::ORIGIN_LOOKUP,
4947                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4948                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4949                           "itemsAfter":items_after,"searchAfter":search_after,
4950                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4951                           "duplicates":true}),
4952                )
4953                .await?;
4954            let items = data
4955                .pointer("/originItems/projectV2/items")
4956                .filter(|value| !value.is_null())
4957                .ok_or_else(|| SourceError::Refused {
4958                    message: format!(
4959                        "GitHub project {}/{} was not found or is not visible to the token",
4960                        self.owner, self.project_number
4961                    ),
4962                })?;
4963            for item in optional_nodes(Some(items), "project items")?
4964                .into_iter()
4965                .flatten()
4966            {
4967                // The board's own items list its drafts too, and a draft is not an issue: no
4968                // narrowed read answers with one, whatever its origin field holds.
4969                if let Some(resolved) = self.resolve(item)?
4970                    && resolved.content_kind == ContentKind::Issue
4971                {
4972                    keep(resolved, &mut found);
4973                }
4974            }
4975            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4976                message: "GitHub search response has no search connection".into(),
4977            })?;
4978            for node in optional_nodes(Some(searched), "search")?
4979                .into_iter()
4980                .flatten()
4981            {
4982                if let Some(resolved) = self.resolve_issue(node).await? {
4983                    keep(resolved, &mut found);
4984                }
4985            }
4986            let items_next = resumed(items, items_after.as_deref())?;
4987            let search_next = resumed(searched, search_after.as_deref())?;
4988            if !items_next.has_more() && !search_next.has_more() {
4989                return Ok(found);
4990            }
4991            items_after = items_next.cursor();
4992            search_after = search_next.cursor();
4993        }
4994    }
4995
4996    /// `found`, with every item this process created or wrote in its place, and every one of
4997    /// them the read did not report added.
4998    ///
4999    /// This process's own record wins over the read's copy of the same item, because a read
5000    /// of an item written moments ago can still be behind what was written onto it — the
5001    /// origin field included, which is the one a narrowed read is confirmed against — and a
5002    /// read that still names an item under a predicate this process's write moved it out of
5003    /// must not return it. The one thing the read knows that the record cannot is when GitHub
5004    /// last saw the item change, which is what a comment-activity read rules a candidate out
5005    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
5006    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
5007    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
5008        // A board draft is not an issue, so no narrowed read returns one, and this process
5009        // having written one does not make it an answer either.
5010        let own: Vec<Resolved> = self
5011            .created()?
5012            .iter()
5013            .chain(self.updated()?.iter())
5014            .filter(|own| own.content_kind == ContentKind::Issue)
5015            .cloned()
5016            .collect();
5017        for mut own in own {
5018            self.resolved_cache()?.insert(own.id.clone(), own.clone());
5019            match found.iter_mut().find(|read| read.id == own.id) {
5020                Some(read) => {
5021                    own.updated_at = own.updated_at.max(read.updated_at);
5022                    *read = own;
5023                }
5024                None => found.push(own),
5025            }
5026        }
5027        Ok(found)
5028    }
5029
5030    /// Whether `item` has a comment created or last edited at or after `since` — always, when
5031    /// there is no instant to hold it to.
5032    ///
5033    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
5034    /// or after the instant moved it there: an issue not updated since holds no such comment,
5035    /// and its comments are never asked for — unless this process commented on it in this
5036    /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
5037    /// only as far as the first that matches. A board draft is not an issue and has no
5038    /// comments, so it never matches.
5039    async fn commented_since(
5040        &self,
5041        item: &Resolved,
5042        since: Option<DateTime<Utc>>,
5043    ) -> Result<bool, SourceError> {
5044        let Some(since) = since else {
5045            return Ok(true);
5046        };
5047        if item.content_kind == ContentKind::DraftIssue {
5048            return Ok(false);
5049        }
5050        // An `updatedAt` this process's own record or a lagging index holds can predate a
5051        // comment this process wrote since, so only an issue it did not comment on is ruled
5052        // out by one.
5053        if item.updated_at.is_some_and(|updated| updated < since)
5054            && !self.commented()?.contains(&item.id)
5055        {
5056            return Ok(false);
5057        }
5058        let query = TaskQuery {
5059            commented_since: Some(since),
5060            ..TaskQuery::default()
5061        };
5062        let mut after: Option<String> = None;
5063        loop {
5064            let data = self
5065                .graphql(
5066                    graphql::ISSUE_COMMENTS,
5067                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5068                )
5069                .await?;
5070            let Some(connection) = data
5071                .get("node")
5072                .filter(|value| !value.is_null())
5073                .and_then(|node| node.get("comments"))
5074                .filter(|value| !value.is_null())
5075            else {
5076                // Removed since the search reported it: no longer an issue with comments.
5077                return Ok(false);
5078            };
5079            let comments = optional_nodes(Some(connection), "issue comments")?
5080                .into_iter()
5081                .flatten()
5082                .map(comment_from)
5083                .collect::<Result<Vec<_>, _>>()?;
5084            if query.comments_match(&comments) {
5085                return Ok(true);
5086            }
5087            match next_cursor(connection)? {
5088                Some(next) => {
5089                    validate_cursor_progress(after.as_deref(), &next.0)?;
5090                    after = Some(next.0);
5091                }
5092                None => return Ok(false),
5093            }
5094        }
5095    }
5096
5097    /// Every item on the board: the union of both enumerations GitHub offers of one.
5098    ///
5099    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5100    /// board **draft** and reads the board's own fields beside its items, and only the search
5101    /// reports an item that connection is behind on. The module documentation is where the lag and the
5102    /// measurements behind it are written down.
5103    ///
5104    /// A search result is admitted on the same terms as any other issue this source reaches
5105    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5106    /// names *this* board — so an issue the index still believes is here after it was taken
5107    /// off is refused rather than reported.
5108    ///
5109    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5110    /// which is what the cache could otherwise have broken.
5111    async fn board(&self) -> Result<Board, SourceError> {
5112        let cached = self.board_cache()?.clone();
5113        let mut board = match cached {
5114            Some(board) => board,
5115            None => {
5116                let read = self.read_board().await?;
5117                *self.board_cache()? = Some(read.clone());
5118                read
5119            }
5120        };
5121        for held in self.searched_issues().await? {
5122            if !board.items.iter().any(|item| item.id == held.id) {
5123                board.items.push(held);
5124            }
5125        }
5126        for own in self.created()?.iter() {
5127            if !board.items.iter().any(|item| item.id == own.id) {
5128                board.items.push(own.clone());
5129            }
5130        }
5131        Ok(board)
5132    }
5133
5134    /// This process's own view of the board, or the refusal a poisoned lock is.
5135    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5136        self.board_cache
5137            .lock()
5138            .map_err(|_| SourceError::Unavailable {
5139                message: "this source's view of the board was left inconsistent by an earlier \
5140                      failure; next: run the command again"
5141                    .into(),
5142            })
5143    }
5144
5145    /// Bring this process's own view of the board up to an item it has just written.
5146    ///
5147    /// A created item goes to `created`, which is what completes a board read GitHub's own
5148    /// eventual consistency has left behind. An item that was already there is replaced
5149    /// where it sits, so a second write of it in the same command reads its real parent
5150    /// rather than the one it had before the first write.
5151    ///
5152    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5153    /// that wins: an item this same run created is held in `created` and not in the cached
5154    /// board, and `board` completes the cached board *from* `created`, so replacing only
5155    /// the cached copy of such an item replaces nothing and the read still reports the
5156    /// title it was created with. The search is the third, and it is the one an item the
5157    /// board's own projection is behind on sits in *alone* — which is exactly the item this
5158    /// source is least able to re-read, so leaving it out would put the stale title back on
5159    /// the only items the completion in [`Self::board`] exists for.
5160    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5161        self.resolved_cache()?.insert(item.id.clone(), item.clone());
5162        if created {
5163            self.created()?.push(item);
5164            return Ok(());
5165        }
5166        {
5167            let mut own = self.created()?;
5168            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5169                *held = item;
5170                return Ok(());
5171            }
5172        }
5173        {
5174            let mut own = self.updated()?;
5175            match own.iter_mut().find(|held| held.id == item.id) {
5176                Some(held) => *held = item.clone(),
5177                None => own.push(item.clone()),
5178            }
5179        }
5180        if let Some(board) = self.board_cache()?.as_mut()
5181            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5182        {
5183            *held = item.clone();
5184        }
5185        if let Some(found) = self.search_cache()?.as_mut()
5186            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5187        {
5188            *held = item.clone();
5189        }
5190        for found in self.narrowed_cache()?.values_mut() {
5191            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5192                *held = item.clone();
5193            }
5194        }
5195        Ok(())
5196    }
5197
5198    /// Forget one item this process has just deleted, from every half of its own view.
5199    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5200        self.resolved_cache()?.remove(id);
5201        self.created()?.retain(|own| own.id != *id);
5202        self.updated()?.retain(|own| own.id != *id);
5203        self.commented()?.retain(|own| own != id);
5204        if let Some(board) = self.board_cache()?.as_mut() {
5205            board.items.retain(|item| item.id != *id);
5206        }
5207        if let Some(found) = self.search_cache()?.as_mut() {
5208            found.retain(|item| item.id != *id);
5209        }
5210        for found in self.narrowed_cache()?.values_mut() {
5211            found.retain(|item| item.id != *id);
5212        }
5213        Ok(())
5214    }
5215
5216    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5217    fn narrowed_cache(
5218        &self,
5219    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5220        self.narrowed_cache
5221            .lock()
5222            .map_err(|_| SourceError::Unavailable {
5223                message: "this source's view of a narrowed read was left inconsistent by an \
5224                      earlier failure; next: run the command again"
5225                    .into(),
5226            })
5227    }
5228
5229    /// Every page of the board, read from GitHub.
5230    async fn read_board(&self) -> Result<Board, SourceError> {
5231        let mut after: Option<String> = None;
5232        let mut items = Vec::new();
5233        let mut board;
5234        loop {
5235            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5236            for item in page
5237                .pointer("/items/nodes")
5238                .and_then(Value::as_array)
5239                .ok_or_else(|| SourceError::Malformed {
5240                    message: "GitHub project items.nodes is not an array".into(),
5241                })?
5242            {
5243                if let Some(resolved) = self.resolve(item)? {
5244                    items.push(resolved);
5245                }
5246            }
5247            let info = page
5248                .pointer("/items/pageInfo")
5249                .ok_or_else(|| SourceError::Malformed {
5250                    message: "GitHub project items have no pageInfo".into(),
5251                })?;
5252            let has_next = required_bool(info, "hasNextPage")?;
5253            let next = has_next
5254                .then(|| required_str(info, "endCursor"))
5255                .transpose()?;
5256            board = page.clone();
5257            match next {
5258                Some(next) => {
5259                    validate_cursor_progress(after.as_deref(), next)?;
5260                    after = Some(next.to_owned());
5261                }
5262                None => break,
5263            }
5264        }
5265        Ok(Board {
5266            id: required_str(&board, "id")?.to_owned(),
5267            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5268            items,
5269        })
5270    }
5271
5272    /// The existing items this source has written, for completing a narrowed read that is
5273    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5274    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5275        self.updated.lock().map_err(|_| SourceError::Unavailable {
5276            message: "this source's record of what it wrote in this run was left inconsistent \
5277                      by an earlier failure; next: run the command again"
5278                .into(),
5279        })
5280    }
5281
5282    /// The issues this source has commented on in this command; see
5283    /// [`Self::commented`](GitHubProjectsSource::commented).
5284    fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
5285        self.commented.lock().map_err(|_| SourceError::Unavailable {
5286            message: "this source's record of what it commented on in this run was left \
5287                      inconsistent by an earlier failure; next: run the command again"
5288                .into(),
5289        })
5290    }
5291
5292    /// Called only once GitHub has answered the comment write, so an issue whose comment
5293    /// failed is never made a candidate a later read would pay a node read for.
5294    fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
5295        let mut commented = self.commented()?;
5296        if !commented.contains(issue) {
5297            commented.push(issue.clone());
5298        }
5299        Ok(())
5300    }
5301
5302    /// The items this source has created, for completing a board read that is behind.
5303    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5304        self.created.lock().map_err(|_| SourceError::Unavailable {
5305            message: "this source's record of what it created in this run was left \
5306                      inconsistent by an earlier failure; next: run the command again"
5307                .into(),
5308        })
5309    }
5310
5311    /// One board item as this source reports it, or `None` for content it ignores.
5312    ///
5313    /// A pull request is neither a project nor a task — it is somebody's change, not a
5314    /// unit of plan — and an item whose content the token cannot see has nothing to
5315    /// report at all.
5316    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5317        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5318            message: "GitHub project item is missing content".into(),
5319        })?;
5320        if content.is_null() {
5321            return Ok(None);
5322        }
5323        let content_kind = match required_str(content, "__typename")? {
5324            "Issue" => ContentKind::Issue,
5325            "DraftIssue" => ContentKind::DraftIssue,
5326            _ => return Ok(None),
5327        };
5328        let field_values = item
5329            .get("fieldValues")
5330            .ok_or_else(|| SourceError::Malformed {
5331                message: "GitHub project item is missing fieldValues".into(),
5332            })?;
5333        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5334        let nodes = field_values
5335            .get("nodes")
5336            .and_then(Value::as_array)
5337            .ok_or_else(|| SourceError::Malformed {
5338                message: "GitHub project item fieldValues.nodes is not an array".into(),
5339            })?;
5340        if let Some(labels) = content.get("labels") {
5341            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5342        }
5343        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5344        let (body, slot) = metadata_body(raw_body.clone())?;
5345        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5346            .map(|id| NativeId(id.to_owned()));
5347        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5348        // to read one from; it is a task, and never a project.
5349        let sub_issues = match content_kind {
5350            ContentKind::Issue => sub_issue_total(content)?,
5351            ContentKind::DraftIssue => 0,
5352        };
5353        let content_id = required_str(content, "id")?;
5354        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5355            message: format!("GitHub issue {content_id}: {message}"),
5356        })?;
5357        let raw_title = required_str(content, "title")?;
5358        // The design prefix is read *first*, before either of the two rules that separate
5359        // a project from a task. A document is not work whatever sub-issues it has and
5360        // whatever marker it carries, and reading the prefix later would make a design
5361        // issue with none of either an empty project.
5362        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5363            BoardKind::Document
5364        } else if parent.is_some() {
5365            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5366            // under a project is that project's task even when it has sub-issues of its
5367            // own.
5368            BoardKind::Work(ItemKind::Task)
5369        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5370            BoardKind::Work(ItemKind::Project)
5371        } else {
5372            BoardKind::Work(ItemKind::Task)
5373        };
5374        // The title a person wrote, which for a document is the one without the prefix —
5375        // the same way `content` above is the body without this source's metadata slot.
5376        let title = match kind {
5377            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5378            BoardKind::Work(_) => raw_title.to_owned(),
5379        };
5380        let own_repository = content
5381            .pointer("/repository/nameWithOwner")
5382            .and_then(Value::as_str)
5383            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5384            .transpose()
5385            .map_err(|message| SourceError::Malformed { message })?;
5386        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5387            Repository::from_metadata(&slot)
5388                .map_err(|message| SourceError::Malformed { message })?
5389        } else {
5390            own_repository.clone().into_iter().collect()
5391        };
5392        let id = NativeId(content_id.to_owned());
5393        // Read only for a task, because only a task has either list: a project or a
5394        // document holding one of these keys holds nothing this source reports, and the
5395        // keys are left out of its caller-visible metadata all the same.
5396        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5397            let listed = |key: &str| {
5398                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5399                    .map_err(|message| SourceError::Malformed { message })
5400            };
5401            (
5402                listed(TaskRef::DELIVERS_KEY)?,
5403                listed(TaskRef::DELIVERED_BY_KEY)?,
5404            )
5405        } else {
5406            (Vec::new(), Vec::new())
5407        };
5408        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5409        let priority = self.held_priority(nodes)?;
5410        // Present when the item was reached through its own issue, whose board entry
5411        // names the board; a read of the board's own items has the board already. An
5412        // empty id names nothing a field write could address, so it is read as absent and
5413        // the write goes back to reading the board.
5414        let board_id = item
5415            .pointer("/project/id")
5416            .and_then(Value::as_str)
5417            .filter(|id| !id.is_empty());
5418        let resolved = Resolved {
5419            item_id: required_str(item, "id")?.to_owned(),
5420            id,
5421            content_kind,
5422            kind,
5423            title,
5424            body: body.filter(|value| !value.is_empty()),
5425            raw_body,
5426            status: self
5427                .statuses
5428                .status(kind.status_kind(), option, closed, reason),
5429            option: option.map(str::to_owned),
5430            priority,
5431            closed,
5432            delivers,
5433            delivered_by,
5434            labels: labels(content)?,
5435            parent,
5436            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5437            number: match content_kind {
5438                ContentKind::Issue => Some(issue_number(content)?),
5439                // A draft is filed in no repository, so nothing ever numbered it:
5440                // `DraftIssue` declares no `number` at all, exactly as it declares no
5441                // `subIssuesSummary` the branch above reads.
5442                ContentKind::DraftIssue => None,
5443            },
5444            url: optional_str(content, "url")?.map(str::to_owned),
5445            created_at: optional_time(content, "createdAt")?,
5446            updated_at: optional_time(content, "updatedAt")?,
5447            own_repository,
5448            repositories,
5449            slot,
5450            board_id: board_id.map(str::to_owned),
5451            fields: field_definitions(nodes),
5452            board_fields: Self::carried_board_fields(content, board_id)?,
5453            blocked_by: carried_blocked_by(content)?,
5454        };
5455        self.resolved_cache()?
5456            .insert(resolved.id.clone(), resolved.clone());
5457        Ok(Some(resolved))
5458    }
5459
5460    /// The field definitions of the board `board_id` names — the project this issue's own
5461    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5462    /// `None` when the read carried none, carried no entry for that board, or the board item
5463    /// named no board, which a write then answers by reading the board's fields itself.
5464    ///
5465    /// Matched by the board's node id and never by its number alone: a project number is
5466    /// unique only within its owner, so another owner's board numbered alike can sit on the
5467    /// same page, and its field and option ids address nothing on this one.
5468    fn carried_board_fields(
5469        content: &Value,
5470        board_id: Option<&str>,
5471    ) -> Result<Option<Value>, SourceError> {
5472        let (Some(nodes), Some(board_id)) = (
5473            content.pointer("/boards/nodes").and_then(Value::as_array),
5474            board_id,
5475        ) else {
5476            return Ok(None);
5477        };
5478        let Some(board) = nodes.iter().find_map(|node| {
5479            let project = node.get("project")?;
5480            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5481        }) else {
5482            return Ok(None);
5483        };
5484        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5485            return Ok(None);
5486        };
5487        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5488        Ok(Some(fields.clone()))
5489    }
5490
5491    /// What one board item's `Priority` field says, through this instance's mapping.
5492    ///
5493    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5494    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5495    /// option the mapping does not name is kept as itself — never read as a level or as
5496    /// `none` — for a read of the task to report by name.
5497    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5498        let Some(mapping) = &self.priorities else {
5499            return Ok(HeldPriority::Read(Priority::None));
5500        };
5501        // A value of the field that names no option — a text field someone called `Priority` —
5502        // is malformed rather than `none`: reading it as no priority would let the next copy
5503        // clear one a person set.
5504        let Some(option) = field_values
5505            .iter()
5506            .find(|value| {
5507                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5508            })
5509            .map(|value| required_str(value, "name"))
5510            .transpose()?
5511        else {
5512            return Ok(HeldPriority::Read(Priority::None));
5513        };
5514        Ok(mapping.priority_of(option).map_or_else(
5515            || HeldPriority::Unmapped(option.to_owned()),
5516            HeldPriority::Read,
5517        ))
5518    }
5519
5520    /// What one board item's status is read from: its `Status` option, whether its issue
5521    /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5522    /// three into the status it reports.
5523    fn status_parts<'a>(
5524        field_values: &'a [Value],
5525        content: &'a Value,
5526    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5527        let option = field_values
5528            .iter()
5529            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5530            .map(|value| required_str(value, "name"))
5531            .transpose()?;
5532        let closed = optional_str(content, "state")? == Some("CLOSED");
5533        Ok((option, closed, optional_str(content, "stateReason")?))
5534    }
5535
5536    /// The board Status option this write selects, or the refusal that says why not.
5537    ///
5538    /// The mapped option is required for both open and terminal targets. A terminal write
5539    /// validates it before changing either representation, so it can never fall back to
5540    /// closing an issue whose board cannot display the matching status.
5541    ///
5542    /// Answers the field's id, the option's id, and the option's name as the board spells
5543    /// it — which is the name a read of the item reports once it sits there.
5544    fn column_for(
5545        &self,
5546        fields: &Value,
5547        kind: ItemKind,
5548        category: StatusCategory,
5549        target: &StatusTarget,
5550    ) -> Result<Option<(String, String, String)>, SourceError> {
5551        let Some(wanted) = target.option() else {
5552            return Ok(None);
5553        };
5554        let missing = |detail: &str| SourceError::Refused {
5555            message: format!(
5556                "{} status {} of source {} needs the board Status option {wanted:?}, and \
5557                 {detail}; next: add that option to the board, which `onetaskgraph sources \
5558                 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5559                 it has",
5560                kind.marker(),
5561                category_name(category),
5562                self.name,
5563                self.name,
5564                category_name(category),
5565                kind.marker()
5566            ),
5567        };
5568        let Some(field) = Board::field(fields, "Status")? else {
5569            return Err(missing("this board has no Status field"));
5570        };
5571        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5572            return Err(missing(
5573                "this board's Status field is not a single-select field",
5574            ));
5575        }
5576        let option = field
5577            .get("options")
5578            .and_then(Value::as_array)
5579            .and_then(|options| {
5580                options.iter().find(|option| {
5581                    option
5582                        .get("name")
5583                        .and_then(Value::as_str)
5584                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5585                })
5586            });
5587        match option {
5588            None => Err(missing("this board does not have it")),
5589            Some(option) => Ok(Some((
5590                required_str(field, "id")?.to_owned(),
5591                required_str(option, "id")?.to_owned(),
5592                required_str(option, "name")?.to_owned(),
5593            ))),
5594        }
5595    }
5596
5597    /// The refusal a status that closes an issue is answered with over a board draft.
5598    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5599        SourceError::Refused {
5600            message: format!(
5601                "status {} of source {} closes the item's issue, and GitHub draft items have \
5602                 no open or closed state",
5603                category_name(category),
5604                self.name
5605            ),
5606        }
5607    }
5608
5609    /// What a status write to one item needs of the board: the board's id and the
5610    /// definition of its `Status` field, read off the item when the item says both.
5611    ///
5612    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5613    /// and its `Status` value carries that field's definition, options and all. An item that
5614    /// does not say — no board id, or no `Status` value to read the field off — takes them
5615    /// from [`Self::board_fields`], which reads no item.
5616    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5617        if let Some(board) = item.carried_board() {
5618            return Ok(board);
5619        }
5620        if item.defines("Status")
5621            && let Some(board_id) = item.named_board()
5622        {
5623            return Ok(BoardFields {
5624                id: board_id,
5625                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5626            });
5627        }
5628        self.board_fields().await
5629    }
5630
5631    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5632    async fn set_status(
5633        &self,
5634        id: &NativeId,
5635        category: StatusCategory,
5636    ) -> Result<Option<Status>, SourceError> {
5637        // Refused before anything is read, in the words a write of the same status is.
5638        let target = self.resolved_target(ItemKind::Task, category)?;
5639        let Some(mut item) = self
5640            .bound_item(id)
5641            .await?
5642            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5643        else {
5644            return Ok(None);
5645        };
5646        let board = self.status_board(&item).await?;
5647        let (field, option, name) = self
5648            .column_for(&board.fields, ItemKind::Task, category, &target)?
5649            .ok_or_else(|| SourceError::Malformed {
5650                message: format!(
5651                    "status {} of source {} names no board Status option",
5652                    category_name(category),
5653                    self.name
5654                ),
5655            })?;
5656        if item.status.category == category && item.option.as_deref() == Some(&name) {
5657            return Ok(Some(item.status));
5658        }
5659        match &target {
5660            StatusTarget::Terminal(_, reason) => {
5661                if item.content_kind == ContentKind::DraftIssue {
5662                    return Err(self.closes_a_draft(category));
5663                }
5664                self.set_item_field(
5665                    board.id.as_str(),
5666                    &item.item_id,
5667                    &field,
5668                    json!({"singleSelectOptionId": option}),
5669                )
5670                .await?;
5671                self.update_content(
5672                    ContentKind::Issue,
5673                    &item.id,
5674                    json!({"stateInput": state_input(Some(&target))}),
5675                )
5676                .await?;
5677                item.closed = true;
5678                item.status =
5679                    self.statuses
5680                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5681                item.option = Some(name);
5682            }
5683            StatusTarget::Column(_) => {
5684                // An option is what an open item's status is, so a closed issue is reopened
5685                // first — sitting closed in the column, it would read back as closed. A draft has
5686                // no state to reopen.
5687                if item.content_kind == ContentKind::Issue && item.closed {
5688                    self.update_content(
5689                        ContentKind::Issue,
5690                        &item.id,
5691                        json!({"stateInput": state_input(Some(&target))}),
5692                    )
5693                    .await?;
5694                    item.closed = false;
5695                }
5696                self.set_item_field(
5697                    board.id.as_str(),
5698                    &item.item_id,
5699                    &field,
5700                    json!({"singleSelectOptionId": option}),
5701                )
5702                .await?;
5703                item.status = self
5704                    .statuses
5705                    .status(ItemKind::Task, Some(&name), false, None);
5706                item.option = Some(name);
5707            }
5708            StatusTarget::Disabled(_) => {
5709                unreachable!("resolved_target refused a disabled status")
5710            }
5711        }
5712        let status = item.status.clone();
5713        self.remember_written(item, false)?;
5714        Ok(Some(status))
5715    }
5716
5717    /// Replace one task's `delivered_by` and nothing else; see
5718    /// [`TaskSource::set_delivered_by`].
5719    ///
5720    /// One update of the body, which differs from the body GitHub holds only inside the
5721    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5722    async fn replace_delivered_by(
5723        &self,
5724        id: &NativeId,
5725        delivered_by: &[TaskRef],
5726    ) -> Result<Option<()>, SourceError> {
5727        let entries = TaskRef::listed(
5728            TaskRef::DELIVERED_BY_KEY,
5729            id,
5730            Some(&self.name),
5731            delivered_by.to_vec(),
5732        )
5733        .map_err(|message| SourceError::Refused { message })?;
5734        let Some(mut item) = self
5735            .bound_item(id)
5736            .await?
5737            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5738        else {
5739            return Ok(None);
5740        };
5741        let mut slot = item.slot.clone();
5742        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5743        self.write_slot(&mut item, &slot).await?;
5744        item.delivered_by = entries;
5745        self.remember_written(item, false)?;
5746        Ok(Some(()))
5747    }
5748
5749    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5750    /// see [`TaskSource::set_task_metadata`].
5751    ///
5752    /// `None` when this board holds no item by that id, or holds one of another kind. The
5753    /// answer is the item as this source now reads it, so what a caller is told the key
5754    /// holds is what the slot holds.
5755    ///
5756    /// A key already holding the value is answered without a write, compared as JSON rather
5757    /// than as the body's bytes: a slot a person spelled with other whitespace would
5758    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5759    async fn set_slot_key(
5760        &self,
5761        id: &NativeId,
5762        kind: BoardKind,
5763        key: &MetadataKey,
5764        value: &Value,
5765    ) -> Result<Option<Resolved>, SourceError> {
5766        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5767            return Ok(None);
5768        };
5769        if item.slot.get(key.as_str()) == Some(value) {
5770            return Ok(Some(item));
5771        }
5772        let mut slot = item.slot.clone();
5773        slot.insert(key.as_str().to_owned(), value.clone());
5774        self.write_slot(&mut item, &slot).await?;
5775        self.remember_written(item.clone(), false)?;
5776        Ok(Some(item))
5777    }
5778
5779    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5780    /// `item` up to what that write left.
5781    ///
5782    /// The body sent differs from the body GitHub holds only inside the slot — see
5783    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5784    /// the mutation the item's content takes, so a board draft's body is written with
5785    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5786    async fn write_slot(
5787        &self,
5788        item: &mut Resolved,
5789        slot: &BTreeMap<String, Value>,
5790    ) -> Result<(), SourceError> {
5791        let held = item.raw_body.clone().unwrap_or_default();
5792        let body = with_slot(&held, slot)?;
5793        if body != held {
5794            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5795                .await?;
5796        }
5797        let (visible, slot) = metadata_body(Some(body.clone()))?;
5798        item.body = visible.filter(|value| !value.is_empty());
5799        item.raw_body = Some(body);
5800        item.slot = slot;
5801        Ok(())
5802    }
5803
5804    /// This instance's target for a category written to an item of `kind`, refusing one
5805    /// that kind has no option for — before anything is read or written.
5806    ///
5807    /// Nothing here mutates the board's option set to make room for a status. GitHub
5808    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5809    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5810    /// field and every item's status.
5811    fn resolved_target(
5812        &self,
5813        kind: ItemKind,
5814        category: StatusCategory,
5815    ) -> Result<StatusTarget, SourceError> {
5816        let target = self.statuses.target(kind, category).clone();
5817        let StatusTarget::Disabled(why) = target else {
5818            return Ok(target);
5819        };
5820        let refusal = why.refusal(&self.name, category, kind);
5821        // Why there is no shipped default, which is the question a person meeting this
5822        // refusal on a source that never mentioned the category asks.
5823        let shipped_none = match category {
5824            StatusCategory::Draft => Some(
5825                "draft has no shipped default because GitHub draft issues cannot have \
5826                 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5827            ),
5828            StatusCategory::Unknown => Some(
5829                "unknown has no shipped default because this board keeps no open-ended status \
5830                 word: every word classified unknown is written to the one board Status option \
5831                 status_mapping.unknown names",
5832            ),
5833            _ => None,
5834        };
5835        Err(match (refusal, shipped_none, why) {
5836            (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5837                SourceError::Refused {
5838                    message: format!("{message}; {note}"),
5839                }
5840            }
5841            (refusal, _, _) => refusal,
5842        })
5843    }
5844
5845    /// What writing `priority` does to one item's `Priority` field on this board, or the
5846    /// refusal naming what the board lacks.
5847    ///
5848    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5849    /// none already, or of an item not created yet. Every other priority selects the option
5850    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5851    /// without that option, is refused rather than given one: reads and writes never create
5852    /// a field or an option.
5853    fn priority_write(
5854        &self,
5855        fields: &Value,
5856        existing: Option<&Resolved>,
5857        priority: Priority,
5858    ) -> Result<Option<PriorityWrite>, SourceError> {
5859        let Some(mapping) = &self.priorities else {
5860            return Err(self.holds_no_priority());
5861        };
5862        let Some(wanted) = mapping.option(priority) else {
5863            if !existing.is_some_and(Resolved::holds_priority) {
5864                return Ok(None);
5865            }
5866            let field =
5867                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5868                    message: format!(
5869                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5870                    ),
5871                })?;
5872            return Ok(Some(PriorityWrite::Clear {
5873                field: required_str(field, "id")?.to_owned(),
5874            }));
5875        };
5876        let missing = |detail: &str| SourceError::Refused {
5877            message: format!(
5878                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5879                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5880                 it, or point priority_mapping.{priority} of this source at an option the board \
5881                 has",
5882                self.name, self.name
5883            ),
5884        };
5885        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5886            return Err(missing(&format!(
5887                "this board has no {PRIORITY_FIELD} field"
5888            )));
5889        };
5890        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5891            return Err(missing(&format!(
5892                "this board's {PRIORITY_FIELD} field is not a single-select field"
5893            )));
5894        }
5895        // An options list that is absent or not a list is an answer this source cannot read,
5896        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5897        let option = field
5898            .get("options")
5899            .and_then(Value::as_array)
5900            .ok_or_else(|| SourceError::Malformed {
5901                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5902            })?
5903            .iter()
5904            .find(|option| {
5905                option
5906                    .get("name")
5907                    .and_then(Value::as_str)
5908                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5909            })
5910            .ok_or_else(|| missing("this board does not have it"))?;
5911        Ok(Some(PriorityWrite::Select {
5912            field: required_str(field, "id")?.to_owned(),
5913            option: required_str(option, "id")?.to_owned(),
5914        }))
5915    }
5916
5917    /// Apply one priority write to one board item.
5918    async fn write_priority(
5919        &self,
5920        board_id: &str,
5921        item_id: &str,
5922        write: &PriorityWrite,
5923    ) -> Result<(), SourceError> {
5924        match write {
5925            PriorityWrite::Select { field, option } => {
5926                self.set_item_field(
5927                    board_id,
5928                    item_id,
5929                    field,
5930                    json!({"singleSelectOptionId": option}),
5931                )
5932                .await
5933            }
5934            PriorityWrite::Clear { field } => {
5935                let data = self
5936                    .graphql(
5937                        graphql::CLEAR_FIELD,
5938                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5939                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5940                    )
5941                    .await?;
5942                let returned = data
5943                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5944                    .ok_or_else(|| SourceError::Malformed {
5945                        message: "GitHub field clear returned no project item".into(),
5946                    })?;
5947                if required_str(returned, "id")? != item_id {
5948                    return Err(SourceError::Malformed {
5949                        message: "GitHub field clear returned the wrong project item".into(),
5950                    });
5951                }
5952                Ok(())
5953            }
5954        }
5955    }
5956
5957    /// The refusal a priority is answered with by an instance configured with no
5958    /// `priority_mapping`, which holds none.
5959    fn holds_no_priority(&self) -> SourceError {
5960        SourceError::Refused {
5961            message: format!(
5962                "source {} holds no task priority: its configuration sets no priority_mapping; \
5963                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5964                 fields {} --apply` to set its board up",
5965                self.name, self.name
5966            ),
5967        }
5968    }
5969
5970    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5971    ///
5972    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5973    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5974    async fn set_priority(
5975        &self,
5976        id: &NativeId,
5977        priority: Priority,
5978    ) -> Result<Option<Priority>, SourceError> {
5979        if self.priorities.is_none() {
5980            return Err(self.holds_no_priority());
5981        }
5982        let Some(mut item) = self
5983            .bound_item(id)
5984            .await?
5985            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5986        else {
5987            return Ok(None);
5988        };
5989        if priority == Priority::None && !item.holds_priority() {
5990            return Ok(Some(priority));
5991        }
5992        // The item's own read carries the field's definition whenever it holds a value of
5993        // it, which a clear always does; a select onto an item holding none reads the board.
5994        let board = match (item.carried_board(), item.named_board()) {
5995            (Some(board), _) => board,
5996            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5997                id,
5998                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5999            },
6000            _ => self.board_fields().await?,
6001        };
6002        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
6003            return Ok(Some(priority));
6004        };
6005        let (document, root, input) = match write {
6006            PriorityWrite::Select { field, option } => (
6007                graphql::UPDATE_FIELD,
6008                "updateProjectV2ItemFieldValue",
6009                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
6010            ),
6011            PriorityWrite::Clear { field } => (
6012                graphql::CLEAR_FIELD,
6013                "clearProjectV2ItemFieldValue",
6014                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
6015            ),
6016        };
6017        let data = self
6018            .graphql(
6019                document,
6020                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
6021            )
6022            .await?;
6023        let returned = data
6024            .get(root)
6025            .and_then(|value| value.get("projectV2Item"))
6026            .ok_or_else(|| SourceError::Malformed {
6027                message: "GitHub priority write returned no project item".into(),
6028            })?;
6029        if required_str(returned, "id")? != item.item_id {
6030            return Err(SourceError::Malformed {
6031                message: "GitHub priority write returned the wrong project item".into(),
6032            });
6033        }
6034        let value = returned
6035            .get("fieldValueByName")
6036            .ok_or_else(|| SourceError::Malformed {
6037                message: "GitHub priority write returned no priority read-back".into(),
6038            })?;
6039        if !value.is_null()
6040            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
6041        {
6042            return Err(SourceError::Malformed {
6043                message: "GitHub priority read-back is not a Priority field value".into(),
6044            });
6045        }
6046        let values = if value.is_null() {
6047            Vec::new()
6048        } else {
6049            vec![value.clone()]
6050        };
6051        item.priority = self.held_priority(&values)?;
6052        let answer = item.task()?.priority;
6053        self.remember_written(item, false)?;
6054        Ok(Some(answer))
6055    }
6056
6057    /// Replace one task's visible body and nothing else; see
6058    /// [`TaskSource::set_task_content`].
6059    ///
6060    /// One update of the body, which differs from the body GitHub holds only outside the
6061    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
6062    /// this source keeps there reads back as it was. A body that would not change is not
6063    /// sent at all.
6064    async fn replace_content(
6065        &self,
6066        id: &NativeId,
6067        content: &str,
6068    ) -> Result<Option<()>, SourceError> {
6069        let Some(mut item) = self
6070            .bound_item(id)
6071            .await?
6072            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6073        else {
6074            return Ok(None);
6075        };
6076        let held = item.raw_body.clone().unwrap_or_default();
6077        let body = with_content(&held, content)?;
6078        // Checked before anything is sent: content ending in what this source reads as its own
6079        // metadata slot would read back as metadata rather than as the content it was.
6080        let (visible, slot) = metadata_body(Some(body.clone()))?;
6081        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
6082            return Err(SourceError::Refused {
6083                message: format!(
6084                    "this content ends in what source {} reads as its own metadata slot \
6085                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6086                     as content; next: remove that trailing block from the content",
6087                    self.name
6088                ),
6089            });
6090        }
6091        if body != held {
6092            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6093                .await?;
6094        }
6095        item.body = visible.filter(|value| !value.is_empty());
6096        item.raw_body = Some(body);
6097        item.slot = slot;
6098        self.remember_written(item, false)?;
6099        Ok(Some(()))
6100    }
6101
6102    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
6103    ///
6104    /// One read of the item — which carries the board's field definitions and the issue's
6105    /// `blockedBy`, so neither is read again — and then only what differs from it: the
6106    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
6107    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
6108    /// the title, the body — visible content and metadata slot together — and a state change.
6109    /// So an update naming any of title, body, metadata, status and priority is one read and
6110    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
6111    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6112    /// as a whole write does; an open one selects its option and then reopens. The origin
6113    /// field is never written: an update is of an item that already exists, whose origin is
6114    /// what it is.
6115    ///
6116    /// The task answered is the item as those writes left it, built from the read and what was
6117    /// sent rather than read again — the same record a later read in this run answers from.
6118    async fn targeted_update(
6119        &self,
6120        id: &NativeId,
6121        update: &TaskUpdate,
6122    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6123        // Everything this source can refuse without reading the item is refused first, in the
6124        // words a whole write of the same fields is refused with.
6125        update.consistent()?;
6126        if update
6127            .title
6128            .as_deref()
6129            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6130        {
6131            return Err(SourceError::Refused {
6132                message: format!(
6133                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6134                     spells a document, so it would read back as one rather than as a task; \
6135                     retitle it",
6136                    self.name
6137                ),
6138            });
6139        }
6140        if let Some(delivers) = &update.delivers {
6141            TaskRef::listed(
6142                TaskRef::DELIVERS_KEY,
6143                id,
6144                Some(&self.name),
6145                delivers.clone(),
6146            )
6147            .map_err(|message| SourceError::Refused { message })?;
6148        }
6149        if self.priorities.is_none()
6150            && update
6151                .priority
6152                .is_some_and(|priority| priority != Priority::None)
6153        {
6154            return Err(self.holds_no_priority());
6155        }
6156        let target = update
6157            .status
6158            .as_ref()
6159            .map(|status| self.resolved_target(ItemKind::Task, status.category))
6160            .transpose()?;
6161        let Some(mut item) = self
6162            .bound_item(id)
6163            .await?
6164            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6165        else {
6166            return Ok(None);
6167        };
6168        let before = item.task()?;
6169
6170        let mut status_move = None;
6171        if let (Some(status), Some(target)) = (&update.status, target) {
6172            let board = self.status_board(&item).await?;
6173            let (field, option, name) = self
6174                .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6175                .ok_or_else(|| SourceError::Malformed {
6176                    message: format!(
6177                        "status {} of source {} names no board Status option",
6178                        category_name(status.category),
6179                        self.name
6180                    ),
6181                })?;
6182            let terminal = matches!(target, StatusTarget::Terminal(_, _));
6183            if terminal && item.content_kind == ContentKind::DraftIssue {
6184                return Err(self.closes_a_draft(status.category));
6185            }
6186            let landed = match &target {
6187                StatusTarget::Terminal(_, reason) => {
6188                    self.statuses
6189                        .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6190                }
6191                _ => self
6192                    .statuses
6193                    .status(ItemKind::Task, Some(&name), false, None),
6194            };
6195            let option_moves = item
6196                .option
6197                .as_deref()
6198                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6199            let state_moves = item.content_kind == ContentKind::Issue
6200                && (item.closed != terminal || (terminal && item.status != landed));
6201            if let Some(moves) = Moves::of(option_moves, state_moves) {
6202                status_move = Some(StatusMove {
6203                    board: board.id,
6204                    field,
6205                    option,
6206                    name,
6207                    target,
6208                    landed,
6209                    moves,
6210                });
6211            }
6212        }
6213
6214        let mut priority_move = None;
6215        if let Some(priority) = update.priority
6216            && self.priorities.is_some()
6217            && item.priority != HeldPriority::Read(priority)
6218        {
6219            let board = match (item.carried_board(), item.named_board()) {
6220                (Some(board), _) => board,
6221                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6222                    id: board,
6223                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6224                },
6225                _ => self.board_fields().await?,
6226            };
6227            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6228                priority_move = Some((board.id, write, priority));
6229            }
6230        }
6231
6232        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6233        // recorded in the slot, and the slot travels in the one body update below.
6234        let edges = match &update.depends_on {
6235            Some(edges) => Some(
6236                self.partition_edges(
6237                    BoardKind::Work(ItemKind::Task),
6238                    item.content_kind,
6239                    item.blocked_by.as_deref(),
6240                    edges,
6241                )
6242                .await?,
6243            ),
6244            None => None,
6245        };
6246
6247        let mut slot = item.slot.clone();
6248        for (key, value) in &update.metadata_set {
6249            slot.insert(key.as_str().to_owned(), value.clone());
6250        }
6251        for key in &update.metadata_remove {
6252            slot.remove(key.as_str());
6253        }
6254        if let Some(delivers) = &update.delivers {
6255            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6256        }
6257        if let Some((_, recorded)) = &edges {
6258            record_edges(&mut slot, recorded);
6259        }
6260        let held = item.raw_body.clone().unwrap_or_default();
6261        let content = match &update.content {
6262            Some(content) => with_content(&held, content)?,
6263            None => held.clone(),
6264        };
6265        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6266        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6267        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6268        let body = if slot == item.slot {
6269            content
6270        } else {
6271            with_slot(&content, &slot)?
6272        };
6273        // Checked before anything is sent, as a content write checks it: content ending in
6274        // what this source reads as its own slot would read back as metadata.
6275        let (visible, read) = metadata_body(Some(body.clone()))?;
6276        let wanted = update.content.as_deref().or(item.body.as_deref());
6277        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6278            return Err(SourceError::Refused {
6279                message: format!(
6280                    "this content ends in what source {} reads as its own metadata slot \
6281                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6282                     as content; next: remove that trailing block from the content",
6283                    self.name
6284                ),
6285            });
6286        }
6287        let recorded_moves =
6288            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6289
6290        // One `updateIssue` carries all three, because every mutation spends the secondary
6291        // limiter and the title, body and state are one mutation's inputs.
6292        let mut fields = serde_json::Map::new();
6293        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6294            fields.insert("title".to_owned(), json!(title));
6295        }
6296        if body != held {
6297            fields.insert("body".to_owned(), json!(body));
6298        }
6299        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6300            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6301        }
6302        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6303        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6304        // without undoing an earlier field when a later one fails — so a body written before a
6305        // board field the board then refused would be left changed. Written after every other
6306        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6307        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6308        // first, together in one request — a terminal option selected before the issue
6309        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6310        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6311        let mut clear: Option<(&BoardId, &str)> = None;
6312        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6313            board_writes.push((
6314                &moving.board,
6315                (
6316                    moving.field.clone(),
6317                    json!({"singleSelectOptionId": moving.option}),
6318                ),
6319            ));
6320        }
6321        match &priority_move {
6322            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6323                board,
6324                (field.clone(), json!({"singleSelectOptionId": option})),
6325            )),
6326            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6327            None => {}
6328        }
6329        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6330        boards.extend(clear.map(|(board, _)| board));
6331        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6332        for board in boards {
6333            let writes = board_writes
6334                .iter()
6335                .filter(|(on, _)| on.as_str() == board.as_str())
6336                .map(|(_, write)| write.clone())
6337                .collect::<Vec<_>>();
6338            let cleared = clear
6339                .filter(|(on, _)| on.as_str() == board.as_str())
6340                .map(|(_, field)| field);
6341            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6342                .await?;
6343        }
6344        let mut blocked_by_moved = false;
6345        if let Some((native, _)) = &edges
6346            && item.content_kind == ContentKind::Issue
6347        {
6348            blocked_by_moved = self
6349                .reconcile_blocked_by(
6350                    &item.id,
6351                    native,
6352                    Issue::Existing(item.blocked_by.as_deref()),
6353                )
6354                .await?;
6355        }
6356        if !fields.is_empty() {
6357            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6358                .await?;
6359        }
6360
6361        if let Some(title) = &update.title {
6362            item.title.clone_from(title);
6363        }
6364        item.body = visible.filter(|value| !value.is_empty());
6365        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6366        item.slot = slot;
6367        if let Some(delivers) = &update.delivers {
6368            item.delivers.clone_from(delivers);
6369        }
6370        if let Some(moving) = status_move {
6371            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6372                && item.content_kind == ContentKind::Issue;
6373            item.status = moving.landed;
6374            item.option = Some(moving.name);
6375        }
6376        if let Some((_, _, priority)) = priority_move {
6377            item.priority = HeldPriority::Read(priority);
6378        }
6379        let task = item.task()?;
6380        let mut written = update.changed(&before, &task);
6381        if blocked_by_moved || recorded_moves {
6382            written.insert(UpdatedField::DependsOn);
6383        }
6384        self.remember_written(item, false)?;
6385        Ok(Some(TaskUpdateOutcome {
6386            task,
6387            written,
6388            delivers_before: before.delivers,
6389        }))
6390    }
6391
6392    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6393    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6394    ///
6395    /// One update of the body: the content outside the slot, and inside it that one entry,
6396    /// every other entry kept as it was. This source keeps no template answers — an issue has
6397    /// no room beside itself that is not its body, and answers written there would duplicate
6398    /// what the content already says and count against GitHub's body limit — so `answers`
6399    /// reaches nothing here. A body that would not change is not sent at all.
6400    async fn replace_rendering(
6401        &self,
6402        id: &NativeId,
6403        kind: BoardKind,
6404        content: &str,
6405        provenance: &Value,
6406        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
6407    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
6408        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6409            return Ok(None);
6410        };
6411        let held = item.raw_body.clone().unwrap_or_default();
6412        let mut slot = item.slot.clone();
6413        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6414        let rewritten;
6415        let content = if let Some(assets) = assets {
6416            let uploads = self
6417                .upload_assets(item.own_repository.as_ref(), assets)
6418                .await?;
6419            rewritten =
6420                onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
6421            rewritten.as_str()
6422        } else {
6423            content
6424        };
6425        let body = with_slot(&with_content(&held, content)?, &slot)?;
6426        // Checked before anything is sent, as a content write checks it.
6427        let (visible, read) = metadata_body(Some(body.clone()))?;
6428        if visible.as_deref().unwrap_or_default() != content || read != slot {
6429            return Err(SourceError::Refused {
6430                message: format!(
6431                    "this content ends in what source {} reads as its own metadata slot \
6432                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6433                     as content; next: remove that trailing block from the template",
6434                    self.name
6435                ),
6436            });
6437        }
6438        if body != held {
6439            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6440                .await?;
6441        }
6442        item.body = visible.filter(|value| !value.is_empty());
6443        item.raw_body = Some(body);
6444        item.slot = read;
6445        self.remember_written(item, false)?;
6446        Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
6447            id: id.clone(),
6448            content: Some(content.to_owned()),
6449        }))
6450    }
6451
6452    async fn set_item_field(
6453        &self,
6454        board_id: &str,
6455        item_id: &str,
6456        field_id: &str,
6457        value: Value,
6458    ) -> Result<(), SourceError> {
6459        let data = self
6460            .graphql(
6461                graphql::UPDATE_FIELD,
6462                json!({"input":{
6463                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6464                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6465            )
6466            .await?;
6467        let returned = data
6468            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6469            .ok_or_else(|| SourceError::Malformed {
6470                message: "GitHub field update returned no project item".into(),
6471            })?;
6472        if required_str(returned, "id")? != item_id {
6473            return Err(SourceError::Malformed {
6474                message: "GitHub field update returned the wrong project item".into(),
6475            });
6476        }
6477        Ok(())
6478    }
6479
6480    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6481    /// one request. Every returned item id is checked, including optional aliases.
6482    async fn set_item_fields(
6483        &self,
6484        board: &str,
6485        item: &str,
6486        fields: &[(String, Value)],
6487        clear: Option<&str>,
6488    ) -> Result<(), SourceError> {
6489        if fields.len() <= 1 && clear.is_none() {
6490            if let Some((field, value)) = fields.first() {
6491                self.set_item_field(board, item, field, value.clone())
6492                    .await?;
6493            }
6494            return Ok(());
6495        }
6496        if fields.is_empty() {
6497            if let Some(field) = clear {
6498                self.write_priority(
6499                    board,
6500                    item,
6501                    &PriorityWrite::Clear {
6502                        field: field.to_owned(),
6503                    },
6504                )
6505                .await?;
6506            }
6507            return Ok(());
6508        }
6509        let input = |index: usize| {
6510            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6511            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6512        };
6513        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6514            "input":input(0),"second":input(1),"third":input(2),
6515            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6516            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6517        })).await?;
6518        for alias in [
6519            Some("updateProjectV2ItemFieldValue"),
6520            (fields.len() > 1).then_some("second"),
6521            (fields.len() > 2).then_some("third"),
6522            clear.map(|_| "cleared"),
6523        ]
6524        .into_iter()
6525        .flatten()
6526        {
6527            let returned = data
6528                .get(alias)
6529                .and_then(|value| value.get("projectV2Item"))
6530                .ok_or_else(|| SourceError::Malformed {
6531                    message: format!("GitHub field update {alias} returned no project item"),
6532                })?;
6533            if required_str(returned, "id")? != item {
6534                return Err(SourceError::Malformed {
6535                    message: format!("GitHub field update {alias} returned the wrong project item"),
6536                });
6537            }
6538        }
6539        Ok(())
6540    }
6541
6542    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6543        let mut after: Option<String> = None;
6544        let mut ids = Vec::new();
6545        loop {
6546            let data = self
6547                .graphql(
6548                    graphql::ISSUE_DEPENDENCIES,
6549                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6550                )
6551                .await?;
6552            let connection =
6553                data.pointer("/node/blockedBy")
6554                    .ok_or_else(|| SourceError::Malformed {
6555                        message: "GitHub dependency response has no blockedBy connection".into(),
6556                    })?;
6557            ids.extend(
6558                connection
6559                    .get("nodes")
6560                    .and_then(Value::as_array)
6561                    .ok_or_else(|| SourceError::Malformed {
6562                        message: "GitHub dependency response nodes is not an array".into(),
6563                    })?
6564                    .iter()
6565                    .map(|value| required_str(value, "id").map(str::to_owned))
6566                    .collect::<Result<Vec<_>, _>>()?,
6567            );
6568            let next = next_cursor(connection)?;
6569            if let Some(next) = &next {
6570                validate_cursor_progress(after.as_deref(), &next.0)?;
6571            }
6572            after = next.map(|cursor| cursor.0);
6573            if after.is_none() {
6574                return Ok(ids);
6575            }
6576        }
6577    }
6578
6579    async fn dependencies(
6580        &self,
6581        id: &NativeId,
6582        near_kind: ItemKind,
6583        direction: Direction,
6584        page: &PageRequest,
6585    ) -> Result<Page<DependencyEdge>, SourceError> {
6586        validate_page(page)?;
6587        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6588        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6589        let recorded = recorded_offset(cursor, direction)?;
6590        // What this issue is blocked by, when a read of it by its own id in this command
6591        // already carried the whole connection — a copy reads the item it writes before it
6592        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6593        // after it. Answered from that read, in the shape the dependency read answers in;
6594        // anything else is asked of GitHub.
6595        let carried = match direction {
6596            Direction::DependsOn => self
6597                .resolved_cache()?
6598                .get(id)
6599                .filter(|item| item.content_kind == ContentKind::Issue)
6600                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6601            Direction::DependedOnBy => None,
6602        }
6603        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6604        // Asked for even in the recorded phase, whose page reads nothing from the
6605        // connection: `__typename` is what says whether this item has a native
6606        // relationship at all, and that is what decides which far ends the reserved key is
6607        // allowed to hold.
6608        let data = match carried {
6609            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6610                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6611            None => {
6612                self.graphql(
6613                    graphql::ISSUE_DEPENDENCIES,
6614                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6615                           "after":if recorded.is_some() {None} else {cursor}}),
6616                )
6617                .await?
6618            }
6619        };
6620        let node =
6621            data.get("node")
6622                .filter(|v| !v.is_null())
6623                .ok_or_else(|| SourceError::Refused {
6624                    message: format!(
6625                        "GitHub item {} was not found or does not support dependencies",
6626                        id.0
6627                    ),
6628                })?;
6629        let connection_name = match direction {
6630            Direction::DependsOn => "blockedBy",
6631            Direction::DependedOnBy => "blocking",
6632        };
6633        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6634        // named natively and the reserved key may hold any far end. An issue's connections
6635        // hold issues, and this source reads them at the near item's own level.
6636        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6637        if let Some(offset) = recorded {
6638            return Ok(recorded_page(
6639                self.recorded_edges(id, near_kind, direction, natively_names, node)
6640                    .await?,
6641                offset,
6642                limit,
6643            ));
6644        }
6645        if natively_names.is_none() {
6646            return Ok(recorded_page(
6647                self.recorded_edges(id, near_kind, direction, natively_names, node)
6648                    .await?,
6649                0,
6650                limit,
6651            ));
6652        }
6653        let connection = node
6654            .get(connection_name)
6655            .ok_or_else(|| SourceError::Malformed {
6656                message: "GitHub dependency response is missing its connection".into(),
6657            })?;
6658        let nodes = connection
6659            .get("nodes")
6660            .and_then(Value::as_array)
6661            .ok_or_else(|| SourceError::Malformed {
6662                message: "GitHub dependency response nodes is not an array".into(),
6663            })?;
6664        // `from` depends on `to`, always. GitHub spells the same relationship from either
6665        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6666        // it — so the near item is `from` in one direction and `to` in the other.
6667        let items = nodes
6668            .iter()
6669            .map(|value| {
6670                let related = NativeId(required_str(value, "id")?.into());
6671                let related_kind = related_kind(value)?;
6672                let (from, to) = match direction {
6673                    Direction::DependsOn => (
6674                        DependencyEndpoint::from_native(id.clone(), near_kind),
6675                        DependencyEndpoint::from_native(related, related_kind),
6676                    ),
6677                    Direction::DependedOnBy => (
6678                        DependencyEndpoint::from_native(related, related_kind),
6679                        DependencyEndpoint::from_native(id.clone(), near_kind),
6680                    ),
6681                };
6682                Ok(DependencyEdge {
6683                    from,
6684                    to,
6685                    kind: DependencyKind::Blocks,
6686                })
6687            })
6688            .collect::<Result<Vec<_>, SourceError>>()?;
6689        let mut next = next_cursor(connection)?;
6690        if let Some(next) = &next {
6691            validate_cursor_progress(cursor, &next.0)?;
6692        }
6693        if next.is_none()
6694            && !self
6695                .recorded_edges(id, near_kind, direction, natively_names, node)
6696                .await?
6697                .is_empty()
6698        {
6699            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6700        }
6701        Ok(Page { items, next })
6702    }
6703
6704    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6705    /// a far end in another source has to live: no GitHub issue relationship can name one.
6706    ///
6707    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6708    /// source never writes one down.
6709    ///
6710    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6711    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6712    /// request beyond the read already made, and reading the board for them would be a
6713    /// walk of every item for one field of one. A draft has no body in that answer, because
6714    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6715    /// listing of the board, which can be behind on the very item asked about.
6716    async fn recorded_edges(
6717        &self,
6718        id: &NativeId,
6719        near_kind: ItemKind,
6720        direction: Direction,
6721        natively_names: Option<ItemKind>,
6722        node: &Value,
6723    ) -> Result<Vec<DependencyEdge>, SourceError> {
6724        if direction != Direction::DependsOn {
6725            return Ok(Vec::new());
6726        }
6727        let slot = match node.get("body") {
6728            Some(body) if natively_names.is_some() => {
6729                metadata_body(body.as_str().map(str::to_owned))?.1
6730            }
6731            _ => {
6732                let Some(item) = self.bound_item(id).await? else {
6733                    return Ok(Vec::new());
6734                };
6735                item.slot
6736            }
6737        };
6738        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6739            .map_err(|message| SourceError::Malformed { message })
6740    }
6741
6742    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6743        self.repository
6744            .as_ref()
6745            .ok_or_else(|| SourceError::Refused {
6746                message: format!(
6747                    "source {} has no repository configured, and a GitHub Projects board has no \
6748                 repository of its own to create an issue in; set repository: owner/name on \
6749                 this source",
6750                    self.name
6751                ),
6752            })
6753    }
6754
6755    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6756    /// states.
6757    ///
6758    /// The fallback is demanded first, whichever arm answers: a write without a configured
6759    /// repository is refused naming the field exactly as it was before the rule existed,
6760    /// so a source that could not write before cannot write now, rather than writing for
6761    /// the one item whose own field happens to decide it.
6762    ///
6763    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6764    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6765    /// entry owned by someone other than the owner of the parent issue's repository —
6766    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6767    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6768    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6769    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6770    /// and is visible to the token is checked where its node id is resolved, still before
6771    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6772    /// looked up in a listing of the board, which can be minutes behind an issue its own
6773    /// `projectItems` already places on it — and that read answers first from this process's
6774    /// own record, so a project created moments ago in this command answers though GitHub
6775    /// has not caught up.
6776    async fn creation_target(
6777        &self,
6778        incoming: &Incoming<'_>,
6779    ) -> Result<RepositoryTarget, SourceError> {
6780        let fallback = self.configured_repository()?;
6781        let what = |incoming: &Incoming<'_>| {
6782            format!(
6783                "{} {:?}",
6784                incoming.written.kind().describes(),
6785                incoming.title
6786            )
6787        };
6788        let parent = match incoming.parent {
6789            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6790                SourceError::Refused {
6791                    message: format!(
6792                        "GitHub project issue {} was not found on the board of source {}, so {} \
6793                         cannot be filed under it",
6794                        parent.0,
6795                        self.name,
6796                        what(incoming)
6797                    ),
6798                }
6799            })?),
6800            None => None,
6801        };
6802        let parents_repository = parent
6803            .as_ref()
6804            .map(|parent| {
6805                // A draft is on the board and so is found, but it has no repository to
6806                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6807                // would refuse the task only once `createIssue` had made it.
6808                if parent.content_kind == ContentKind::DraftIssue {
6809                    return Err(SourceError::Refused {
6810                        message: format!(
6811                            "GitHub project item {} on the board of source {} is a draft, \
6812                             which cannot have sub-issues, so {} cannot be filed under it",
6813                            parent.id.0,
6814                            self.name,
6815                            what(incoming)
6816                        ),
6817                    });
6818                }
6819                // An issue's repository is where a sub-issue is placed and whose owner it
6820                // is compared against, so a parent whose repository this source cannot
6821                // spell as `owner/name` — GitHub's login grammar is wider than this
6822                // source's floor — is one nothing can be filed under.
6823                parent
6824                    .own_repository
6825                    .as_ref()
6826                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6827                    .ok_or_else(|| SourceError::Malformed {
6828                        message: format!(
6829                            "GitHub project issue {} on the board of source {} is in {}, which \
6830                             is not a {}/owner/name repository this source can place {} in",
6831                            parent.id.0,
6832                            self.name,
6833                            parent
6834                                .own_repository
6835                                .as_ref()
6836                                .map_or("no repository", Repository::as_str),
6837                            RepositoryTarget::HOST,
6838                            what(incoming)
6839                        ),
6840                    })
6841            })
6842            .transpose()?;
6843        match incoming.repositories {
6844            [named] => {
6845                let target =
6846                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6847                        message: format!(
6848                            "{} names repository {}, which is not a {}/owner/name repository \
6849                             source {} can create an issue in; name one that is, or name none",
6850                            what(incoming),
6851                            named.as_str(),
6852                            RepositoryTarget::HOST,
6853                            self.name
6854                        ),
6855                    })?;
6856                if let Some(parents) = &parents_repository
6857                    && parents.owner != target.owner
6858                {
6859                    return Err(SourceError::Refused {
6860                        message: format!(
6861                            "{} names repository {}, owned by {}, but its project's issue is in \
6862                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6863                             of the same owner as its parent issue; name a repository of {}, or \
6864                             name none",
6865                            what(incoming),
6866                            target.slug(),
6867                            target.owner,
6868                            parents.slug(),
6869                            parents.owner,
6870                            parents.owner
6871                        ),
6872                    });
6873                }
6874                Ok(target)
6875            }
6876            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6877        }
6878    }
6879
6880    /// The node id of the repository `incoming` is being created in, or the refusal naming
6881    /// the item and the repository the token cannot see.
6882    ///
6883    /// Resolved once per command per repository; see [`Self::repository_cache`].
6884    async fn repository_id(
6885        &self,
6886        repository: &RepositoryTarget,
6887        incoming: &Incoming<'_>,
6888    ) -> Result<String, SourceError> {
6889        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6890            return Ok(id);
6891        }
6892        let data = self
6893            .graphql(
6894                graphql::REPOSITORY,
6895                json!({"owner":repository.owner,"name":repository.name}),
6896            )
6897            .await?;
6898        self.repository_read(&data, repository, incoming)
6899    }
6900
6901    /// The repository's node id out of an answer carrying the `repository` root, held for
6902    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6903    fn repository_read(
6904        &self,
6905        data: &Value,
6906        repository: &RepositoryTarget,
6907        incoming: &Incoming<'_>,
6908    ) -> Result<String, SourceError> {
6909        let node = data
6910            .get("repository")
6911            .filter(|value| !value.is_null())
6912            .ok_or_else(|| SourceError::Refused {
6913                message: format!(
6914                    "GitHub repository {} was not found or is not visible to the token, so {} \
6915                     {:?} cannot be created in it",
6916                    repository.slug(),
6917                    incoming.written.kind().describes(),
6918                    incoming.title
6919                ),
6920            })?;
6921        let id = required_str(node, "id")?.to_owned();
6922        self.repository_cache()?
6923            .insert(repository.clone(), id.clone());
6924        Ok(id)
6925    }
6926
6927    fn repository_cache(
6928        &self,
6929    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6930        self.repository_cache
6931            .lock()
6932            .map_err(|_| SourceError::Unavailable {
6933                message: "this source's record of the destination repository was left \
6934                          inconsistent by an earlier failure; next: run the command again"
6935                    .into(),
6936            })
6937    }
6938
6939    /// Create or update one board item, whichever kind it is.
6940    async fn write_item(
6941        &self,
6942        incoming: &Incoming<'_>,
6943        target: Option<&NativeId>,
6944        depends_on: &[DependencyEdge],
6945    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
6946        // Refused before anything is read or written: a task or a project titled the way
6947        // this board spells a document would land as an issue this same source reads back
6948        // as a document, so the field this destination cannot carry is named rather than
6949        // written and silently reclassified.
6950        if let Written::Work(kind, _) = incoming.written
6951            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6952        {
6953            return Err(SourceError::Refused {
6954                message: format!(
6955                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6956                     spells a document, so it would read back as one rather than as a {}; \
6957                     retitle it, or copy it as a document",
6958                    kind.marker(),
6959                    self.name,
6960                    kind.marker()
6961                ),
6962            });
6963        }
6964        // The destination is read by its own id, and whether this board holds it is decided
6965        // by that read — its own `projectItems` — rather than by whether a listing of the
6966        // board happens to include it yet. See the module documentation.
6967        let existing = match target {
6968            Some(target) => {
6969                Some(
6970                    self.bound_item(target)
6971                        .await?
6972                        .ok_or_else(|| SourceError::Refused {
6973                            message: format!("GitHub destination item {} was not found", target.0),
6974                        })?,
6975                )
6976            }
6977            None => None,
6978        };
6979        let existing = existing.as_ref();
6980        // An existing issue is never moved; a new one is created where the rule says — and
6981        // knowing where is what lets the board's fields and that repository's id be read
6982        // together, before anything below needs either.
6983        let creation_target = match existing {
6984            Some(_) => None,
6985            None => {
6986                let target = self.creation_target(incoming).await?;
6987                self.creation_context(&target, incoming).await?;
6988                Some(target)
6989            }
6990        };
6991        let board = self
6992            .fields_for(
6993                existing,
6994                incoming.written.status().is_some(),
6995                incoming
6996                    .priority
6997                    .is_some_and(|priority| priority != Priority::None),
6998            )
6999            .await?;
7000        let status_target = incoming
7001            .written
7002            .work_status()
7003            .map(|(kind, status)| self.resolved_target(kind, status.category))
7004            .transpose()?;
7005        let column = match (incoming.written.work_status(), status_target.as_ref()) {
7006            (Some((kind, status)), Some(target)) => {
7007                self.column_for(&board.fields, kind, status.category, target)?
7008            }
7009            _ => None,
7010        };
7011        // Resolved before anything is created, for the reason the column above is: a
7012        // priority this board has no option for is refused while nothing has been written.
7013        let priority_write = match incoming.priority {
7014            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
7015            None => None,
7016        };
7017        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
7018        if content_kind == ContentKind::DraftIssue {
7019            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
7020                (status_target.as_ref(), incoming.written.status())
7021            {
7022                return Err(self.closes_a_draft(status.category));
7023            }
7024            if incoming.parent.is_some() {
7025                return Err(SourceError::Refused {
7026                    message: "GitHub draft items cannot be a project's sub-issue".into(),
7027                });
7028            }
7029        }
7030        match existing {
7031            Some(item) if content_kind == ContentKind::Issue => {
7032                if item.labels != incoming.labels {
7033                    return Err(SourceError::Refused {
7034                        message: "GitHub issue labels differ from the labels being written".into(),
7035                    });
7036                }
7037            }
7038            _ => {
7039                if !incoming.labels.is_empty() {
7040                    return Err(SourceError::Refused {
7041                        message: "GitHub items created by this destination carry no labels".into(),
7042                    });
7043                }
7044            }
7045        }
7046
7047        // The repository the issue really lives in is what the slot below is written against,
7048        // so a single entry that is where the issue is created travels as no key at all, and
7049        // the read side derives it back from the issue.
7050        let own_repository = match (existing, &creation_target) {
7051            (Some(item), _) => item.own_repository.clone(),
7052            (None, Some(target)) => Some(
7053                Repository::try_from(target.origin())
7054                    .map_err(|message| SourceError::Config { message })?,
7055            ),
7056            (None, None) => None,
7057        };
7058        let (native, fallback) = self
7059            .partition_edges(
7060                incoming.written.kind(),
7061                content_kind,
7062                existing.and_then(|item| item.blocked_by.as_deref()),
7063                depends_on,
7064            )
7065            .await?;
7066        let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
7067        let content = match incoming.assets {
7068            Some(assets) => {
7069                let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
7070                let rewritten = onetaskgraph_plugin_api::serve_asset_references(
7071                    incoming.content.unwrap_or_default(),
7072                    &mut slot,
7073                    &uploads,
7074                );
7075                incoming.content.map(|_| rewritten)
7076            }
7077            None => incoming.content.map(str::to_owned),
7078        };
7079        let body = compose_body(content.as_deref(), &slot)?;
7080        // Read before anything is created, for the reason the field below is: a value
7081        // this destination cannot store has to refuse, and refusing after `createIssue`
7082        // would leave an issue behind that nothing asked for. The engine writes a
7083        // qualified id here; a caller handing this key anything else is told so rather
7084        // than having it silently stored as no origin at all.
7085        // 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.
7086        let origin = match incoming.metadata.get(ORIGIN_KEY) {
7087            None => "",
7088            Some(Value::String(origin)) => origin.as_str(),
7089            Some(other) => {
7090                return Err(SourceError::Refused {
7091                    message: format!(
7092                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
7093                         is {other}"
7094                    ),
7095                });
7096            }
7097        };
7098        // Resolved before anything is created: a board that cannot carry the copy origin
7099        // has to refuse the write, and refusing it after `createIssue` would leave an
7100        // issue behind that nothing asked for.
7101        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
7102            Some(field) => {
7103                if required_str(field, "__typename")? != "ProjectV2Field" {
7104                    return Err(SourceError::Refused {
7105                        message: format!(
7106                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
7107                        ),
7108                    });
7109                }
7110                Some(required_str(field, "id")?.to_owned())
7111            }
7112            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
7113                return Err(SourceError::Refused {
7114                    message: format!(
7115                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
7116                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
7117                         the board"
7118                    ),
7119                });
7120            }
7121            None => None,
7122        };
7123
7124        let Landed {
7125            content_id,
7126            item_id,
7127            url,
7128            number,
7129        } = match existing {
7130            // Its content is written last, below, once everything else has landed.
7131            Some(item) => Landed {
7132                content_id: item.id.clone(),
7133                item_id: item.item_id.clone(),
7134                url: item.url.clone(),
7135                number: item.number,
7136            },
7137            None => {
7138                let target = creation_target
7139                    .as_ref()
7140                    .ok_or_else(|| SourceError::Malformed {
7141                        message: "a new item was decided without a repository to create it in"
7142                            .into(),
7143                    })?;
7144                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7145                    .await?
7146            }
7147        };
7148
7149        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7150        let column = column
7151            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7152            .map(|(field, option, _)| (field, option));
7153        // Creating an item here is several calls — `createIssue`, which files it on the
7154        // board, then its board fields, the parent and the dependencies — and GitHub can fail
7155        // at any of them. Everything this source can refuse *before* the first of those is
7156        // already checked above, so what is left is GitHub itself failing part way. When it
7157        // does over an item this call created, the issue is taken back: a write that
7158        // refused must not leave an item behind that nobody asked for, and one that does
7159        // makes the retry create a second.
7160        // Whether the board-field write carrying a moved origin was answered as landing whole.
7161        // When it was refused, GitHub does not say which of its fields ran before the one that
7162        // failed, so the origin may or may not have moved.
7163        let mut origin_landed = false;
7164        let landed = self
7165            .finish_write(
7166                board.id.as_str(),
7167                incoming,
7168                &content_id,
7169                &item_id,
7170                content_kind,
7171                existing,
7172                origin_field.as_deref(),
7173                origin,
7174                column,
7175                status_target.as_ref(),
7176                priority_write.as_ref(),
7177                &native,
7178                &mut origin_landed,
7179            )
7180            .await;
7181        // An existing item's title, body and state go last, in one `updateIssue`, once its board
7182        // fields and its relationships have landed: a refusal of any of those then leaves its
7183        // body — and the metadata slot inside it — exactly as it stood.
7184        let landed = match (landed, existing) {
7185            (Ok(()), Some(item)) => {
7186                self.update_existing(item, incoming, &body, status_target.as_ref())
7187                    .await
7188            }
7189            (landed, _) => landed,
7190        };
7191        if let Err(error) = landed {
7192            match existing {
7193                // Best effort, and the write's own failure is what the caller is told: a
7194                // refusal naming the tidy-up would hide why the write failed at all.
7195                None => {
7196                    let _ = self.delete_issue(&content_id).await;
7197                }
7198                // The origin field is the one piece of an existing item's metadata written
7199                // before its body, so a write refused after it puts it back as it was. When
7200                // that is refused too, the write's own failure is still what the caller is
7201                // told — with what it left behind added, because the item's metadata is then
7202                // not as it stood and a caller retrying has to know which key moved.
7203                Some(item) => {
7204                    let before = item.origin.as_deref().unwrap_or("");
7205                    if let Some(field) = origin_field.as_deref()
7206                        && before != origin
7207                        && let Err(restore) = self
7208                            .set_item_field(
7209                                board.id.as_str(),
7210                                &item.item_id,
7211                                field,
7212                                json!({"text": before}),
7213                            )
7214                            .await
7215                    {
7216                        let left = if origin_landed {
7217                            format!(
7218                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7219                                 not be put back to {before:?} ({restore}), so item {} still \
7220                                 holds {origin:?} there",
7221                                item.id.0
7222                            )
7223                        } else {
7224                            format!(
7225                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7226                                 {origin:?}, GitHub does not say whether that part of it ran, \
7227                                 and putting it back to {before:?} was refused ({restore}), so \
7228                                 item {} holds {origin:?} or {before:?} there",
7229                                item.id.0
7230                            )
7231                        };
7232                        return Err(noting(
7233                            error,
7234                            &format!(
7235                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7236                                 run the write again"
7237                            ),
7238                        ));
7239                    }
7240                }
7241            }
7242            return Err(error);
7243        }
7244
7245        let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7246            (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7247                self.statuses
7248                    .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7249            }
7250            (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7251                self.statuses
7252                    .status(kind, written_option.as_deref(), false, None)
7253            }
7254            (Some((_, status)), _) => status.clone(),
7255            (None, _) => Status {
7256                category: StatusCategory::Unknown,
7257                name: "Open".to_owned(),
7258            },
7259        };
7260
7261        // So the rest of this command reads what it just did rather than what the board
7262        // said before it. See `remember_written` for which half takes it.
7263        let remembered = Resolved {
7264            item_id,
7265            id: content_id.clone(),
7266            content_kind,
7267            kind: incoming.written.kind(),
7268            title: incoming.title.to_owned(),
7269            // The visible half of the body this write composed, split back off it the
7270            // way a read splits it — so what this record reports is what a read of the
7271            // same issue reports, rather than the person's text with the metadata slot
7272            // still on the end of it.
7273            body: metadata_body(body.clone())?.0,
7274            raw_body: body.clone(),
7275            // A document has no status of its own; what it reads back as is whatever
7276            // the issue's own state says, which is what a re-read reports.
7277            status: written_status,
7278            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7279            priority: match incoming.priority {
7280                Some(priority) => HeldPriority::Read(priority),
7281                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7282                    item.priority.clone()
7283                }),
7284            },
7285            // What `state_input` asked for: closed for a terminal target, open for any other
7286            // status, and the issue's own state left as it was by a document write.
7287            closed: content_kind == ContentKind::Issue
7288                && match status_target.as_ref() {
7289                    Some(StatusTarget::Terminal(_, _)) => true,
7290                    Some(_) => false,
7291                    None => existing.is_some_and(|item| item.closed),
7292                },
7293            delivers: incoming.delivers.to_vec(),
7294            delivered_by: incoming.delivered_by.to_vec(),
7295            labels: incoming.labels.to_vec(),
7296            parent: incoming.parent.cloned(),
7297            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7298            number,
7299            // In the update path this is the item's own url, read off `existing` where the
7300            // record above was bound, so one expression serves both halves.
7301            url,
7302            created_at: existing.and_then(|item| item.created_at),
7303            updated_at: existing.and_then(|item| item.updated_at),
7304            own_repository,
7305            repositories: incoming.repositories.to_vec(),
7306            slot,
7307            board_id: Some(board.id.as_str().to_owned()),
7308            fields: board
7309                .fields
7310                .get("nodes")
7311                .and_then(Value::as_array)
7312                .cloned()
7313                .unwrap_or_default(),
7314            board_fields: Some(board.fields.clone()),
7315            // What this write left the relationship holding is known by id alone, and a
7316            // later read of its edges needs each far end's kind, so it reads them again.
7317            blocked_by: None,
7318        };
7319        self.remember_written(remembered, existing.is_none())?;
7320        Ok(onetaskgraph_plugin_api::AssetsWritten {
7321            id: content_id,
7322            content,
7323        })
7324    }
7325
7326    /// Everything a write does after the item exists: its board fields, its parent, and
7327    /// its dependencies.
7328    ///
7329    /// Split out of `write_item` so there is one place a failure past the point of no
7330    /// return is caught, rather than a tidy-up repeated at each `?` above.
7331    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7332    // so there is one place a failure past the point of no return is caught, and its
7333    // arguments are exactly the values that tail already had in scope. Bundling them into a
7334    // struct would describe no concept — it would be "the arguments of this function" — and
7335    // would put the whole of `write_item`'s locals behind one more indirection.
7336    #[allow(clippy::too_many_arguments)]
7337    async fn finish_write(
7338        &self,
7339        board_id: &str,
7340        incoming: &Incoming<'_>,
7341        content_id: &NativeId,
7342        item_id: &str,
7343        content_kind: ContentKind,
7344        existing: Option<&Resolved>,
7345        origin_field: Option<&str>,
7346        origin: &str,
7347        column: Option<(String, String)>,
7348        status_target: Option<&StatusTarget>,
7349        priority: Option<&PriorityWrite>,
7350        native: &[String],
7351        origin_landed: &mut bool,
7352    ) -> Result<(), SourceError> {
7353        let mut fields = Vec::new();
7354        if let Some(field_id) = origin_field
7355            && existing.map_or(!origin.is_empty(), |item| {
7356                item.origin.as_deref().unwrap_or("") != origin
7357            })
7358        {
7359            fields.push((field_id.to_owned(), json!({"text":origin})));
7360        }
7361        if let Some((field_id, option_id)) = column {
7362            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7363        }
7364        let clear = match priority {
7365            Some(PriorityWrite::Select { field, option }) => {
7366                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7367                None
7368            }
7369            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7370            None => None,
7371        };
7372        self.set_item_fields(board_id, item_id, &fields, clear)
7373            .await?;
7374        *origin_landed = true;
7375
7376        // An existing issue closes in the `updateIssue` its write ends with; one created just
7377        // now closes here, once its option is selected.
7378        if existing.is_none()
7379            && content_kind == ContentKind::Issue
7380            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7381        {
7382            self.update_content(
7383                ContentKind::Issue,
7384                content_id,
7385                json!({"stateInput":state_input(status_target)}),
7386            )
7387            .await?;
7388        }
7389
7390        if content_kind == ContentKind::Issue {
7391            self.reparent(
7392                existing.and_then(|item| item.parent.clone()),
7393                content_id,
7394                incoming.parent,
7395            )
7396            .await?;
7397            // A document takes part in no dependency graph, so writing one neither reads
7398            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7399            // against the empty list a document write carries would *delete* whatever
7400            // relationships a person had made on that issue, which is a write nobody
7401            // asked for.
7402            if incoming.written.kind() != BoardKind::Document {
7403                let issue = match existing {
7404                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7405                    None => Issue::Created,
7406                };
7407                self.reconcile_blocked_by(content_id, native, issue).await?;
7408            }
7409        }
7410        Ok(())
7411    }
7412
7413    /// Delete one issue, which takes its board item with it.
7414    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7415        let data = self
7416            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7417            .await?;
7418        data.pointer("/deleteIssue/repository")
7419            .filter(|value| !value.is_null())
7420            .ok_or_else(|| SourceError::Malformed {
7421                message: "GitHub issue deletion returned no repository".into(),
7422            })?;
7423        self.forget(id)?;
7424        Ok(())
7425    }
7426
7427    /// Remove one item this copy created, so a copy that could not finish leaves the board
7428    /// as it found it.
7429    ///
7430    /// Deleting the issue takes its board item with it, so there is no second mutation to
7431    /// keep in step. An id the board does not hold is not an error: the item is already
7432    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7433    /// by its own id — a listing of the board can still be missing an item it holds, and
7434    /// reading that as *already gone* would leave behind the very item this was asked to
7435    /// take back.
7436    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7437        let Some(item) = self.bound_item(id).await? else {
7438            return Ok(());
7439        };
7440        if item.content_kind == ContentKind::DraftIssue {
7441            return Err(SourceError::Refused {
7442                message: format!(
7443                    "GitHub item {} is a draft, and this source removes an item by deleting \
7444                     its issue; next: remove it from the board by hand",
7445                    id.0
7446                ),
7447            });
7448        }
7449        let data = self
7450            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7451            .await?;
7452        data.pointer("/deleteIssue/repository")
7453            .filter(|value| !value.is_null())
7454            .ok_or_else(|| SourceError::Malformed {
7455                message: "GitHub issue deletion returned no repository".into(),
7456            })?;
7457        self.forget(id)?;
7458        Ok(())
7459    }
7460
7461    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7462    /// task.
7463    ///
7464    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7465    /// read of the task cannot disagree about which ids name one: a project or a document of
7466    /// this board is not a task here either.
7467    ///
7468    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7469    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7470    /// which would read as a task nobody has commented on yet.
7471    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7472        let cached = self.resolved_cache()?.get(task).cloned();
7473        let Some(item) = (match cached {
7474            Some(item) => Some(item),
7475            None => self.item_by_id(task).await?,
7476        })
7477        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7478            return Ok(None);
7479        };
7480        if item.content_kind == ContentKind::DraftIssue {
7481            return Err(self.draft_has_no_comments(task));
7482        }
7483        Ok(Some(item.id))
7484    }
7485
7486    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7487    /// issues, and a draft is not one.
7488    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7489        SourceError::Refused {
7490            message: format!(
7491                "task {} of source {} is a draft item on the board, and GitHub keeps \
7492                 comments on issues alone, so a draft has none to read or write; next: \
7493                 convert the draft to an issue on the board, then comment on the issue it \
7494                 becomes",
7495                task.0, self.name
7496            ),
7497        }
7498    }
7499
7500    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7501    /// request — or `None` when this board holds no task by that id.
7502    ///
7503    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7504    /// is answered with the draft and the refusal, at the price of the draft's own read.
7505    async fn issue_detail(
7506        &self,
7507        id: &NativeId,
7508        page: &PageRequest,
7509    ) -> Result<Option<TaskDetailRead>, SourceError> {
7510        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7511        let asked = self
7512            .graphql(
7513                graphql::ISSUE_DETAIL,
7514                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7515                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7516                       "duplicates":true}),
7517            )
7518            .await;
7519        let data = match asked {
7520            Ok(data) => data,
7521            Err(error) if unresolvable_node(&error) => return Ok(None),
7522            Err(error) => return Err(error),
7523        };
7524        // `node` is null for an id that names nothing, and absent only from an answer this
7525        // source cannot read — never the same thing.
7526        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7527            message: format!("GitHub answered the read of {} with no node", id.0),
7528        })?;
7529        self.detail_of(id, node, true, after).await
7530    }
7531
7532    /// Several tasks, each with the first page of its comments when `comments` is set, read
7533    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7534    /// order.
7535    ///
7536    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7537    /// one item at a time, so that id is answered as missing and the others as themselves; any
7538    /// other refusal is every id of that batch's answer.
7539    async fn issue_details(
7540        &self,
7541        ids: &[NativeId],
7542        comments: Option<&PageRequest>,
7543    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7544        let mut read = Vec::with_capacity(ids.len());
7545        for batch in ids.chunks(DETAIL_BATCH) {
7546            match self
7547                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7548                .await
7549            {
7550                Ok(data) => {
7551                    for (slot, id) in batch.iter().enumerate() {
7552                        // Every alias asked for is answered, null for an id naming nothing;
7553                        // one missing is an answer this source cannot read.
7554                        let read_one = match data.get(format!("i{slot}")) {
7555                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7556                            None => Err(SourceError::Malformed {
7557                                message: format!(
7558                                    "GitHub answered a batch read with no item for {}",
7559                                    id.0
7560                                ),
7561                            }),
7562                        };
7563                        read.push(read_one);
7564                    }
7565                }
7566                Err(error) if unresolvable_node(&error) => {
7567                    for id in batch {
7568                        read.push(match comments {
7569                            Some(page) => self.issue_detail(id, page).await,
7570                            None => self.task_read(id).await,
7571                        });
7572                    }
7573                }
7574                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7575            }
7576        }
7577        read
7578    }
7579
7580    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7581    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7582        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7583            task,
7584            comments: None,
7585        }))
7586    }
7587
7588    /// What one node a detail read reached says: the task this board holds by `id`, with the
7589    /// page of comments the node carries when `commented` — or `None` for a node that is no
7590    /// task of this board.
7591    ///
7592    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7593    /// and an item this process created answers from this process's own record, which a node
7594    /// read taken moments after the write can still be behind.
7595    async fn detail_of(
7596        &self,
7597        id: &NativeId,
7598        node: &Value,
7599        commented: bool,
7600        after: Option<&str>,
7601    ) -> Result<Option<TaskDetailRead>, SourceError> {
7602        if node.is_null() {
7603            return Ok(None);
7604        }
7605        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7606        // An issue answered under one id is that id's, or the answer is not one this source
7607        // can report: reporting another issue's task and comments under the qualified id asked
7608        // for would be the one wrong answer here. A draft's own read checks the same.
7609        if !draft
7610            && optional_str(node, "__typename")? == Some("Issue")
7611            && required_str(node, "id")? != id.0
7612        {
7613            return Err(SourceError::Malformed {
7614                message: format!(
7615                    "GitHub answered the read of {} with issue {}",
7616                    id.0,
7617                    required_str(node, "id")?
7618                ),
7619            });
7620        }
7621        let item = if draft {
7622            self.draft_by_id(id).await?
7623        } else {
7624            self.resolve_issue(node).await?
7625        };
7626        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7627            return Ok(None);
7628        };
7629        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7630        let task = own.unwrap_or(item).task()?;
7631        let comments = match (commented, draft) {
7632            (false, _) => None,
7633            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7634            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7635        };
7636        Ok(Some(TaskDetailRead { task, comments }))
7637    }
7638
7639    /// Whether the comment `comment` is one of `issue`'s own.
7640    ///
7641    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7642    /// comment's id and nothing else: a comment id given against the wrong task would
7643    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7644    /// names something that is not an issue comment, is a comment this task does not have —
7645    /// which is what GitHub refusing to resolve it means too.
7646    async fn comment_is_on(
7647        &self,
7648        issue: &NativeId,
7649        comment: &NativeId,
7650    ) -> Result<bool, SourceError> {
7651        let asked = self
7652            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7653            .await;
7654        let data = match asked {
7655            Ok(data) => data,
7656            Err(error) if unresolvable_node(&error) => return Ok(false),
7657            Err(error) => return Err(error),
7658        };
7659        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7660            return Ok(false);
7661        };
7662        if optional_str(node, "__typename")? != Some("IssueComment") {
7663            return Ok(false);
7664        }
7665        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7666            message: format!("GitHub issue comment {} names no issue", comment.0),
7667        })?;
7668        Ok(required_str(on, "id")? == issue.0)
7669    }
7670
7671    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7672    async fn partition_edges(
7673        &self,
7674        near_kind: BoardKind,
7675        near_content: ContentKind,
7676        carried: Option<&[Value]>,
7677        depends_on: &[DependencyEdge],
7678    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7679        let mut native = Vec::new();
7680        let mut fallback = Vec::new();
7681        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7682            .iter()
7683            .map(|edge| {
7684                let same_source = edge
7685                    .to
7686                    .source()
7687                    .is_none_or(|source| source == self.name.as_str());
7688                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7689                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7690                // colons of its own, so the far end is everything after that one separator.
7691                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7692                let far_id = if edge.to.is_qualified() {
7693                    edge.to
7694                        .id()
7695                        .split_once(':')
7696                        .map_or(edge.to.id(), |(_, native)| native)
7697                } else {
7698                    edge.to.id()
7699                };
7700                // One that already blocks the near issue was answered by that issue's own
7701                // read, which carried each of its blockers' kinds — an issue every one — so it
7702                // is not read again.
7703                let blocking = carried.and_then(|nodes| {
7704                    nodes
7705                        .iter()
7706                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7707                });
7708                (edge, far_id, same_source, blocking)
7709            })
7710            .collect();
7711        // Every other same-source far end is read by its own id, exactly as the item it is a
7712        // far end of is: whether this board holds it is that read's answer, never a listing's.
7713        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7714        let mut unread: Vec<NativeId> = Vec::new();
7715        for (_, far_id, same_source, blocking) in &far_ends {
7716            let id = NativeId((*far_id).to_owned());
7717            if *same_source && blocking.is_none() && !unread.contains(&id) {
7718                unread.push(id);
7719            }
7720        }
7721        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7722            .iter()
7723            .cloned()
7724            .zip(self.items_by_ids(&unread).await?)
7725            .collect();
7726        for (edge, far_id, same_source, blocking) in far_ends {
7727            let far = match (same_source, blocking) {
7728                (false, _) => None,
7729                (true, Some(node)) => Some(FarEnd {
7730                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7731                        BoardKind::Document
7732                    } else {
7733                        BoardKind::Work(related_kind(node)?)
7734                    },
7735                    content_kind: ContentKind::Issue,
7736                }),
7737                (true, None) => {
7738                    let read = read
7739                        .get(&NativeId(far_id.to_owned()))
7740                        .cloned()
7741                        .flatten()
7742                        .ok_or_else(|| SourceError::Refused {
7743                            message: format!("GitHub dependency item {far_id} was not found"),
7744                        })?;
7745                    Some(FarEnd {
7746                        kind: read.kind,
7747                        content_kind: read.content_kind,
7748                    })
7749                }
7750            };
7751            let far = far.as_ref();
7752            // The caller says which kind the far end is, and this board holds the far end
7753            // itself, so a disagreement is settled here rather than stored: recorded, the
7754            // wrong kind would read back as a cross-level edge that never existed; written
7755            // natively, it would name a relationship of a different level than the caller
7756            // asked for.
7757            //
7758            // A far end this board holds as a *document* fails the same comparison and is
7759            // refused by the same sentence: `ItemKind` has no document variant because
7760            // nothing may point at one, so no caller can name it correctly and the refusal
7761            // is the only honest answer.
7762            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7763                return Err(SourceError::Refused {
7764                    message: format!(
7765                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7766                         names it as a {}; record the kind it is",
7767                        disagreeing.kind.describes(),
7768                        edge.to.kind.marker()
7769                    ),
7770                });
7771            }
7772            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7773            // however the far end is spelled — and one classified native here would be
7774            // written nowhere at all, because a draft's native reconciliation never runs.
7775            let native_here = near_content == ContentKind::Issue
7776                && far.is_some_and(|far| {
7777                    far.content_kind == ContentKind::Issue
7778                        && BoardKind::Work(edge.to.kind) == near_kind
7779                });
7780            if native_here {
7781                native.push(far_id.to_owned());
7782            } else {
7783                fallback.push(edge.clone());
7784            }
7785        }
7786        Ok((native, fallback))
7787    }
7788
7789    async fn update_existing(
7790        &self,
7791        item: &Resolved,
7792        incoming: &Incoming<'_>,
7793        body: &Option<String>,
7794        status_target: Option<&StatusTarget>,
7795    ) -> Result<(), SourceError> {
7796        let title = incoming.written_title();
7797        // A terminal status closes the issue here, in the same mutation as its body: its board
7798        // option was selected before this, so a close never lands on an item whose board cannot
7799        // show it.
7800        let fields = match item.content_kind {
7801            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7802            ContentKind::Issue => json!({"title":title,"body":body,
7803                                         "stateInput":state_input(status_target)}),
7804        };
7805        self.update_content(item.content_kind, &item.id, fields)
7806            .await
7807    }
7808
7809    /// Update one board item's content with exactly `fields` beside its id, through the
7810    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7811    /// a draft.
7812    ///
7813    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7814    /// is what lets a narrow write carry the one thing it changes and nothing else.
7815    async fn update_content(
7816        &self,
7817        kind: ContentKind,
7818        id: &NativeId,
7819        fields: Value,
7820    ) -> Result<(), SourceError> {
7821        let (operation, id_key, pointer) = match kind {
7822            ContentKind::DraftIssue => (
7823                graphql::UPDATE_DRAFT,
7824                "draftIssueId",
7825                "/updateProjectV2DraftIssue/draftIssue",
7826            ),
7827            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7828        };
7829        let mut input = fields;
7830        input[id_key] = json!(id.0);
7831        let data = self.graphql(operation, json!({"input":input})).await?;
7832        let returned = data
7833            .pointer(pointer)
7834            .ok_or_else(|| SourceError::Malformed {
7835                message: "GitHub item update returned no item".into(),
7836            })?;
7837        if required_str(returned, "id")? != id.0 {
7838            return Err(SourceError::Malformed {
7839                message: "GitHub item update returned the wrong item".into(),
7840            });
7841        }
7842        Ok(())
7843    }
7844
7845    /// Creates one issue, files it on the board, and reports what a read of it would say:
7846    /// its content id, its board item id, and the web address GitHub gave it.
7847    ///
7848    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7849    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7850    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7851    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7852    /// "Content already exists in this project". A terminal status is not written here:
7853    /// `finish_write` selects its option first and closes the issue after, so a close never
7854    /// lands on an item whose board cannot show it.
7855    ///
7856    /// The address and the number come back here because this is the only place either is
7857    /// known before GitHub's own board read catches up — an item this run created answers
7858    /// the reads that follow it out of the record below, and one remembered without them
7859    /// would report no location and no key for the rest of the run.
7860    async fn create_and_file_issue(
7861        &self,
7862        board_id: &str,
7863        repository: &RepositoryTarget,
7864        incoming: &Incoming<'_>,
7865        body: &Option<String>,
7866    ) -> Result<Landed, SourceError> {
7867        let repository_id = self.repository_id(repository, incoming).await?;
7868        let data = self
7869            .graphql(
7870                graphql::CREATE_ISSUE,
7871                json!({"input":{
7872                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7873                }}),
7874            )
7875            .await?;
7876        let created = data
7877            .pointer("/createIssue/issue")
7878            .filter(|value| !value.is_null())
7879            .ok_or_else(|| SourceError::Malformed {
7880                message: "GitHub issue creation returned no issue".into(),
7881            })?;
7882        let content_id = NativeId(required_str(created, "id")?.to_owned());
7883        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7884        // a response without it is not worth failing a landed write over — the item simply
7885        // reports no location until the board read catches up, which is what it did before.
7886        let url = optional_str(created, "url")?.map(str::to_owned);
7887        // The issue exists from here on, so an unreadable number and a refused board
7888        // filing below each try, best effort, to take it back: an issue in the repository
7889        // that is on no board is an item nobody asked for and nothing here would find again.
7890        //
7891        // Its number is optional on the same terms its address is — a landed write is not
7892        // worth failing over a member that came back missing, and such an item reports no
7893        // handle until a board read catches up. A number that is *present* and is not an
7894        // unsigned integer is still a response this source cannot read.
7895        let number = match created_issue_number(created) {
7896            Ok(number) => number,
7897            Err(error) => {
7898                let _ = self.delete_issue(&content_id).await;
7899                return Err(error);
7900            }
7901        };
7902        let added = match self
7903            .graphql(
7904                graphql::ADD_TO_BOARD,
7905                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7906            )
7907            .await
7908        {
7909            Ok(added) => added,
7910            Err(error) => {
7911                let _ = self.delete_issue(&content_id).await;
7912                return Err(error);
7913            }
7914        };
7915        let item = added
7916            .pointer("/addProjectV2ItemById/item")
7917            .filter(|value| !value.is_null())
7918            .ok_or_else(|| SourceError::Malformed {
7919                message: "GitHub board addition returned no project item".into(),
7920            })?;
7921        Ok(Landed {
7922            content_id,
7923            item_id: required_str(item, "id")?.to_owned(),
7924            url,
7925            number,
7926        })
7927    }
7928
7929    /// Move one issue under the project it now belongs to, or out of the one it left.
7930    async fn reparent(
7931        &self,
7932        held: Option<NativeId>,
7933        child: &NativeId,
7934        wanted: Option<&NativeId>,
7935    ) -> Result<(), SourceError> {
7936        if held.as_ref() == wanted {
7937            return Ok(());
7938        }
7939        if let Some(held) = &held {
7940            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7941                .await?;
7942        }
7943        if let Some(wanted) = wanted {
7944            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7945                .await?;
7946        }
7947        Ok(())
7948    }
7949
7950    async fn sub_issue(
7951        &self,
7952        operation: &str,
7953        parent: &NativeId,
7954        child: &NativeId,
7955        root: &str,
7956    ) -> Result<(), SourceError> {
7957        let data = self
7958            .graphql(
7959                operation,
7960                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7961            )
7962            .await?;
7963        let issue =
7964            data.pointer(&format!("/{root}/issue"))
7965                .ok_or_else(|| SourceError::Malformed {
7966                    message: "GitHub sub-issue update returned no issue".into(),
7967                })?;
7968        let sub =
7969            data.pointer(&format!("/{root}/subIssue"))
7970                .ok_or_else(|| SourceError::Malformed {
7971                    message: "GitHub sub-issue update returned no sub-issue".into(),
7972                })?;
7973        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7974            return Err(SourceError::Malformed {
7975                message: "GitHub sub-issue update returned the wrong issues".into(),
7976            });
7977        }
7978        Ok(())
7979    }
7980
7981    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7982    /// whether there was one.
7983    ///
7984    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7985    /// relationships are not read: there is nothing a read of them could find.
7986    async fn reconcile_blocked_by(
7987        &self,
7988        content_id: &NativeId,
7989        native: &[String],
7990        issue: Issue<'_>,
7991    ) -> Result<bool, SourceError> {
7992        let current = match issue {
7993            Issue::Created => Vec::new(),
7994            Issue::Existing(Some(held)) => held
7995                .iter()
7996                .map(|far| required_str(far, "id").map(str::to_owned))
7997                .collect::<Result<Vec<_>, _>>()?,
7998            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7999        };
8000        let mut changed = false;
8001        for (operation, far_id) in current
8002            .iter()
8003            .filter(|id| !native.contains(id))
8004            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
8005            .chain(
8006                native
8007                    .iter()
8008                    .filter(|id| !current.contains(id))
8009                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
8010            )
8011        {
8012            let data = self
8013                .graphql(
8014                    operation,
8015                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
8016                )
8017                .await?;
8018            let root = if operation == graphql::ADD_BLOCKED_BY {
8019                "addBlockedBy"
8020            } else {
8021                "removeBlockedBy"
8022            };
8023            let issue =
8024                data.pointer(&format!("/{root}/issue"))
8025                    .ok_or_else(|| SourceError::Malformed {
8026                        message: "GitHub dependency update returned no issue".into(),
8027                    })?;
8028            let blocker = data
8029                .pointer(&format!("/{root}/blockingIssue"))
8030                .ok_or_else(|| SourceError::Malformed {
8031                    message: "GitHub dependency update returned no blocking issue".into(),
8032                })?;
8033            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
8034            {
8035                return Err(SourceError::Malformed {
8036                    message: "GitHub dependency update returned the wrong issues".into(),
8037                });
8038            }
8039            changed = true;
8040        }
8041        Ok(changed)
8042    }
8043}
8044
8045/// What a write needs to know of one far end it names: which kind of item it is, and whether
8046/// it is an issue a native relationship can name.
8047struct FarEnd {
8048    kind: BoardKind,
8049    content_kind: ContentKind,
8050}
8051
8052/// Whether the issue one write reconciles was created by that write or was already there.
8053#[derive(Clone, Copy, PartialEq, Eq)]
8054enum Issue<'a> {
8055    /// Created by this write, so it holds no relationships yet.
8056    Created,
8057    /// On the board before this write, holding whatever relationships it holds — the far
8058    /// ends of its whole `blockedBy`, when the read that reached it carried them.
8059    Existing(Option<&'a [Value]>),
8060}
8061
8062/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
8063enum Reached {
8064    /// An issue this board holds, resolved into everything this source reports about it.
8065    Held(Box<Resolved>),
8066    /// Nothing this board holds: no such node, or a node on some other board.
8067    Nothing,
8068    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
8069    /// again by [`GitHubProjectsSource::draft_by_id`].
8070    Draft,
8071}
8072
8073/// What GitHub says when a string is not a node id it can resolve.
8074///
8075/// Matched because it is the ordinary answer to a project selector naming a project by its
8076/// *name*, and reporting that as a failure would make naming one impossible. It is read
8077/// off the refusal GitHub sent, never guessed from the shape of the string: this source
8078/// does not define the syntax of a GitHub node id and would be wrong about it.
8079const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
8080
8081/// `error` with `note` added to the end of what it says, its kind and every other member
8082/// unchanged — so a caller still branches on the failure that happened, and reads beside it
8083/// what that failure left behind.
8084fn noting(error: SourceError, note: &str) -> SourceError {
8085    match error {
8086        SourceError::Config { message } => SourceError::Config {
8087            message: message + note,
8088        },
8089        SourceError::Auth { message } => SourceError::Auth {
8090            message: message + note,
8091        },
8092        SourceError::Refused { message } => SourceError::Refused {
8093            message: message + note,
8094        },
8095        SourceError::RateLimited {
8096            retry_after_seconds,
8097            message,
8098        } => SourceError::RateLimited {
8099            retry_after_seconds,
8100            message: Some(message.unwrap_or_default() + note),
8101        },
8102        SourceError::Unavailable { message } => SourceError::Unavailable {
8103            message: message + note,
8104        },
8105        SourceError::Malformed { message } => SourceError::Malformed {
8106            message: message + note,
8107        },
8108    }
8109}
8110
8111/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
8112/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
8113/// for them.
8114///
8115/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
8116/// is read again at no added price.
8117fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
8118    let mut variables = serde_json::Map::new();
8119    for slot in 0..DETAIL_BATCH {
8120        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
8121        variables.insert(format!("id{slot}"), json!(id));
8122    }
8123    variables.insert(
8124        "first".to_owned(),
8125        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
8126    );
8127    variables.insert("comments".to_owned(), json!(comments.is_some()));
8128    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
8129    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
8130    variables.insert("duplicates".to_owned(), json!(true));
8131    Value::Object(variables)
8132}
8133
8134/// Whether this refusal is GitHub saying the id names no node at all.
8135fn unresolvable_node(error: &SourceError) -> bool {
8136    matches!(error, SourceError::Refused { message }
8137        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
8138}
8139
8140/// One project name, as a search qualifier which filters on it at the server.
8141///
8142/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8143/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8144/// the way it documents. A title matched here is still compared for equality afterwards:
8145/// the qualifier narrows what the server sends, and this source decides what it names.
8146fn title_qualifier(name: &str) -> String {
8147    format!("in:title {}", quoted(name))
8148}
8149
8150/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8151/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8152/// it documents — so a value holding a qualifier's spelling is searched for rather than
8153/// obeyed.
8154fn quoted(value: &str) -> String {
8155    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8156    format!("\"{escaped}\"")
8157}
8158
8159/// The search qualifier for the issues updated at or after `since`.
8160///
8161/// Written to the second, rounded down, which can only widen what the search returns.
8162fn updated_qualifier(since: DateTime<Utc>) -> String {
8163    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8164}
8165
8166/// The search terms that narrow a board-scoped issue search to a task query's text and
8167/// metadata predicates, or `None` when it carries neither.
8168///
8169/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8170/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8171/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8172/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8173/// a metadata value searches both fields for both — wider than asked, never narrower, and
8174/// every candidate is confirmed in process afterwards.
8175///
8176/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8177/// matches whole tokens where a substring rule would match inside a word, so an item holding
8178/// the text only inside a longer word is not returned. A text of nothing but whitespace
8179/// matches every item, so it narrows nothing and is not sent.
8180fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8181    let text = query
8182        .text
8183        .as_ref()
8184        .filter(|text| !text.terms.trim().is_empty());
8185    if text.is_none() && query.metadata.is_empty() {
8186        return None;
8187    }
8188    let (title, body) = match text.map(|text| text.fields) {
8189        None => (false, true),
8190        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8191        Some(TextFields::Content) => (false, true),
8192        Some(TextFields::TitleOrContent) => (true, true),
8193    };
8194    let fields = match (title, body) {
8195        (true, true) => "in:title,body",
8196        (true, false) => "in:title",
8197        _ => "in:body",
8198    };
8199    let phrases = text
8200        .map(|text| text.terms.clone())
8201        .into_iter()
8202        .chain(
8203            query
8204                .metadata
8205                .iter()
8206                .map(|wanted| as_stored(wanted.value())),
8207        )
8208        .map(|phrase| quoted(&phrase))
8209        .collect::<Vec<_>>();
8210    Some(format!("{fields} {}", phrases.join(" ")))
8211}
8212
8213/// The search terms that narrow a board-scoped issue search to a project or document query's
8214/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8215/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8216fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8217    narrowing_qualifiers(&TaskQuery {
8218        text: text.cloned(),
8219        ..TaskQuery::default()
8220    })
8221}
8222
8223/// Refuses a project or document query's text GitHub's issue search cannot find, before
8224/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8225/// query's.
8226fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8227    refuse_unsearchable(&TaskQuery {
8228        text: text.cloned(),
8229        ..TaskQuery::default()
8230    })
8231}
8232
8233/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8234/// before anything is asked of GitHub.
8235///
8236/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8237/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8238/// left out, the search is every issue of the board. So this source says it cannot answer
8239/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8240/// nothing GitHub could search for, and keeps the board read it always had.
8241fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8242    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8243                       letter or digit with a bounded query";
8244    if let Some(text) = &query.text
8245        && !text.terms.trim().is_empty()
8246        && !has_words(&text.terms)
8247    {
8248        return Err(SourceError::Refused {
8249            message: format!(
8250                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8251                text.terms
8252            ),
8253        });
8254    }
8255    if let Some(wanted) = query
8256        .metadata
8257        .iter()
8258        .find(|wanted| !has_words(wanted.value()))
8259    {
8260        return Err(SourceError::Refused {
8261            message: format!(
8262                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8263                wanted.value(),
8264                std::iter::once(wanted.key())
8265                    .chain(wanted.path().iter().map(String::as_str))
8266                    .collect::<Vec<_>>()
8267                    .join("/"),
8268            ),
8269        });
8270    }
8271    Ok(())
8272}
8273
8274/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8275fn has_words(phrase: &str) -> bool {
8276    phrase.chars().any(char::is_alphanumeric)
8277}
8278
8279/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8280///
8281/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8282/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8283/// which GitHub's word match would read as different words.
8284fn as_stored(value: &str) -> String {
8285    let encoded = Value::String(value.to_owned()).to_string();
8286    encoded[1..encoded.len() - 1].to_owned()
8287}
8288
8289/// The one narrower question a task query carrying a text, metadata or origin predicate is
8290/// sent as.
8291enum Narrowing {
8292    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8293    Origin(String),
8294    /// The board-scoped issue search narrowed by these qualifiers.
8295    Search(String),
8296}
8297
8298impl Narrowing {
8299    /// What this question is remembered under for the length of one command.
8300    fn key(&self) -> String {
8301        match self {
8302            Self::Origin(origin) => format!("origin {origin}"),
8303            Self::Search(also) => format!("search {also}"),
8304        }
8305    }
8306}
8307
8308/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8309enum Resumed {
8310    /// It reported another page, which starts after this cursor.
8311    More(String),
8312    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8313    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8314    /// can go on walking the other connection.
8315    Ended(Option<String>),
8316}
8317
8318impl Resumed {
8319    /// Whether the connection has another page.
8320    const fn has_more(&self) -> bool {
8321        matches!(self, Self::More(_))
8322    }
8323
8324    /// The cursor to send this connection next.
8325    fn cursor(self) -> Option<String> {
8326        match self {
8327            Self::More(next) => Some(next),
8328            Self::Ended(last) => last,
8329        }
8330    }
8331}
8332
8333/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8334/// with no cursor to it, or from a cursor that does not advance.
8335fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8336    let info = connection
8337        .get("pageInfo")
8338        .ok_or_else(|| SourceError::Malformed {
8339            message: "GitHub connection has no pageInfo".into(),
8340        })?;
8341    let end = optional_str(info, "endCursor")?;
8342    if required_bool(info, "hasNextPage")? {
8343        let next = end.ok_or_else(|| SourceError::Malformed {
8344            message: "GitHub connection reports another page and no endCursor".into(),
8345        })?;
8346        validate_cursor_progress(after, next)?;
8347        return Ok(Resumed::More(next.to_owned()));
8348    }
8349    Ok(Resumed::Ended(
8350        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8351    ))
8352}
8353
8354/// The board, and every item on it this source reports.
8355#[derive(Clone)]
8356struct Board {
8357    id: String,
8358    fields: Value,
8359    items: Vec<Resolved>,
8360}
8361
8362/// What a write needs of the board and nothing more: its node id and its field
8363/// definitions, in the shape a read of the board's own `fields` gives them.
8364///
8365/// Deliberately no items. A write decides which item it writes, which parent it files
8366/// under and which far ends it names by reading each of them by its own id; this is the
8367/// half of the board those reads cannot carry, and holding no item is what keeps it from
8368/// ever being asked whether an item is there.
8369#[derive(Clone)]
8370struct BoardFields {
8371    id: BoardId,
8372    fields: Value,
8373}
8374
8375/// A board's node id: what a field write and `addProjectV2ItemById` address.
8376///
8377/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8378/// refused where it is read, and one an item names blank is read as not named at all.
8379#[derive(Clone)]
8380struct BoardId(String);
8381
8382/// Where one write left its item, for the record the rest of the command reads it out of.
8383///
8384/// A named record rather than a tuple because the update arm and the create arm each fill
8385/// all four, and two `Option`s of different meaning side by side in a tuple are two
8386/// positions a reader has to count.
8387struct Landed {
8388    /// The issue's own node id, which is the [`NativeId`] this source reports.
8389    content_id: NativeId,
8390    /// The board item's id, which is what a field write addresses.
8391    // 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.
8392    item_id: String,
8393    /// The web address GitHub gave the issue, when it gave one.
8394    // 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.
8395    url: Option<String>,
8396    /// The issue's number on its repository, when GitHub reported one.
8397    number: Option<u64>,
8398}
8399
8400impl BoardId {
8401    fn parse(id: &str) -> Result<Self, SourceError> {
8402        if id.trim().is_empty() {
8403            return Err(SourceError::Malformed {
8404                message: "GitHub named a board with a blank node id".into(),
8405            });
8406        }
8407        Ok(Self(id.to_owned()))
8408    }
8409
8410    fn as_str(&self) -> &str {
8411        &self.0
8412    }
8413}
8414
8415impl Board {
8416    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8417        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8418        let nodes = fields
8419            .get("nodes")
8420            .and_then(Value::as_array)
8421            .ok_or_else(|| SourceError::Malformed {
8422                message: "GitHub project fields.nodes is not an array".into(),
8423            })?;
8424        Ok(nodes
8425            .iter()
8426            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8427    }
8428}
8429
8430/// One board item, resolved into everything this source reports about it.
8431#[derive(Clone)]
8432struct Resolved {
8433    item_id: String,
8434    id: NativeId,
8435    content_kind: ContentKind,
8436    kind: BoardKind,
8437    title: String,
8438    body: Option<String>,
8439    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8440    /// that changes the slot alone has to keep byte for byte outside it.
8441    raw_body: Option<String>,
8442    status: Status,
8443    /// The name of the board `Status` option this item sits in, as the board spells it.
8444    option: Option<String>,
8445    /// What its `Priority` field says, read through this instance's mapping.
8446    priority: HeldPriority,
8447    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8448    closed: bool,
8449    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8450    delivers: Vec<TaskRef>,
8451    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8452    /// task.
8453    delivered_by: Vec<TaskRef>,
8454    labels: Vec<Label>,
8455    parent: Option<NativeId>,
8456    // 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.
8457    origin: Option<String>,
8458    /// The issue's own number on its repository, as GitHub reports it.
8459    ///
8460    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8461    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8462    /// an issue this run created whose creating mutation answered without one, which is a
8463    /// response GitHub's own schema says cannot happen and which a landed write is not
8464    /// worth failing over. An `Issue` read off the board always has one.
8465    number: Option<u64>,
8466    url: Option<String>,
8467    created_at: Option<DateTime<Utc>>,
8468    updated_at: Option<DateTime<Utc>>,
8469    own_repository: Option<Repository>,
8470    repositories: Vec<Repository>,
8471    slot: BTreeMap<String, Value>,
8472    /// The node id of the board this item sits on, when the read that reached it said.
8473    board_id: Option<String>,
8474    /// The definition of every board field this item holds a value of, in the shape a read
8475    /// of the board's own `fields` gives one.
8476    ///
8477    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8478    /// which says nothing about whether the board has it.
8479    fields: Vec<Value>,
8480    /// Every field the board this item sits on defines, as its own read of the board's
8481    /// `fields` gives them — when the read that reached the item carried them, which a read
8482    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8483    /// of the board.
8484    board_fields: Option<Value>,
8485    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8486    /// selects one — when the read that reached it carried the connection to its end, which a
8487    /// read of it by its own id does for any issue blocked by no more than a page. What a
8488    /// write reconciles that relationship against, and what a read of its forward edges in
8489    /// the same command answers with.
8490    blocked_by: Option<Vec<Value>>,
8491}
8492
8493impl Resolved {
8494    /// The board this item's own read names it on, when that read named one this source can
8495    /// address.
8496    fn named_board(&self) -> Option<BoardId> {
8497        self.board_id
8498            .as_deref()
8499            .and_then(|id| BoardId::parse(id).ok())
8500    }
8501
8502    /// The board's id and every field it defines, when the read that reached this item
8503    /// carried both — which a read of it by its own id does.
8504    fn carried_board(&self) -> Option<BoardFields> {
8505        Some(BoardFields {
8506            id: self.named_board()?,
8507            fields: self.board_fields.clone()?,
8508        })
8509    }
8510
8511    /// Whether this item holds a value of the board field called `name`, and so carries
8512    /// that field's definition. `false` says nothing about whether the board has the field.
8513    fn defines(&self, name: &str) -> bool {
8514        self.fields
8515            .iter()
8516            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8517    }
8518
8519    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8520    /// in a field of its own, and none of the five keys that are only an encoding.
8521    ///
8522    /// The two delivery keys are left out for every kind, not only for a task: they are
8523    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8524    /// document carrying one holds nothing a caller's own metadata could mean by it.
8525    fn metadata(&self) -> BTreeMap<String, Value> {
8526        let mut metadata = self.slot.clone();
8527        metadata.remove(Repository::METADATA_KEY);
8528        metadata.remove(DependencyEdge::RECORDED_KEY);
8529        metadata.remove(ItemKind::METADATA_KEY);
8530        metadata.remove(TaskRef::DELIVERS_KEY);
8531        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8532        // The board field is the origin, and the body's copy of it is only a mirror for the
8533        // issue search to find: an item whose field holds none has none, whatever its body
8534        // says, so no reader ever sees two answers.
8535        metadata.remove(ORIGIN_KEY);
8536        if let Some(origin) = &self.origin {
8537            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8538        }
8539        metadata
8540    }
8541
8542    /// Where this item is, as a link a reader can open.
8543    ///
8544    /// A board is a hosted place and every issue on it has a web address, so that address
8545    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8546    /// of place it is, so a reader knows to open it rather than to read a file out. It
8547    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8548    /// reported before, and this says what that address *is*.
8549    ///
8550    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8551    /// rather than a third variant, which is the contract's "the source did not say". An
8552    /// issue this run created is not one of those: its address comes back from the
8553    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8554    /// rather than from whenever the board read catches up.
8555    fn location(&self) -> Option<Location> {
8556        self.url.clone().map(Location::Url)
8557    }
8558
8559    /// The short handle this board's backend shows people for a task: the issue's number
8560    /// alone, as a decimal string.
8561    ///
8562    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8563    /// value for this backend. A draft has no number and so no handle, which is the
8564    /// contract's *absent* rather than a handle of some other shape — and the native
8565    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8566    /// derives from.
8567    fn key(&self) -> Option<String> {
8568        self.number.map(|number| number.to_string())
8569    }
8570
8571    /// Whether its `Priority` field holds a value at all, mapped or not.
8572    fn holds_priority(&self) -> bool {
8573        self.priority != HeldPriority::Read(Priority::None)
8574    }
8575
8576    /// The task this item is.
8577    ///
8578    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8579    /// reading that as a level would be a guess, and reading it as `none` would let the next
8580    /// copy clear a priority a person set.
8581    fn task(&self) -> Result<Task, SourceError> {
8582        let priority = match &self.priority {
8583            HeldPriority::Read(priority) => *priority,
8584            HeldPriority::Unmapped(option) => {
8585                return Err(SourceError::Malformed {
8586                    message: format!(
8587                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8588                         this source's priority_mapping does not name, so its priority cannot be \
8589                         read; next: name {option:?} under priority_mapping, or move the item to \
8590                         a mapped option",
8591                        self.id,
8592                        self.number
8593                            .map(|number| format!(" (#{number})"))
8594                            .unwrap_or_default()
8595                    ),
8596                });
8597            }
8598        };
8599        Ok(Task {
8600            id: self.id.clone(),
8601            key: self.key(),
8602            title: self.title.clone(),
8603            content: self.body.clone(),
8604            status: self.status.clone(),
8605            priority,
8606            labels: self.labels.clone(),
8607            project: self.parent.clone(),
8608            url: self.url.clone(),
8609            location: self.location(),
8610            created_at: self.created_at,
8611            updated_at: self.updated_at,
8612            metadata: self.metadata(),
8613            repositories: self.repositories.clone(),
8614            delivers: self.delivers.clone(),
8615            delivered_by: self.delivered_by.clone(),
8616        })
8617    }
8618
8619    fn project(&self) -> Project {
8620        Project {
8621            id: self.id.clone(),
8622            title: self.title.clone(),
8623            content: self.body.clone(),
8624            status: self.status.clone(),
8625            labels: self.labels.clone(),
8626            url: self.url.clone(),
8627            location: self.location(),
8628            created_at: self.created_at,
8629            updated_at: self.updated_at,
8630            metadata: self.metadata(),
8631            repositories: self.repositories.clone(),
8632        }
8633    }
8634
8635    /// The same issue as a document: the project it is filed under, and no status and no
8636    /// dependencies, because a document is not work.
8637    fn document(&self) -> Document {
8638        Document {
8639            id: self.id.clone(),
8640            title: self.title.clone(),
8641            content: self.body.clone(),
8642            project: self.parent.clone(),
8643            labels: self.labels.clone(),
8644            url: self.url.clone(),
8645            location: self.location(),
8646            created_at: self.created_at,
8647            updated_at: self.updated_at,
8648            metadata: self.metadata(),
8649            repositories: self.repositories.clone(),
8650        }
8651    }
8652}
8653
8654/// Where one targeted update moves an item's status, and which of its two halves move.
8655struct StatusMove {
8656    /// The board the item's `Status` field is on.
8657    board: BoardId,
8658    /// The `Status` field's id.
8659    field: String,
8660    /// The option's id.
8661    option: String,
8662    /// The option's name, as the board spells it.
8663    name: String,
8664    /// What the status asks of the issue's state.
8665    target: StatusTarget,
8666    /// The status the item reads as once it is there.
8667    landed: Status,
8668    /// Which of the status's two halves differ from what the item holds.
8669    moves: Moves,
8670}
8671
8672/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8673/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8674/// and is not a value of this type.
8675#[derive(Clone, Copy, PartialEq, Eq)]
8676enum Moves {
8677    /// The option alone.
8678    Option,
8679    /// The issue's state alone: open, closed, or closed with another reason.
8680    State,
8681    /// Both.
8682    Both,
8683}
8684
8685impl Moves {
8686    /// What differs, or `None` when nothing does.
8687    const fn of(option: bool, state: bool) -> Option<Self> {
8688        match (option, state) {
8689            (true, true) => Some(Self::Both),
8690            (true, false) => Some(Self::Option),
8691            (false, true) => Some(Self::State),
8692            (false, false) => None,
8693        }
8694    }
8695
8696    /// Whether the option moves.
8697    const fn option(self) -> bool {
8698        matches!(self, Self::Option | Self::Both)
8699    }
8700
8701    /// Whether the issue's state moves.
8702    const fn state(self) -> bool {
8703        matches!(self, Self::State | Self::Both)
8704    }
8705}
8706
8707/// What one write is, and the status that comes with being it.
8708///
8709/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8710/// status and a task or a project always has one, so "a document carrying a status" and
8711/// "a task carrying none" are states a write cannot be in rather than states every use
8712/// site below has to defend against.
8713enum Written<'a> {
8714    /// A document, which is not work and so has no status at all.
8715    Document,
8716    /// A task or a project, and the status it is being written with.
8717    Work(ItemKind, &'a Status),
8718}
8719
8720impl Written<'_> {
8721    /// Which of the board's three kinds this write is.
8722    const fn kind(&self) -> BoardKind {
8723        match self {
8724            Self::Document => BoardKind::Document,
8725            Self::Work(kind, _) => BoardKind::Work(*kind),
8726        }
8727    }
8728
8729    /// The status this write carries. A document carries none, so a write of one says
8730    /// nothing about the issue's open or closed state and selects no board `Status`
8731    /// option.
8732    const fn status(&self) -> Option<&Status> {
8733        match self {
8734            Self::Document => None,
8735            Self::Work(_, status) => Some(status),
8736        }
8737    }
8738
8739    /// The status this write carries with the kind whose half of `status_mapping` it is
8740    /// written through.
8741    const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8742        match self {
8743            Self::Document => None,
8744            Self::Work(kind, status) => Some((*kind, status)),
8745        }
8746    }
8747}
8748
8749/// The item being written, in the one shape all three write methods reach.
8750struct Incoming<'a> {
8751    written: Written<'a>,
8752    /// The title a person wrote. A document's goes onto the issue with
8753    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8754    title: &'a str,
8755    content: Option<&'a str>,
8756    assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
8757    labels: &'a [Label],
8758    metadata: &'a BTreeMap<String, Value>,
8759    repositories: &'a [Repository],
8760    parent: Option<&'a NativeId>,
8761    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8762    /// what keeps either key out of their slot.
8763    delivers: &'a [TaskRef],
8764    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8765    delivered_by: &'a [TaskRef],
8766    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8767    /// project, a document, and every write to an instance with no `priority_mapping` —
8768    /// which is what keeps such a write's requests exactly what they were before.
8769    priority: Option<Priority>,
8770}
8771
8772/// What one write does to an item's `Priority` field.
8773enum PriorityWrite {
8774    /// Select this option of this field.
8775    Select {
8776        /// The `Priority` field's id.
8777        field: String,
8778        /// The mapped option's id.
8779        option: String,
8780    },
8781    /// Clear the field's value, which is what `none` is.
8782    Clear {
8783        /// The `Priority` field's id.
8784        field: String,
8785    },
8786}
8787
8788impl Incoming<'_> {
8789    /// The title this write puts on the issue.
8790    fn written_title(&self) -> String {
8791        match self.written {
8792            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8793            Written::Work(..) => self.title.to_owned(),
8794        }
8795    }
8796}
8797
8798#[derive(Clone, Copy, PartialEq, Eq)]
8799enum ContentKind {
8800    DraftIssue,
8801    Issue,
8802}
8803
8804/// What one board issue is: a document, or the work an [`ItemKind`] names.
8805///
8806/// A type of this source's own rather than an `ItemKind` with a third variant, because
8807/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8808/// document — the contract keeps a document out of that enum deliberately. Holding the
8809/// board's three answers in one value is what makes every place that asks "which is this?"
8810/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8811/// two thirds of the board.
8812#[derive(Clone, Copy, PartialEq, Eq)]
8813enum BoardKind {
8814    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8815    Document,
8816    /// Every other issue, and every draft.
8817    Work(ItemKind),
8818}
8819
8820impl BoardKind {
8821    /// Whose half of `status_mapping` an item of this kind reads its status through. A
8822    /// document has no status of its own, so the task half stands in for whatever the issue
8823    /// holds; nothing reports it.
8824    const fn status_kind(self) -> ItemKind {
8825        match self {
8826            Self::Document => ItemKind::Task,
8827            Self::Work(kind) => kind,
8828        }
8829    }
8830
8831    /// How a refusal names this kind to the person reading it.
8832    const fn describes(self) -> &'static str {
8833        match self {
8834            Self::Document => "document",
8835            Self::Work(kind) => kind.marker(),
8836        }
8837    }
8838}
8839
8840/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8841///
8842/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8843/// the shared cross-source journeys assert one answer to one question, so two sources
8844/// that disagree about what "carries the label bug" means fail them.
8845fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8846    let holds = |name: &String| {
8847        labels
8848            .iter()
8849            .any(|label| label.name.eq_ignore_ascii_case(name))
8850    };
8851    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8852        && filter.all_of.iter().all(holds)
8853        && !filter.none_of.iter().any(holds)
8854}
8855
8856/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8857/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8858fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8859    statuses.is_empty() || statuses.contains(&category)
8860}
8861
8862/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8863///
8864/// `content` is the item's own prose — the body with this source's trailing metadata
8865/// comment already taken off — so a search never matches an encoding the author of the
8866/// issue never wrote.
8867fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8868    let terms = query.terms.to_lowercase();
8869    let in_title = title.to_lowercase().contains(&terms);
8870    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8871    match query.fields {
8872        TextFields::Title => in_title,
8873        TextFields::Content => in_content,
8874        TextFields::TitleOrContent => in_title || in_content,
8875    }
8876}
8877
8878/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8879///
8880/// The project predicate is passed separately because a read narrowed to one project has
8881/// already answered it by asking *that project* for its own items — and re-applying it
8882/// there would compare the caller's selector, which may be a project's **name**, against
8883/// the id of the project that name resolved to, and keep nothing. Every other read passes
8884/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8885/// source really does apply.
8886fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8887    labels_match(&task.labels, &query.labels)
8888        && status_matches(task.status.category, &query.statuses)
8889        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8890        && match project {
8891            ProjectFilter::Any => true,
8892            ProjectFilter::Orphans => task.project.is_none(),
8893            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8894        }
8895        && query
8896            .text
8897            .as_ref()
8898            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8899        // Against the parsed metadata slot, and against the origin field, which is where
8900        // `Resolved::metadata` reads each of them from.
8901        && query.metadata_matches(&task.metadata)
8902        && query.origin_matches(&task.metadata)
8903}
8904
8905fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8906    labels_match(&project.labels, &query.labels)
8907        && status_matches(project.status.category, &query.statuses)
8908        && query
8909            .text
8910            .as_ref()
8911            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8912}
8913
8914/// The same three predicates a task query carries, minus the status filter.
8915///
8916/// A document is not work, so it has no status for one to compare against and the query
8917/// type carries none. The project predicate is the same one — a design issue filed under a
8918/// project issue is in that project, and one filed under nothing is in none — so it is
8919/// spelled the same way here rather than answered differently.
8920fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8921    labels_match(&document.labels, &query.labels)
8922        && match project {
8923            ProjectFilter::Any => true,
8924            ProjectFilter::Orphans => document.project.is_none(),
8925            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8926        }
8927        && query
8928            .text
8929            .as_ref()
8930            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8931}
8932
8933#[async_trait::async_trait]
8934impl TaskSource for GitHubProjectsSource {
8935    fn kind(&self) -> &'static str {
8936        KIND
8937    }
8938    fn capabilities(&self) -> Capabilities {
8939        Capabilities {
8940            projects: Support::Native,
8941            documents: Support::Native,
8942            comments: Support::Native,
8943            assets: Support::Native,
8944            priority: if self.priorities.is_some() {
8945                Support::Native
8946            } else {
8947                Support::Unsupported
8948            },
8949            filter_by_priority: Support::Native,
8950            filter_by_comment_activity: Support::Native,
8951            filter_by_metadata: Support::Native,
8952            filter_by_origin: Support::Native,
8953            orphan_tasks: Support::Native,
8954            filter_by_label: Support::Native,
8955            filter_by_status: Support::Native,
8956            search_title: Support::Native,
8957            search_content: Support::Native,
8958            task_dependencies: DependencySupport::BothDirections,
8959            project_dependencies: DependencySupport::BothDirections,
8960            max_page_size: MAX_PAGE_SIZE,
8961        }
8962    }
8963    async fn health(&self) -> Result<Health, SourceError> {
8964        let board = self.board_page(None, 1).await?;
8965        Ok(Health {
8966            reachable: true,
8967            detail: Some(format!(
8968                "reading GitHub project {}/{} ({})",
8969                self.owner,
8970                self.project_number,
8971                required_str(&board, "title")?
8972            )),
8973        })
8974    }
8975    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8976        self.item_by_id(id)
8977            .await?
8978            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8979            .map(|item| item.task())
8980            .transpose()
8981    }
8982    async fn task_assets(
8983        &self,
8984        id: &NativeId,
8985    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
8986        self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
8987    }
8988    async fn task_asset(
8989        &self,
8990        id: &NativeId,
8991        name: &onetaskgraph_plugin_api::AssetName,
8992    ) -> Result<Option<Vec<u8>>, SourceError> {
8993        self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
8994            .await
8995    }
8996    async fn set_task_rendering_with_assets(
8997        &self,
8998        id: &NativeId,
8999        content: &str,
9000        provenance: &Value,
9001        _answers: &BTreeMap<String, Value>,
9002        assets: &onetaskgraph_plugin_api::AssetWrite,
9003    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9004        self.replace_rendering(
9005            id,
9006            BoardKind::Work(ItemKind::Task),
9007            content,
9008            provenance,
9009            Some(assets),
9010        )
9011        .await
9012    }
9013    async fn document_assets(
9014        &self,
9015        id: &NativeId,
9016    ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
9017        self.held_assets(id, BoardKind::Document).await
9018    }
9019    async fn document_asset(
9020        &self,
9021        id: &NativeId,
9022        name: &onetaskgraph_plugin_api::AssetName,
9023    ) -> Result<Option<Vec<u8>>, SourceError> {
9024        self.held_asset(id, BoardKind::Document, name).await
9025    }
9026    async fn set_document_rendering_with_assets(
9027        &self,
9028        id: &NativeId,
9029        content: &str,
9030        provenance: &Value,
9031        _answers: &BTreeMap<String, Value>,
9032        assets: &onetaskgraph_plugin_api::AssetWrite,
9033    ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9034        self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
9035            .await
9036    }
9037    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
9038        Ok(self
9039            .item_by_id(id)
9040            .await?
9041            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9042            .map(|item| item.project()))
9043    }
9044    async fn query_tasks(
9045        &self,
9046        query: &TaskQuery,
9047        page: &PageRequest,
9048    ) -> Result<Page<Task>, SourceError> {
9049        validate_page(page)?;
9050        refuse_unsearchable(query)?;
9051        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
9052            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
9053                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
9054                (Some(also), None) => Some(also),
9055                (None, Some(since)) => Some(updated_qualifier(since)),
9056                (None, None) => None,
9057            };
9058            if let Some(also) = qualifiers {
9059                return self.search_tasks(query, page, &also).await;
9060            }
9061        }
9062
9063        // A read narrowed to one project asks that project for its own tasks, so nothing
9064        // about it costs what the rest of the board holds. A read carrying a text, metadata
9065        // or origin predicate asks GitHub the narrower question those predicates are, and a
9066        // read narrowed to comment activity alone asks the board's own issue search for the
9067        // issues updated since, which is every issue a comment could have been written or
9068        // edited on since. Every other task read is a question about the whole board and is
9069        // answered by reading it.
9070        let (held, membership) = match (&query.project, query.commented_since) {
9071            (ProjectFilter::Is(project), _) => (
9072                self.project_children(project).await?,
9073                // Answered by where these items came from; see `task_matches`.
9074                &ProjectFilter::Any,
9075            ),
9076            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
9077                match (self.narrowed(query).await?, since) {
9078                    (Some(narrowed), _) => (narrowed, &query.project),
9079                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
9080                    (None, None) => (self.board().await?.items, &query.project),
9081                }
9082            }
9083        };
9084        // Filtered before paged: a page of a filtered result is a page of the survivors,
9085        // never the survivors of a page.
9086        let mut tasks = Vec::new();
9087        for item in held
9088            .iter()
9089            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9090        {
9091            let task = item.task()?;
9092            if task_matches(&task, query, membership)
9093                && self.commented_since(item, query.commented_since).await?
9094            {
9095                tasks.push(task);
9096            }
9097        }
9098        Ok(offset_page(
9099            tasks,
9100            numeric_cursor(page.cursor.as_ref())?,
9101            page.limit.min(MAX_PAGE_SIZE) as usize,
9102        ))
9103    }
9104    async fn query_projects(
9105        &self,
9106        query: &ProjectQuery,
9107        page: &PageRequest,
9108    ) -> Result<Page<Project>, SourceError> {
9109        validate_page(page)?;
9110        refuse_unsearchable_text(query.text.as_ref())?;
9111        // The projects a board holds are found by an issue search scoped to that board,
9112        // never by walking the board's own item connection: what tells a project from a
9113        // task is the `parent` each issue carries, which costs nothing to read. A query
9114        // carrying a text asks that search for the text too, so it reads the issues that
9115        // hold it rather than every issue of the board.
9116        let held = match self.text_searched(query.text.as_ref()).await? {
9117            Some(searched) => searched,
9118            None => self.board_issues().await?,
9119        };
9120        let projects = held
9121            .iter()
9122            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9123            .map(Resolved::project)
9124            .filter(|project| project_matches(project, query))
9125            .collect();
9126        Ok(offset_page(
9127            projects,
9128            numeric_cursor(page.cursor.as_ref())?,
9129            page.limit.min(MAX_PAGE_SIZE) as usize,
9130        ))
9131    }
9132    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
9133        Ok(self
9134            .item_by_id(id)
9135            .await?
9136            .filter(|item| item.kind == BoardKind::Document)
9137            .map(|item| item.document()))
9138    }
9139    async fn query_documents(
9140        &self,
9141        query: &DocumentQuery,
9142        page: &PageRequest,
9143    ) -> Result<Page<Document>, SourceError> {
9144        validate_page(page)?;
9145        // Narrowed to one project, this is the same sub-issue read a task list scoped to
9146        // that project makes — a document filed under a project is a sub-issue of it too,
9147        // and which of them come back is the kind this caller asked for. Unscoped, a query
9148        // carrying a text asks the board-scoped issue search for it, as a task query does,
9149        // and only one carrying none reads the board.
9150        let (held, membership) = match &query.project {
9151            ProjectFilter::Is(project) => (
9152                self.project_children(project).await?,
9153                // Answered by where these items came from; see `task_matches`.
9154                &ProjectFilter::Any,
9155            ),
9156            ProjectFilter::Any | ProjectFilter::Orphans => {
9157                refuse_unsearchable_text(query.text.as_ref())?;
9158                match self.text_searched(query.text.as_ref()).await? {
9159                    Some(searched) => (searched, &query.project),
9160                    None => (self.board().await?.items, &query.project),
9161                }
9162            }
9163        };
9164        // Filtered before paged, exactly as a task read is: a page of a filtered result is
9165        // a page of the survivors, never the survivors of a page.
9166        let documents = held
9167            .iter()
9168            .filter(|item| item.kind == BoardKind::Document)
9169            .map(Resolved::document)
9170            .filter(|document| document_matches(document, query, membership))
9171            .collect();
9172        Ok(offset_page(
9173            documents,
9174            numeric_cursor(page.cursor.as_ref())?,
9175            page.limit.min(MAX_PAGE_SIZE) as usize,
9176        ))
9177    }
9178    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
9179        validate_page(page)?;
9180        let offset = numeric_cursor(page.cursor.as_ref())?;
9181        let mut labels = self
9182            .board()
9183            .await?
9184            .items
9185            .into_iter()
9186            .flat_map(|item| item.labels)
9187            .fold(Vec::new(), |mut all, label| {
9188                if !all.iter().any(|x: &Label| x.id == label.id) {
9189                    all.push(label);
9190                }
9191                all
9192            });
9193        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
9194        Ok(offset_page(
9195            labels,
9196            offset,
9197            page.limit.min(MAX_PAGE_SIZE) as usize,
9198        ))
9199    }
9200    async fn task_dependencies(
9201        &self,
9202        id: &NativeId,
9203        direction: Direction,
9204        page: &PageRequest,
9205    ) -> Result<Page<DependencyEdge>, SourceError> {
9206        self.dependencies(id, ItemKind::Task, direction, page).await
9207    }
9208    async fn project_dependencies(
9209        &self,
9210        id: &NativeId,
9211        direction: Direction,
9212        page: &PageRequest,
9213    ) -> Result<Page<DependencyEdge>, SourceError> {
9214        self.dependencies(id, ItemKind::Project, direction, page)
9215            .await
9216    }
9217
9218    fn writes(&self) -> WriteSupport {
9219        WriteSupport::Supported
9220    }
9221
9222    /// Create or update one task.
9223    ///
9224    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9225    /// neither may name the task itself or name one task twice — and land in the body's
9226    /// metadata slot under their reserved keys, in place of any caller metadata of those
9227    /// names.
9228    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9229        self.write_task_assets(write, None)
9230            .await
9231            .map(|written| written.id)
9232    }
9233
9234    async fn write_task_with_assets(
9235        &self,
9236        write: &ItemWrite<Task>,
9237        _answers: Option<&BTreeMap<String, Value>>,
9238        assets: &onetaskgraph_plugin_api::AssetWrite,
9239    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9240        self.write_task_assets(write, Some(assets)).await
9241    }
9242
9243    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9244        self.write_item(
9245            &Incoming {
9246                written: Written::Work(ItemKind::Project, &write.item.status),
9247                title: &write.item.title,
9248                content: write.item.content.as_deref(),
9249                assets: None,
9250                labels: &write.item.labels,
9251                metadata: &write.item.metadata,
9252                repositories: &write.item.repositories,
9253                parent: None,
9254                delivers: &[],
9255                delivered_by: &[],
9256                priority: None,
9257            },
9258            write.target.as_ref(),
9259            &write.depends_on,
9260        )
9261        .await
9262        .map(|written| written.id)
9263    }
9264
9265    /// Create or update one document, which is one issue titled the way this board spells
9266    /// a document.
9267    ///
9268    /// Everything else is exactly a task write: caller metadata goes to the same canonical
9269    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9270    /// or a field this board cannot carry is refused by name rather than dropped, a target
9271    /// naming an issue this board does not hold is refused rather than created, and an
9272    /// issue this call created is taken back when the rest of the write fails.
9273    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9274        self.write_document_assets(write, None)
9275            .await
9276            .map(|written| written.id)
9277    }
9278
9279    async fn write_document_with_assets(
9280        &self,
9281        write: &ItemWrite<Document>,
9282        _answers: Option<&BTreeMap<String, Value>>,
9283        assets: &onetaskgraph_plugin_api::AssetWrite,
9284    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9285        self.write_document_assets(write, Some(assets)).await
9286    }
9287
9288    /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9289    /// which reads nothing; then the board's `Status` option. Over an existing item that is
9290    /// read off the item, as the write reads it, and the item is held among this command's
9291    /// resolved records so the write that follows reuses that read rather than repeating it;
9292    /// an item that does not carry the field takes the board's fields, which are held once
9293    /// read. A create is checked against the board's fields only when this command already
9294    /// holds them, because a create reads them together with its repository, in one request,
9295    /// and refuses a missing option before it writes anything.
9296    async fn check_status_write(
9297        &self,
9298        kind: ItemKind,
9299        category: StatusCategory,
9300        target: Option<&NativeId>,
9301    ) -> Result<(), SourceError> {
9302        let status = self.resolved_target(kind, category)?;
9303        if status.option().is_none() {
9304            return Ok(());
9305        }
9306        let fields = match target {
9307            Some(target) => {
9308                // A target this board does not hold is the write's own refusal to make.
9309                let Some(item) = self.bound_item(target).await? else {
9310                    return Ok(());
9311                };
9312                self.resolved_cache()?.insert(target.clone(), item.clone());
9313                self.fields_for(Some(&item), true, false).await?.fields
9314            }
9315            None => {
9316                let held = self
9317                    .board_cache()?
9318                    .as_ref()
9319                    .map(|board| board.fields.clone());
9320                match held.or_else(|| {
9321                    self.fields_cache()
9322                        .ok()
9323                        .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9324                }) {
9325                    Some(fields) => fields,
9326                    None => return Ok(()),
9327                }
9328            }
9329        };
9330        self.column_for(&fields, kind, category, &status)
9331            .map(|_| ())
9332    }
9333
9334    /// Set one task's status alone.
9335    ///
9336    /// An open target reopens a closed issue with an `updateIssue` carrying only its
9337    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9338    /// terminal target selects its mapped option, then closes with its fixed reason. No
9339    /// request carries a title, a body or a label. The status
9340    /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9341    /// what a re-read reports.
9342    async fn set_task_status(
9343        &self,
9344        id: &NativeId,
9345        category: StatusCategory,
9346    ) -> Result<Option<Status>, SourceError> {
9347        self.set_status(id, category).await
9348    }
9349
9350    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9351    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9352    /// for `none`. Refused by an instance with no `priority_mapping`.
9353    async fn set_task_priority(
9354        &self,
9355        id: &NativeId,
9356        priority: Priority,
9357    ) -> Result<Option<Priority>, SourceError> {
9358        self.set_priority(id, priority).await
9359    }
9360
9361    /// Replace one task's content with a single body update that keeps the metadata slot
9362    /// byte for byte.
9363    async fn set_task_content(
9364        &self,
9365        id: &NativeId,
9366        content: &str,
9367    ) -> Result<Option<()>, SourceError> {
9368        self.replace_content(id, content).await
9369    }
9370
9371    /// Replace one task issue's content and its provenance slot entry with a single body
9372    /// update. The answers are not kept: see `replace_rendering`.
9373    async fn set_task_rendering(
9374        &self,
9375        id: &NativeId,
9376        content: &str,
9377        provenance: &Value,
9378        _answers: &BTreeMap<String, Value>,
9379    ) -> Result<Option<()>, SourceError> {
9380        self.replace_rendering(
9381            id,
9382            BoardKind::Work(ItemKind::Task),
9383            content,
9384            provenance,
9385            None,
9386        )
9387        .await
9388        .map(|written| written.map(|_| ()))
9389    }
9390
9391    /// Replace one design-document issue's content and its provenance slot entry, on exactly
9392    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9393    async fn set_document_rendering(
9394        &self,
9395        id: &NativeId,
9396        content: &str,
9397        provenance: &Value,
9398        _answers: &BTreeMap<String, Value>,
9399    ) -> Result<Option<()>, SourceError> {
9400        self.replace_rendering(id, BoardKind::Document, content, provenance, None)
9401            .await
9402            .map(|written| written.map(|_| ()))
9403    }
9404
9405    /// Replace one project issue's content and its provenance slot entry, on exactly the
9406    /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9407    async fn set_project_rendering(
9408        &self,
9409        id: &NativeId,
9410        content: &str,
9411        provenance: &Value,
9412        _answers: &BTreeMap<String, Value>,
9413    ) -> Result<Option<()>, SourceError> {
9414        self.replace_rendering(
9415            id,
9416            BoardKind::Work(ItemKind::Project),
9417            content,
9418            provenance,
9419            None,
9420        )
9421        .await
9422        .map(|written| written.map(|_| ()))
9423    }
9424
9425    /// Apply a targeted update with one read of the item and a write only for what differs:
9426    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9427    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9428    async fn update_task(
9429        &self,
9430        id: &NativeId,
9431        update: &TaskUpdate,
9432    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9433        self.targeted_update(id, update).await
9434    }
9435
9436    /// Replace one task's `delivered_by` with a single body update that changes the
9437    /// metadata slot and nothing outside it.
9438    async fn set_delivered_by(
9439        &self,
9440        id: &NativeId,
9441        delivered_by: &[TaskRef],
9442    ) -> Result<Option<()>, SourceError> {
9443        self.replace_delivered_by(id, delivered_by).await
9444    }
9445
9446    /// Set one key of one task issue's metadata with a single body update that changes the
9447    /// metadata slot and nothing outside it — no title, label, state or board field request —
9448    /// and sends nothing when the task already holds that value under the key.
9449    async fn set_task_metadata(
9450        &self,
9451        id: &NativeId,
9452        key: &MetadataKey,
9453        value: &Value,
9454    ) -> Result<Option<Task>, SourceError> {
9455        Ok(self
9456            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9457            .await?
9458            .map(|item| item.task())
9459            .transpose()?)
9460    }
9461
9462    /// Set one key of one project issue's metadata, on exactly the terms of
9463    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9464    async fn set_project_metadata(
9465        &self,
9466        id: &NativeId,
9467        key: &MetadataKey,
9468        value: &Value,
9469    ) -> Result<Option<Project>, SourceError> {
9470        Ok(self
9471            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9472            .await?
9473            .map(|item| item.project()))
9474    }
9475
9476    /// Set one key of one design-document issue's metadata, on exactly the terms of
9477    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9478    async fn set_document_metadata(
9479        &self,
9480        id: &NativeId,
9481        key: &MetadataKey,
9482        value: &Value,
9483    ) -> Result<Option<Document>, SourceError> {
9484        Ok(self
9485            .set_slot_key(id, BoardKind::Document, key, value)
9486            .await?
9487            .map(|item| item.document()))
9488    }
9489
9490    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9491        self.delete_item(id).await
9492    }
9493
9494    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9495        self.delete_item(id).await
9496    }
9497
9498    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9499        self.delete_item(id).await
9500    }
9501
9502    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9503    ///
9504    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9505    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9506    ///
9507    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9508    /// board is the read of its comments. A draft this process already resolved is refused
9509    /// without one.
9510    async fn task_comments(
9511        &self,
9512        task: &NativeId,
9513        page: &PageRequest,
9514    ) -> Result<Option<Page<Comment>>, SourceError> {
9515        validate_page(page)?;
9516        let cached = self.resolved_cache()?.get(task).cloned();
9517        if let Some(item) = cached {
9518            if item.kind != BoardKind::Work(ItemKind::Task) {
9519                return Ok(None);
9520            }
9521            if item.content_kind == ContentKind::DraftIssue {
9522                return Err(self.draft_has_no_comments(task));
9523            }
9524        }
9525        match self.issue_detail(task, page).await? {
9526            Some(TaskDetailRead {
9527                comments: Some(comments),
9528                ..
9529            }) => comments,
9530            _ => Ok(None),
9531        }
9532    }
9533
9534    /// Every id's task, with the first page of its comments when `comments` names it:
9535    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9536    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9537    async fn get_task_details(
9538        &self,
9539        ids: &[NativeId],
9540        comments: Option<&PageRequest>,
9541    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9542        if let Some(page) = comments
9543            && let Err(error) = validate_page(page)
9544        {
9545            return ids.iter().map(|_| Err(error.clone())).collect();
9546        }
9547        match (ids, comments) {
9548            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9549            ([id], None) => vec![self.task_read(id).await],
9550            _ => self.issue_details(ids, comments).await,
9551        }
9552    }
9553
9554    /// Add one comment to the task's issue, as the account the token belongs to.
9555    ///
9556    /// The author is refused before anything is sent — not even the task is read — because
9557    /// no answer GitHub could give would make posting under another name than the one asked
9558    /// for the right outcome.
9559    async fn add_comment(
9560        &self,
9561        task: &NativeId,
9562        comment: &NewComment,
9563    ) -> Result<Option<Comment>, SourceError> {
9564        if let Some(author) = &comment.author {
9565            return Err(SourceError::Refused {
9566                message: format!(
9567                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9568                     the token signs in as the author of every comment; next: leave --author \
9569                     out, and the comment is posted as that account",
9570                    self.name
9571                ),
9572            });
9573        }
9574        let Some(issue) = self.commented_issue(task).await? else {
9575            return Ok(None);
9576        };
9577        let data = self
9578            .graphql(
9579                graphql::ADD_COMMENT,
9580                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9581            )
9582            .await?;
9583        let subject = data
9584            .pointer("/addComment/subject")
9585            .filter(|value| !value.is_null())
9586            .ok_or_else(|| SourceError::Malformed {
9587                message: "GitHub comment addition returned no subject".into(),
9588            })?;
9589        if required_str(subject, "id")? != issue.0 {
9590            return Err(SourceError::Malformed {
9591                message: "GitHub comment addition answered about another issue".into(),
9592            });
9593        }
9594        let added = data
9595            .pointer("/addComment/commentEdge/node")
9596            .filter(|value| !value.is_null())
9597            .ok_or_else(|| SourceError::Malformed {
9598                message: "GitHub comment addition returned no comment".into(),
9599            })?;
9600        let added = comment_from(added)?;
9601        self.remember_commented(&issue)?;
9602        Ok(Some(added))
9603    }
9604
9605    async fn edit_comment(
9606        &self,
9607        task: &NativeId,
9608        comment: &NativeId,
9609        body: &CommentBody,
9610    ) -> Result<Option<Comment>, SourceError> {
9611        let Some(issue) = self.commented_issue(task).await? else {
9612            return Ok(None);
9613        };
9614        if !self.comment_is_on(&issue, comment).await? {
9615            return Ok(None);
9616        }
9617        let data = self
9618            .graphql(
9619                graphql::UPDATE_COMMENT,
9620                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9621            )
9622            .await?;
9623        let edited = data
9624            .pointer("/updateIssueComment/issueComment")
9625            .filter(|value| !value.is_null())
9626            .ok_or_else(|| SourceError::Malformed {
9627                message: "GitHub comment update returned no comment".into(),
9628            })?;
9629        let edited = comment_from(edited)?;
9630        if edited.id != *comment {
9631            return Err(SourceError::Malformed {
9632                message: "GitHub comment update returned the wrong comment".into(),
9633            });
9634        }
9635        self.remember_commented(&issue)?;
9636        Ok(Some(edited))
9637    }
9638
9639    async fn delete_comment(
9640        &self,
9641        task: &NativeId,
9642        comment: &NativeId,
9643    ) -> Result<Option<NativeId>, SourceError> {
9644        let Some(issue) = self.commented_issue(task).await? else {
9645            return Ok(None);
9646        };
9647        if !self.comment_is_on(&issue, comment).await? {
9648            return Ok(None);
9649        }
9650        let data = self
9651            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9652            .await?;
9653        // The payload says nothing about the comment it removed, so what is checked is that
9654        // GitHub answered the mutation at all rather than leaving it unanswered.
9655        data.get("deleteIssueComment")
9656            .filter(|value| !value.is_null())
9657            .ok_or_else(|| SourceError::Malformed {
9658                message: "GitHub comment deletion returned no payload".into(),
9659            })?;
9660        Ok(Some(comment.clone()))
9661    }
9662
9663    /// Every request this source has recorded, and what each of GitHub's two budgets was
9664    /// attributed — read off the same accounting the session report is rendered from, so
9665    /// the two cannot count one request two ways.
9666    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9667        Ok(Some(self.ledger.snapshot().metering()))
9668    }
9669
9670    /// Drop every item, search answer and board read this source holds, so the next command
9671    /// reads the board as a person has since left it.
9672    ///
9673    /// Every one of those is held on the assumption that nothing but this source writes the
9674    /// board while a command runs, which stops being true the moment the command is over: a
9675    /// body a person edited would be overwritten from the record held here, and a card they
9676    /// moved would be read as still where this source left it. The board's own field
9677    /// definitions go too, because a person can add or delete a `Status` option and a write
9678    /// resolved against the held list would not re-read on a miss. What stays is what stays
9679    /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9680    /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9681    /// accounting [`metering`](TaskSource::metering) answers from.
9682    ///
9683    /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9684    /// refused, because clearing it is what puts it right.
9685    async fn end_command(&self) -> Result<(), SourceError> {
9686        fn clear<T: Default>(held: &Mutex<T>) {
9687            *held
9688                .lock()
9689                .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9690            held.clear_poison();
9691        }
9692        clear(&self.created);
9693        clear(&self.updated);
9694        clear(&self.commented);
9695        clear(&self.board_cache);
9696        clear(&self.search_cache);
9697        clear(&self.narrowed_cache);
9698        clear(&self.search_next);
9699        clear(&self.resolved_cache);
9700        clear(&self.fields_cache);
9701        Ok(())
9702    }
9703}
9704
9705/// One issue comment as the contract carries it.
9706///
9707/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9708/// and when it answers an actor with no login, because either way the source did not say who
9709/// wrote it — which is what an absent author means, rather than an author called nothing.
9710fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9711    Ok(Comment {
9712        id: NativeId(required_str(value, "id")?.to_owned()),
9713        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9714            .map(str::to_owned),
9715        created_at: optional_time(value, "createdAt")?,
9716        updated_at: optional_time(value, "updatedAt")?,
9717        body: required_str(value, "body")?.to_owned(),
9718        url: optional_str(value, "url")?.map(str::to_owned),
9719    })
9720}
9721
9722/// The page of comments one issue node carries, resumed from `after`.
9723fn comment_page(
9724    node: &Value,
9725    issue: &str,
9726    after: Option<&str>,
9727) -> Result<Page<Comment>, SourceError> {
9728    let connection = node
9729        .get("comments")
9730        .filter(|value| !value.is_null())
9731        .ok_or_else(|| SourceError::Malformed {
9732            message: format!("GitHub issue {issue} answered with no comments connection"),
9733        })?;
9734    let items = optional_nodes(Some(connection), "issue comments")?
9735        .into_iter()
9736        .flatten()
9737        .map(comment_from)
9738        .collect::<Result<Vec<_>, _>>()?;
9739    let next = next_cursor(connection)?;
9740    if let Some(next) = &next {
9741        validate_cursor_progress(after, &next.0)?;
9742    }
9743    Ok(Page { items, next })
9744}
9745
9746/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9747/// end — `None` when it carried none, or a page with more past it.
9748fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9749    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9750        return Ok(None);
9751    };
9752    if next_cursor(connection)?.is_some() {
9753        return Ok(None);
9754    }
9755    Ok(Some(
9756        optional_nodes(Some(connection), "blocked-by issues")?
9757            .into_iter()
9758            .flatten()
9759            .cloned()
9760            .collect(),
9761    ))
9762}
9763
9764/// Where the recorded tail of a dependency walk resumes; see
9765/// [`GitHubProjectsSource::recorded_edges`].
9766const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9767
9768/// The board text field this source keeps a copy's origin in.
9769///
9770/// Named after the key it holds, and held to that name by the guard below rather than by
9771/// a reader noticing.
9772const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9773
9774/// The metadata key that field holds.
9775///
9776/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9777/// constructs or interprets the qualified id it carries. This source names it only to
9778/// route it — a short, typed value belongs in a typed field rather than in the body slot
9779/// a caller's own prose shares.
9780///
9781/// Restated rather than imported, because no plugin crate may depend on the engine. What
9782/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9783/// target in `check`: it reads the engine's own literal and fails naming the file and the
9784/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9785/// that creates a second item every run instead of finding the one it wrote — and that is
9786/// too late to learn it.
9787const ORIGIN_KEY: &str = "onetaskgraph.origin";
9788
9789/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9790///
9791/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9792/// is derived from the far end, never written down on the near item — so only a forward
9793/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9794/// it did not come from, and it is told so rather than answered with an empty page that
9795/// reads as a walk which ended.
9796fn recorded_offset(
9797    cursor: Option<&str>,
9798    direction: Direction,
9799) -> Result<Option<usize>, SourceError> {
9800    cursor
9801        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9802        .map(|offset| {
9803            if direction != Direction::DependsOn {
9804                return Err(SourceError::Config {
9805                    message: format!(
9806                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9807                         reverse dependency read never issues; resume it in the direction \
9808                         that reported it"
9809                    ),
9810                });
9811            }
9812            offset.parse().map_err(|_| SourceError::Config {
9813                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9814            })
9815        })
9816        .transpose()
9817}
9818
9819fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9820    let mut page = offset_page(edges, offset, limit.max(1));
9821    page.next = page
9822        .next
9823        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9824    page
9825}
9826
9827/// The kind of one issue reached through a dependency connection.
9828///
9829/// The same questions the board scan asks, over the fields the dependency document
9830/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9831/// then anything with sub-issues or the marker is a project.
9832///
9833/// # Errors
9834///
9835/// A far end this board holds as a document is refused rather than reported. The two
9836/// answers that are not refusals would both be wrong: reporting it as a task names an id
9837/// no task read of this source can find, and reporting it as a project names one no
9838/// project read can. There is no third value to return — `ItemKind` has no document
9839/// variant, because nothing may point at a document — so the relationship itself is what
9840/// the person is told about.
9841fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9842    let id = required_str(value, "id")?;
9843    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9844        return Err(SourceError::Refused {
9845            message: format!(
9846                "GitHub issue {id} is a document of this board — its title begins \
9847                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9848                 on by one; next: remove that issue's blocking relationship on this board"
9849            ),
9850        });
9851    }
9852    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9853    if parent.is_some() {
9854        return Ok(ItemKind::Task);
9855    }
9856    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9857    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9858        message: format!("GitHub issue {id}: {message}"),
9859    })?;
9860    let sub_issues = sub_issue_total(value)?;
9861    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9862        ItemKind::Project
9863    } else {
9864        ItemKind::Task
9865    })
9866}
9867
9868/// The `IssueStateUpdateInput` one status target asks for.
9869///
9870/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9871/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9872/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9873/// would report a change forever. A document has no status at all, and asks for neither.
9874fn state_input(target: Option<&StatusTarget>) -> Value {
9875    match target {
9876        Some(StatusTarget::Terminal(_, reason)) => {
9877            json!({"value":"CLOSED","stateReason":reason.reason()})
9878        }
9879        Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9880        // A document has no status, so a write of one says nothing about the issue's open
9881        // or closed state rather than forcing it open: `stateInput` is what carries that
9882        // instruction, and an explicit null asks for no change to it.
9883        None => Value::Null,
9884    }
9885}
9886
9887/// The metadata one write stores in the item's body slot.
9888///
9889/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9890/// rather than carried: the kind marker so an empty project stays readable, the
9891/// repository list only when it is not exactly the issue's own repository, and the far
9892/// ends no relationship here can name.
9893///
9894/// The copy origin is the one typed field that is also mirrored here, and only as a
9895/// mirror: it lands in the board's origin field as well, which stays the one every reader
9896/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9897/// and catches up with a write in seconds rather than minutes — can find the item by it.
9898/// A reader of the release before this one drops the slot's copy and reads the field, so an
9899/// item written here still reads with exactly one origin there.
9900fn slot_metadata(
9901    incoming: &Incoming<'_>,
9902    own_repository: Option<&Repository>,
9903    fallback: &[DependencyEdge],
9904) -> BTreeMap<String, Value> {
9905    let mut metadata = incoming.metadata.clone();
9906    match metadata.remove(ORIGIN_KEY) {
9907        Some(Value::String(origin)) if !origin.is_empty() => {
9908            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9909        }
9910        _ => {}
9911    }
9912    match incoming.written.kind() {
9913        BoardKind::Work(kind) => metadata.insert(
9914            ItemKind::METADATA_KEY.to_owned(),
9915            Value::String(kind.marker().to_owned()),
9916        ),
9917        // A document is told by its title, so it carries no kind marker: that key names
9918        // what a dependency endpoint points at, and nothing may point at a document.
9919        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9920    };
9921    let derivable = own_repository
9922        .map(|own| incoming.repositories == [own.clone()])
9923        .unwrap_or(incoming.repositories.is_empty());
9924    if derivable {
9925        metadata.remove(Repository::METADATA_KEY);
9926    } else {
9927        metadata.insert(
9928            Repository::METADATA_KEY.to_owned(),
9929            Value::Array(
9930                incoming
9931                    .repositories
9932                    .iter()
9933                    .map(|repository| Value::String(repository.as_str().to_owned()))
9934                    .collect(),
9935            ),
9936        );
9937    }
9938    // The typed lists are what land, whatever the caller's own metadata held under their
9939    // keys: a key of either name travelling beside the field would otherwise be a second
9940    // answer to the same question, and the field is the one the contract names.
9941    for (key, entries) in [
9942        (TaskRef::DELIVERS_KEY, incoming.delivers),
9943        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9944    ] {
9945        set_task_list(&mut metadata, key, entries);
9946    }
9947    record_edges(&mut metadata, fallback);
9948    metadata
9949}
9950
9951/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9952/// one slot's metadata, or no such key when there are none.
9953fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9954    if fallback.is_empty() {
9955        metadata.remove(DependencyEdge::RECORDED_KEY);
9956    } else {
9957        metadata.insert(
9958            DependencyEdge::RECORDED_KEY.to_owned(),
9959            Value::Array(
9960                fallback
9961                    .iter()
9962                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9963                    .collect(),
9964            ),
9965        );
9966    }
9967}
9968
9969/// Every label one item carries, from its content's own connection and nowhere else.
9970///
9971/// There is no second place to read one from: no document this source sends selects the
9972/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9973/// cannot carry one at all. The module documentation records the three schema facts that
9974/// settle it.
9975fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9976    optional_nodes(content.get("labels"), "content labels")?
9977        .into_iter()
9978        .flatten()
9979        .map(|v| {
9980            Ok(Label {
9981                id: NativeId(required_str(v, "id")?.to_owned()),
9982                name: required_str(v, "name")?.to_owned(),
9983                color: optional_str(v, "color")?.map(str::to_owned),
9984            })
9985        })
9986        .collect()
9987}
9988
9989/// The definition of each board field one item's values are values of, in the shape a read
9990/// of the board's own `fields` gives one.
9991///
9992/// A value names its field through a fragment on that field's own type, so the type is
9993/// known from which kind of value it is: a single-select value's field is a
9994/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9995/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9996fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9997    field_values
9998        .iter()
9999        .filter_map(|value| {
10000            let field = value.get("field")?.as_object()?;
10001            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
10002            let typename = if value.get("text").is_some() {
10003                "ProjectV2Field"
10004            } else if value.get("name").is_some() {
10005                "ProjectV2SingleSelectField"
10006            } else {
10007                return None;
10008            };
10009            let mut defined = field.clone();
10010            defined.insert("__typename".to_owned(), json!(typename));
10011            Some(Value::Object(defined))
10012        })
10013        .collect()
10014}
10015
10016fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
10017    let Some(node) = field_values
10018        .iter()
10019        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
10020    else {
10021        return Ok(None);
10022    };
10023    Ok(optional_str(node, "text")?.map(str::to_owned))
10024}
10025
10026fn valid_github_owner(owner: &str) -> bool {
10027    !owner.is_empty()
10028        && owner.len() <= 39
10029        && !owner.starts_with('-')
10030        && !owner.ends_with('-')
10031        && !owner.contains("--")
10032        && owner
10033            .bytes()
10034            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
10035}
10036
10037/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
10038/// neither of the two names a path segment already means.
10039fn valid_github_repository_name(name: &str) -> bool {
10040    !name.is_empty()
10041        && name.len() <= 100
10042        && name != "."
10043        && name != ".."
10044        && name
10045            .bytes()
10046            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
10047}
10048
10049fn valid_environment_name(name: &str) -> bool {
10050    let mut bytes = name.bytes();
10051    bytes
10052        .next()
10053        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
10054        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
10055}
10056
10057/// How many sub-issues one issue has.
10058///
10059/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
10060/// absent or non-integer one is a response this source cannot read — and reading it as
10061/// zero would classify a project as a task, which is exactly the mistake the marker
10062/// exists to keep from happening quietly.
10063fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
10064    let summary = issue
10065        .get("subIssuesSummary")
10066        .ok_or_else(|| SourceError::Malformed {
10067            message: "GitHub issue is missing subIssuesSummary".into(),
10068        })?;
10069    summary
10070        .get("total")
10071        .and_then(Value::as_u64)
10072        .ok_or_else(|| SourceError::Malformed {
10073            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
10074        })
10075}
10076
10077/// One issue's own `number`.
10078///
10079/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
10080/// an issue in this module asks for it. So a read of one that comes back without it, or
10081/// with something that is not an unsigned integer, is a response this source cannot read —
10082/// absence here is **not** "this issue has no number". A draft is the content that has
10083/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
10084/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
10085fn issue_number(issue: &Value) -> Result<u64, SourceError> {
10086    issue
10087        .get("number")
10088        .and_then(Value::as_u64)
10089        .ok_or_else(|| SourceError::Malformed {
10090            message: "GitHub issue number is missing or is not an unsigned integer".into(),
10091        })
10092}
10093
10094/// The `number` a creating mutation answered with, and `None` when it answered without one;
10095/// why a missing one is tolerated is at the call in `create_and_file_issue`.
10096fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
10097    match created.get("number") {
10098        None | Some(Value::Null) => Ok(None),
10099        Some(value) => value
10100            .as_u64()
10101            .map(Some)
10102            .ok_or_else(|| SourceError::Malformed {
10103                message: "GitHub created issue number is not an unsigned integer".into(),
10104            }),
10105    }
10106}
10107
10108fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10109    value
10110        .get(field)
10111        .and_then(Value::as_str)
10112        .ok_or_else(|| SourceError::Malformed {
10113            message: format!("GitHub response is missing string field {field}"),
10114        })
10115}
10116
10117fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10118    let found = required_str(value, field)?;
10119    if found.trim().is_empty() {
10120        return Err(SourceError::Malformed {
10121            message: format!("GitHub response has blank string field {field}"),
10122        });
10123    }
10124    Ok(found)
10125}
10126
10127/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
10128/// needs one — Linear spells them too, in its own description field.
10129///
10130/// Restated rather than shared, because a plugin crate depends on the contract crate and
10131/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
10132/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
10133/// source round-trips its own writes perfectly well under its own spelling.
10134const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
10135const METADATA_CLOSE: &str = "\n-->";
10136
10137/// What the composer puts between a non-empty visible body and the slot, and the one thing
10138/// the parser takes off the visible body when it takes the slot off — exactly once, so every
10139/// other trailing byte of the body comes back as it was written.
10140// 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.
10141const METADATA_SEPARATOR: &str = "\n\n";
10142
10143/// The visible body and the metadata slot at the end of it.
10144///
10145/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
10146/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
10147/// own content and is left alone. The visible body is everything before the slot less the
10148/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
10149fn metadata_body(
10150    body: Option<String>,
10151) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
10152    let Some(body) = body else {
10153        return Ok((None, BTreeMap::new()));
10154    };
10155    let Some(slot) = slot_span(&body)? else {
10156        return Ok((Some(body), BTreeMap::new()));
10157    };
10158    let metadata =
10159        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
10160            SourceError::Malformed {
10161                message: format!(
10162                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
10163                ),
10164            }
10165        })?;
10166    let before = &body[..slot.start];
10167    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
10168    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
10169}
10170
10171/// Where the metadata slot sits in one body, as byte offsets into it.
10172struct SlotSpan {
10173    /// Where [`METADATA_OPEN`] begins.
10174    start: usize,
10175    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
10176    encoded_start: usize,
10177    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
10178    encoded_end: usize,
10179    /// Just past [`METADATA_CLOSE`].
10180    end: usize,
10181}
10182
10183/// The slot at the very end of `body`, or `None` when it has none.
10184///
10185/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
10186/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
10187/// slot.
10188fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
10189    let Some(start) = body.rfind(METADATA_OPEN) else {
10190        return Ok(None);
10191    };
10192    let encoded_start = start + METADATA_OPEN.len();
10193    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
10194        return Err(SourceError::Malformed {
10195            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
10196        });
10197    };
10198    let encoded_end = encoded_start + relative_end;
10199    let end = encoded_end + METADATA_CLOSE.len();
10200    if !body[end..].trim().is_empty() {
10201        return Ok(None);
10202    }
10203    Ok(Some(SlotSpan {
10204        start,
10205        encoded_start,
10206        encoded_end,
10207        end,
10208    }))
10209}
10210
10211/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10212/// slot as it was.
10213///
10214/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10215/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10216/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10217/// or alone in an empty body — and a body with no slot that is given no metadata is
10218/// returned as it is.
10219fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10220    let encoded = if metadata.is_empty() {
10221        None
10222    } else {
10223        Some(
10224            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10225                message: error.to_string(),
10226            })?,
10227        )
10228    };
10229    Ok(match (slot_span(body)?, encoded) {
10230        (Some(slot), Some(encoded)) => format!(
10231            "{}{encoded}{}",
10232            &body[..slot.encoded_start],
10233            &body[slot.encoded_end..]
10234        ),
10235        (Some(slot), None) => {
10236            let before = &body[..slot.start];
10237            format!(
10238                "{}{}",
10239                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10240                &body[slot.end..]
10241            )
10242        }
10243        (None, None) => body.to_owned(),
10244        (None, Some(encoded)) if body.is_empty() => {
10245            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10246        }
10247        (None, Some(encoded)) => {
10248            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10249        }
10250    })
10251}
10252
10253/// `body` with everything before its metadata slot replaced by `content`, and the slot
10254/// itself kept byte for byte.
10255///
10256/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10257/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10258/// `content` is empty — so a read of the result reports `content` as the visible body and
10259/// the slot's metadata exactly as it was.
10260fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10261    let Some(slot) = slot_span(body)? else {
10262        return Ok(content.to_owned());
10263    };
10264    let kept = &body[slot.start..];
10265    Ok(if content.is_empty() {
10266        kept.to_owned()
10267    } else {
10268        format!("{content}{METADATA_SEPARATOR}{kept}")
10269    })
10270}
10271
10272/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10273fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10274    if entries.is_empty() {
10275        metadata.remove(key);
10276    } else {
10277        metadata.insert(
10278            key.to_owned(),
10279            Value::Array(
10280                entries
10281                    .iter()
10282                    .map(|entry| Value::String(entry.as_str().to_owned()))
10283                    .collect(),
10284            ),
10285        );
10286    }
10287}
10288
10289fn compose_body(
10290    content: Option<&str>,
10291    metadata: &BTreeMap<String, Value>,
10292) -> Result<Option<String>, SourceError> {
10293    let visible = content.unwrap_or_default();
10294    if metadata.is_empty() {
10295        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10296    }
10297    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10298        message: error.to_string(),
10299    })?;
10300    Ok(Some(if visible.is_empty() {
10301        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10302    } else {
10303        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10304    }))
10305}
10306
10307fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10308    value
10309        .get(field)
10310        .and_then(Value::as_bool)
10311        .ok_or_else(|| SourceError::Malformed {
10312            message: format!("GitHub response is missing boolean field {field}"),
10313        })
10314}
10315fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10316    match value.get(field) {
10317        None | Some(Value::Null) => Ok(None),
10318        Some(value) => value
10319            .as_str()
10320            .map(Some)
10321            .ok_or_else(|| SourceError::Malformed {
10322                message: format!("GitHub response field {field} is not a string or null"),
10323            }),
10324    }
10325}
10326fn optional_nodes<'a>(
10327    connection: Option<&'a Value>,
10328    name: &str,
10329) -> Result<Option<&'a Vec<Value>>, SourceError> {
10330    match connection {
10331        None | Some(Value::Null) => Ok(None),
10332        Some(value) => value
10333            .get("nodes")
10334            .and_then(Value::as_array)
10335            .map(Some)
10336            .ok_or_else(|| SourceError::Malformed {
10337                message: format!("GitHub {name}.nodes is not an array"),
10338            }),
10339    }
10340}
10341fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10342    let page_info = connection
10343        .get("pageInfo")
10344        .ok_or_else(|| SourceError::Malformed {
10345            message: format!("GitHub {name} has no pageInfo"),
10346        })?;
10347    if required_bool(page_info, "hasNextPage")? {
10348        return Err(SourceError::Malformed {
10349            message: format!(
10350                "GitHub {name} exceeds the supported nested connection size of {size}"
10351            ),
10352        });
10353    }
10354    Ok(())
10355}
10356fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10357    optional_str(value, field)?
10358        .map(|timestamp| {
10359            timestamp.parse().map_err(|error| SourceError::Malformed {
10360                message: format!("GitHub response field {field} is not a timestamp: {error}"),
10361            })
10362        })
10363        .transpose()
10364}
10365fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10366    if page.limit == 0 {
10367        Err(SourceError::Config {
10368            message: "page limit must be at least 1".into(),
10369        })
10370    } else {
10371        Ok(())
10372    }
10373}
10374fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10375    let page = connection
10376        .get("pageInfo")
10377        .filter(|value| value.is_object())
10378        .ok_or_else(|| SourceError::Malformed {
10379            message: "GitHub connection is missing pageInfo".into(),
10380        })?;
10381    if required_bool(page, "hasNextPage")? {
10382        let cursor = required_str(page, "endCursor")?;
10383        validate_cursor_progress(None, cursor)?;
10384        Ok(Some(Cursor(cursor.into())))
10385    } else {
10386        Ok(None)
10387    }
10388}
10389fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10390    if next.is_empty() || previous == Some(next) {
10391        Err(SourceError::Malformed {
10392            message: "GitHub pagination cursor is empty or did not advance".into(),
10393        })
10394    } else {
10395        Ok(())
10396    }
10397}
10398/// The version of this plugin's opaque narrowing-search cursor.
10399pub const SEARCH_CURSOR_VERSION: u32 = 4;
10400
10401#[derive(Serialize, Deserialize)]
10402#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10403enum SearchConnection {
10404    Initial {},
10405    Continuing { after: Cursor },
10406    Exhausted {},
10407}
10408impl SearchConnection {
10409    fn after(&self) -> Option<&str> {
10410        match self {
10411            Self::Continuing { after } => Some(&after.0),
10412            _ => None,
10413        }
10414    }
10415    fn exhausted(&self) -> bool {
10416        matches!(self, Self::Exhausted { .. })
10417    }
10418    /// Whether a cursor naming this position, `offset` rows into its page, is one this
10419    /// plugin could have handed out: a page is resumed only part of the way through it — an
10420    /// offset of a whole page or more would skip rows nobody was given — an initial page
10421    /// only once some of it was handed out, and an exhausted connection has no page to be
10422    /// part of the way through.
10423    fn valid_resume(&self, offset: usize) -> bool {
10424        let within = offset < SEARCH_PAGE_SIZE as usize;
10425        match self {
10426            Self::Initial { .. } => offset > 0 && within,
10427            Self::Continuing { after } => !after.0.is_empty() && within,
10428            Self::Exhausted { .. } => offset == 0,
10429        }
10430    }
10431}
10432
10433/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10434#[derive(Serialize, Deserialize)]
10435#[serde(deny_unknown_fields)]
10436struct SearchPosition {
10437    version: u32,
10438    connection: SearchConnection,
10439    /// How many rows of the page `connection` starts were already handed out.
10440    #[serde(default, skip_serializing_if = "is_zero")]
10441    offset: usize,
10442    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10443    seen: Vec<NativeId>,
10444    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10445    own: Vec<NativeId>,
10446}
10447impl Default for SearchPosition {
10448    fn default() -> Self {
10449        Self {
10450            version: SEARCH_CURSOR_VERSION,
10451            connection: SearchConnection::Initial {},
10452            offset: 0,
10453            seen: Vec::new(),
10454            own: Vec::new(),
10455        }
10456    }
10457}
10458
10459fn is_zero(offset: &usize) -> bool {
10460    *offset == 0
10461}
10462
10463fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10464    cursor.map_or(Ok(0), |c| {
10465        c.0.parse().map_err(|_| SourceError::Config {
10466            message: "page cursor is invalid".into(),
10467        })
10468    })
10469}
10470fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10471    if offset > items.len() {
10472        return Page::last(vec![]);
10473    }
10474    let tail = items.split_off(offset);
10475    let mut selected = tail;
10476    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10477    selected.truncate(limit);
10478    Page {
10479        items: selected,
10480        next,
10481    }
10482}
10483
10484impl GitHubProjectsSource {
10485    async fn write_task_assets(
10486        &self,
10487        write: &ItemWrite<Task>,
10488        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10489    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10490        let near = write.target.as_ref().unwrap_or(&write.item.id);
10491        for (key, entries) in [
10492            (TaskRef::DELIVERS_KEY, &write.item.delivers),
10493            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
10494        ] {
10495            TaskRef::listed(key, near, Some(&self.name), entries.clone())
10496                .map_err(|message| SourceError::Refused { message })?;
10497        }
10498        if self.priorities.is_none() && write.item.priority != Priority::None {
10499            return Err(self.holds_no_priority());
10500        }
10501        self.write_item(
10502            &Incoming {
10503                written: Written::Work(ItemKind::Task, &write.item.status),
10504                title: &write.item.title,
10505                content: write.item.content.as_deref(),
10506                assets,
10507                labels: &write.item.labels,
10508                metadata: &write.item.metadata,
10509                repositories: &write.item.repositories,
10510                parent: write.item.project.as_ref(),
10511                delivers: &write.item.delivers,
10512                delivered_by: &write.item.delivered_by,
10513                priority: self.priorities.as_ref().map(|_| write.item.priority),
10514            },
10515            write.target.as_ref(),
10516            &write.depends_on,
10517        )
10518        .await
10519    }
10520    async fn write_document_assets(
10521        &self,
10522        write: &ItemWrite<Document>,
10523        assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10524    ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10525        // A document takes part in no dependency graph, so there is no far end to write
10526        // natively and none to record: a caller naming one is told so rather than having it
10527        // stored under the reserved key, where a later read would report an edge the
10528        // contract says cannot exist.
10529        if !write.depends_on.is_empty() {
10530            return Err(SourceError::Refused {
10531                message: format!(
10532                    "this write names {} dependencies for a document, and a document takes \
10533                     part in no dependency graph; next: put the dependency on the task or \
10534                     project the document is about",
10535                    write.depends_on.len()
10536                ),
10537            });
10538        }
10539        self.write_item(
10540            &Incoming {
10541                written: Written::Document,
10542                title: &write.item.title,
10543                content: write.item.content.as_deref(),
10544                assets,
10545                labels: &write.item.labels,
10546                metadata: &write.item.metadata,
10547                repositories: &write.item.repositories,
10548                parent: write.item.project.as_ref(),
10549                delivers: &[],
10550                delivered_by: &[],
10551                priority: None,
10552            },
10553            write.target.as_ref(),
10554            &[],
10555        )
10556        .await
10557    }
10558}
10559
10560/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10561/// itself, for the two things no journey can observe.
10562///
10563/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
10564/// with and without the call, that a settlement, a board listing and a metadata search each
10565/// read afresh after it — the resolved records, the written-item overlay, the board and its
10566/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10567/// because a status write naming an option a person deleted is refused the same whether or
10568/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10569/// while one of its locks is held. So these assert those directly, and every other holder
10570/// beside them so a holder added later without a clear in the call fails here.
10571#[cfg(test)]
10572mod end_command_tests {
10573    use super::*;
10574
10575    struct Token;
10576
10577    impl SecretResolver for Token {
10578        fn get(&self, var: &str) -> Option<SecretString> {
10579            (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10580        }
10581    }
10582
10583    fn source() -> GitHubProjectsSource {
10584        let config = serde_json::from_value(json!({
10585            "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10586            // Nothing here is sent: the source is only built and its state inspected.
10587            "endpoint": "http://127.0.0.1:9/graphql",
10588        }))
10589        .expect("a usable configuration");
10590        GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10591            .expect("the source builds")
10592    }
10593
10594    /// One issue as a board read answers it.
10595    fn resolved(source: &GitHubProjectsSource) -> Resolved {
10596        source
10597            .resolve(&json!({
10598                "id": "ITEM-1",
10599                "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10600                            "body": "what a person may since have edited", "state": "OPEN",
10601                            "stateReason": null, "url": null, "number": 1,
10602                            "subIssuesSummary": {"total": 0},
10603                            "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10604                "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10605            }))
10606            .expect("the item reads")
10607            .expect("an issue")
10608    }
10609
10610    /// Hold something in every holder the call clears, and the repository id it keeps.
10611    fn fill(source: &GitHubProjectsSource) {
10612        let item = resolved(source);
10613        source.created.lock().unwrap().push(item.clone());
10614        source.updated.lock().unwrap().push(item.clone());
10615        *source.board_cache.lock().unwrap() = Some(Board {
10616            id: "PVT-board".into(),
10617            fields: json!({"nodes": []}),
10618            items: vec![item.clone()],
10619        });
10620        *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10621        source
10622            .narrowed_cache
10623            .lock()
10624            .unwrap()
10625            .insert("status:todo".into(), vec![item.clone()]);
10626        source
10627            .search_next
10628            .lock()
10629            .unwrap()
10630            .insert("status:todo".into(), Some("cursor".into()));
10631        source
10632            .resolved_cache
10633            .lock()
10634            .unwrap()
10635            .insert(item.id.clone(), item);
10636        *source.fields_cache.lock().unwrap() = Some(BoardFields {
10637            id: BoardId::parse("PVT-board").unwrap(),
10638            fields: json!({"nodes": []}),
10639        });
10640        source
10641            .repository_cache
10642            .lock()
10643            .unwrap()
10644            .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10645    }
10646
10647    fn assert_dropped(source: &GitHubProjectsSource) {
10648        assert!(source.created().unwrap().is_empty(), "created");
10649        assert!(source.updated().unwrap().is_empty(), "updated");
10650        assert!(source.board_cache().unwrap().is_none(), "board");
10651        assert!(source.search_cache.lock().unwrap().is_none(), "search");
10652        assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10653        assert!(
10654            source.search_next.lock().unwrap().is_empty(),
10655            "search paging"
10656        );
10657        assert!(
10658            source.resolved_cache().unwrap().is_empty(),
10659            "resolved records"
10660        );
10661        assert!(source.fields_cache().unwrap().is_none(), "board fields");
10662        assert_eq!(
10663            source.repository_cache().unwrap().len(),
10664            1,
10665            "a repository's node id stays valid and is kept"
10666        );
10667    }
10668
10669    fn end(source: &GitHubProjectsSource) {
10670        tokio::runtime::Builder::new_current_thread()
10671            .build()
10672            .unwrap()
10673            .block_on(source.end_command())
10674            .expect("the command ends");
10675    }
10676
10677    #[test]
10678    fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10679        let source = source();
10680        fill(&source);
10681        end(&source);
10682        assert_dropped(&source);
10683    }
10684
10685    #[test]
10686    fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10687        fn poison<T: Send>(held: &Mutex<T>) {
10688            std::thread::scope(|scope| {
10689                let _ = scope
10690                    .spawn(|| {
10691                        let _guard = held.lock().unwrap();
10692                        panic!("a failure while the lock is held");
10693                    })
10694                    .join();
10695            });
10696            assert!(held.is_poisoned());
10697        }
10698        let source = source();
10699        fill(&source);
10700        poison(&source.created);
10701        poison(&source.updated);
10702        poison(&source.board_cache);
10703        poison(&source.search_cache);
10704        poison(&source.narrowed_cache);
10705        poison(&source.search_next);
10706        poison(&source.resolved_cache);
10707        poison(&source.fields_cache);
10708        assert!(
10709            source.resolved_cache().is_err(),
10710            "a poisoned lock is refused before the call"
10711        );
10712        end(&source);
10713        assert_dropped(&source);
10714    }
10715}